"""One-writer git — how the server owns the vault checkout while it runs.

Multi-client ≠ multi-writer (docs/V3-PLAN.md §1.7): every seat funnels through
this one process, so a single process-wide lock serializes all mutations, each
write lands as its own commit (+ fail-soft push), and a background pass
reconciles with the remote on an interval — a Python port of one
``vault-sync.sh`` tick (pull --rebase, abort on conflict, commit strays, push,
heartbeat). While the server runs it *supersedes* the standalone
``com.alexandria.vault-sync`` job: the heartbeat written here is what the shell
script checks before doing anything, so they never double-commit — and if the
server dies, the heartbeat goes stale and vault-sync resumes as the fallback.

Invariants shared with the sweep/sync: atomic writes (temp + ``os.replace``,
the engine's ``save_state`` pattern), plain commits, never wedge the tree (a
conflicted rebase is aborted, the next tick retries), and a vault that isn't a
git checkout degrades to lock-only (writes work, nothing to commit to).
"""
import json
import os
import subprocess
import threading
import time
from datetime import datetime, timezone
from pathlib import Path

from alexandria_vault.mcp.durability import make_backend

# Where the appliance's heartbeats live (mirrors vault-sync.sh / health_check:
# the directory holding $SWEEP_STATE, defaulting beside the sweep's state file).
def state_dir(env=None) -> Path:
    env = os.environ if env is None else env
    return Path(env.get("SWEEP_STATE")
                or (Path.home() / "Obsidian" / "secrets" / "sweep-state.json")).parent

HEARTBEAT_NAME = "vault-mcp-status.json"   # {pid, at} — vault-sync.sh checks this
SYNC_STATUS_NAME = "sync-status.json"      # same shape vault-sync.sh writes


def _utcnow() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


class VaultWriter:
    """Serialized, committed writes + the interval sync pass for one vault."""

    def __init__(self, vault, config=None, env=None):
        self.vault = Path(vault).resolve()
        self.config = config or {}
        self.env = os.environ if env is None else env  # live env by default — the
        # heartbeat/state dir must follow $SWEEP_STATE exactly like vault-sync.sh
        # Off-host durability policy (git-remote by default → historical behavior).
        self.durability = make_backend(self.config, self.env)
        self.lock = threading.RLock()
        self._stop = threading.Event()
        self._thread = None
        self.on_change = None  # called after any mutation path (commit / pull) —
        # the service hangs the warm graph-index invalidation here (P4)

    # ---- plumbing -------------------------------------------------------------

    def _git(self, *args, check=False):
        """Run git in the vault with a config-driven identity fallback, so a
        checkout that was never ``alexandria init``-stamped can still commit."""
        agent = (self.config.get("agent") or {})
        ident = ["-c", "user.name=" + (agent.get("name") or "alexandria-vault-mcp"),
                 "-c", "user.email=" + (agent.get("email") or "vault-mcp@localhost")]
        return subprocess.run(["git", "-C", str(self.vault), *ident, *args],
                              capture_output=True, text=True, check=check)

    def is_repo(self) -> bool:
        return (self.vault / ".git").exists()

    def atomic_write(self, rel, text) -> Path:
        """Temp + ``os.replace`` in the target dir — never a half-written note.
        Jailed independently of the caller's checks (defense in depth: this is
        the one primitive that puts bytes on disk)."""
        from alexandria_vault.vault_core import JailError
        p = (self.vault / rel).resolve()
        if p != self.vault and self.vault not in p.parents:
            raise JailError(f"{rel!r} escapes the vault root")
        p.parent.mkdir(parents=True, exist_ok=True)
        tmp = p.with_name(p.name + ".vault-mcp.tmp")
        tmp.write_text(text, encoding="utf-8")
        os.replace(tmp, p)
        return p

    def atomic_write_bytes(self, rel, data) -> Path:
        """Binary sibling of :meth:`atomic_write`, same jail and same temp +
        ``os.replace``. Needed because a vault holds documents as well as notes
        (AGENTS.md sends binaries to an area's ``Files/``) and the text path
        would mangle every one of them through a UTF-8 encode."""
        from alexandria_vault.vault_core import JailError
        p = (self.vault / rel).resolve()
        if p != self.vault and self.vault not in p.parents:
            raise JailError(f"{rel!r} escapes the vault root")
        p.parent.mkdir(parents=True, exist_ok=True)
        tmp = p.with_name(p.name + ".vault-mcp.tmp")
        tmp.write_bytes(data)
        os.replace(tmp, p)
        return p

    def append_text(self, rel, text) -> Path:
        """Jailed append (``vault_core.fs.append_text``) — the JSONL path for
        ``.chat`` records: an append must never read-modify-write, so record
        writes land as tiny diffs and the header line is untouchable by
        construction. Crash-safety is the format's, not the write's: a torn
        tail line is skipped by readers, never a lost file."""
        from alexandria_vault.vault_core import fs
        return fs.append_text(self.vault, rel, text)

    # ---- commit + push (per write) ---------------------------------------------

    def commit_and_push(self, message):
        """Stage everything, commit if there are changes, push fail-soft.

        Callers hold :attr:`lock`. Returns the short sha (or ``None`` when the
        tree was clean / not a repo). A rejected push is not an error — the
        interval pass (or the next write) retries after its pull-rebase.
        """
        self._notify_change()   # the tree was (probably) just written to —
        # fire even on the not-a-repo/clean paths, an invalidation is only a flag
        if not self.is_repo():
            return None
        self._git("add", "-A")
        if self._git("diff", "--cached", "--quiet").returncode == 0:
            return None
        r = self._git("commit", "-m", message)
        if r.returncode != 0:
            raise RuntimeError(f"git commit failed: {(r.stderr or r.stdout).strip()}")
        self.durability.push(self)   # off-host, fail-soft (git-remote: git push)
        return self._git("rev-parse", "--short", "HEAD").stdout.strip() or None

    def _notify_change(self):
        if self.on_change is not None:
            self.on_change()

    def _remote_name(self) -> str:
        return self.env.get("SYNC_REMOTE", "origin")

    def _remote(self) -> bool:
        return self._remote_name() in self._git("remote").stdout.split()

    def _branch(self) -> str:
        return (self.env.get("SYNC_BRANCH")
                or self._git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() or "main")

    # ---- the interval sync pass (one vault-sync.sh tick, in-process) -----------

    def sync_pass(self) -> dict:
        """pull --rebase (abort on conflict) → commit strays → push → heartbeat."""
        with self.lock:
            if not self.is_repo():
                return {"ok": True, "note": "not a git working tree"}
            rec = self.durability.reconcile(self)   # git-remote: pull --rebase; volume: no-op
            if rec.ran:
                self._notify_change()   # a reconcile may have changed the tree
            conflict = rec.conflict
            try:
                self.commit_and_push(f"vault-sync: {_utcnow()}")
                pushed = True
            except RuntimeError:
                pushed = False
            if conflict:
                status = {"ok": False, "note": "rebase conflict — aborted, will retry"}
            elif not pushed:
                status = {"ok": False, "note": "commit failed — will retry"}
            else:
                status = {"ok": True, "note": "clean"}
        self._write_status(SYNC_STATUS_NAME, dict(at=_utcnow(), **status))
        return status

    def _write_status(self, name, payload):
        """Best-effort heartbeat into the host state dir (doctor/health read it)."""
        try:
            d = state_dir(self.env)
            d.mkdir(parents=True, exist_ok=True)
            (d / name).write_text(json.dumps(payload) + "\n", encoding="utf-8")
        except OSError:
            pass

    def write_heartbeat(self):
        """The 'this process owns git for the vault' claim vault-sync.sh honors."""
        self._write_status(HEARTBEAT_NAME, {"pid": os.getpid(), "at": _utcnow()})

    def clear_heartbeat(self):
        try:
            (state_dir(self.env) / HEARTBEAT_NAME).unlink()
        except OSError:
            pass

    # ---- background loop --------------------------------------------------------

    def start_sync_loop(self, interval_seconds):
        """Heartbeat + sync every ``interval_seconds`` in a daemon thread."""
        def loop():
            while not self._stop.wait(interval_seconds):
                self.write_heartbeat()
                self.sync_pass()
        self.write_heartbeat()
        self._thread = threading.Thread(target=loop, name="vault-mcp-sync", daemon=True)
        self._thread.start()

    def stop_sync_loop(self):
        self._stop.set()
        if self._thread:
            self._thread.join(timeout=5)
        self.clear_heartbeat()


def heartbeat_fresh(path, max_age_seconds) -> bool:
    """True if a heartbeat file exists, is recent, and its pid is alive —
    the exact test vault-sync.sh applies before superseding itself."""
    try:
        data = json.loads(Path(path).read_text(encoding="utf-8"))
        if time.time() - os.stat(path).st_mtime > max_age_seconds:
            return False
        os.kill(int(data.get("pid", -1)), 0)
        return True
    except (OSError, ValueError):
        return False
