"""``python -m alexandria_vault.mcp --vault <path>`` — the vault-mcp daemon.

What the thin module's wrapper (``Agent/scripts/mcp/vault-mcp.sh``) execs under
launchd (``com.alexandria.vault-mcp``, RunAtLoad + KeepAlive). Startup order:
resolve + strictly load the vault, fail closed on seats, claim git ownership
(heartbeat + interval sync — superseding the standalone vault-sync tick), wire
Tailscale Serve idempotently, then serve streamable HTTP on the fixed port.

Sidecar mode (the Orion desktop spawns this as a child process): ``--port 0``
binds an ephemeral port, a machine-readable ``{"event": "ready", …}`` line on
stdout announces the actual port only after the socket listens, and
``--exit-with-parent`` ties the server's lifetime to the spawner's stdin pipe
so a crashed parent can never leave a stray git owner behind.
"""
import argparse
import json
import os
import signal
import sys
import threading
import time

from .seats import SeatConfigError
from .service import VaultMCPService
from . import tailscale


def _announce_line(port, host, svc) -> str:
    """The ready line a spawning parent awaits on stdout — emitted only after
    the socket is bound + listening, so connecting on sight of it cannot race
    the bind. One JSON object, one line; everything else the daemon prints is
    human prose, so a parser keys on ``"event": "ready"``."""
    from .tools import _server_version
    return json.dumps({"event": "ready",
                       "url": f"http://{host}:{port}/mcp",
                       "port": port,
                       "pid": os.getpid(),
                       "server_version": _server_version(),
                       "vault": str(svc.vault)})


def _default_orphan(cleanup=None):
    # Cleanup BEFORE the signal: uvicorn re-raises a captured SIGTERM after its
    # graceful shutdown (so parents see the true cause of death), which kills
    # the process inside serve() — main()'s finally never runs. The watchdog
    # therefore stops the sync loop (clearing the git-ownership heartbeat so
    # vault-sync resumes promptly) itself, then signals. Hard exit backstop.
    if cleanup is not None:
        try:
            cleanup()
        except Exception:
            pass
    os.kill(os.getpid(), signal.SIGTERM)
    time.sleep(10)
    os._exit(1)


def _watch_parent_stdin(stdin=None, on_orphan=None):
    """Block until stdin reaches EOF, then trigger shutdown.

    The sidecar contract: the spawner keeps our stdin pipe open for its whole
    life and never writes to it — EOF means the parent is gone (quit *or*
    crashed), and this process must not linger as a stray git owner. An
    unreadable stdin proves no live parent either, so it shuts down too."""
    stream = stdin if stdin is not None else sys.stdin.buffer
    try:
        while stream.read(4096):
            pass          # drain defensively until EOF
    except Exception:
        pass
    (on_orphan or _default_orphan)()


def _announce_birth(cfg, *, force):
    """The served worker's fail-soft birth call (docs/RELAY-CENSUS.md §7.2). Called at
    thread start (``force=True``) and then on EVERY tick (``force=False``): birth.json
    makes the per-tick call free — a fresh success skips for a day, a failure backs
    off — so a long-running brain keeps its liveness stamp current without a flag
    here deciding it is "done", and an owner flipping ``federation.announce`` while
    served is honoured on the next tick. The config is seeded in memory
    (``birth.effective_config``) so a pre-4.0 vault with no persisted relay address
    still announces — the upgrade wave must reach the fleet that exists.

    Returns True (landed, or nothing to do this tick), False (not landed; the next
    tick tries again) or ``"terminal"`` — a non-relay failure (a corrupt key, PyNaCl
    missing) that will not heal by itself: said ONCE, then left alone until the next
    restart, the same reasoning the outbox applies to a security refusal."""
    try:
        from alexandria_vault.federation import birth
        res = birth.announce(birth.effective_config(cfg), channel=birth.default_channel(),
                             force=force)
    except Exception as e:  # noqa: BLE001 — a birth must never kill serve
        print(f"vault-mcp: birth announcement failed ({e}) — not retrying until restart")
        return "terminal"
    if res.get("announced"):
        print(f"vault-mcp: announced this brain to the relay "
              f"(fingerprint {(res.get('fingerprint') or '')[:12]}…)")
        return True
    if res.get("skipped"):
        return True
    print(f"vault-mcp: birth announcement not landed ({res.get('error')}) — will retry")
    return False


def _start_federation_worker(svc):
    """Daemon thread that runs one federation tick every `poll_seconds` (§5.6).

    Started whenever config has a `federation` block (a relay_url) — NOT gated on a
    peer existing at boot, so a peer paired AFTER `serve` starts is picked up: the
    loop reloads Agent/config.json from disk each tick (an out-of-process
    `alexandria peer add` is seen on the next tick) and simply does nothing on a tick
    with no v2 peers. The git-writer lock is held ONLY around the per-envelope commit,
    never across the relay network I/O (deposit/poll/ack) — a hung relay must not
    stall the vault's write tools. Push-only holds: only outbound relay calls."""
    try:
        from alexandria_vault.federation import birth, worker
        from alexandria_vault.federation import relay_client as rc
    except Exception as e:  # noqa: BLE001 — the `federation` extra isn't installed
        print(f"vault-mcp: federation configured but unavailable ({e}) — worker off")
        return
    # The thread exists whenever there is a relay to announce to — including a
    # pre-4.0 vault whose config never gained the block (seeded in memory for the
    # birth only). The TICK still requires the PERSISTED relay_url, as before.
    if not ((birth.effective_config(svc.config).get("federation") or {}).get("relay_url")):
        return
    def _reload():
        # Read the on-disk config fresh so an out-of-process `peer add` is seen; fall
        # back to the boot snapshot on any read/parse error.
        try:
            import json
            from pathlib import Path
            return json.loads(
                (Path(svc.vault) / "Agent" / "config.json").read_text(encoding="utf-8")) or {}
        except Exception:  # noqa: BLE001
            return svc.config or {}

    def _locked_commit(message):
        with svc.writer.lock:                            # serialize the commit only
            return svc.writer.commit_and_push(message)

    def loop():
        import time
        # Birth (docs/RELAY-CENSUS.md §4.1): announce at thread start, before the
        # sleep, regardless of peers — this is also the upgrade wave (every brain alive
        # when the census shipped announces on its next boot). Then once per tick,
        # unforced: birth.json turns that into one refresh a day (liveness for a brain
        # that never restarts) and a backed-off retry after a failure. Only a
        # non-relay failure stops the calls until the next restart.
        fatal = _announce_birth(_reload(), force=True) == "terminal"
        while True:
            cfg = _reload()
            fed = cfg.get("federation") or {}
            url = fed.get("relay_url")
            peers = [p for p in (cfg.get("peers") or [])
                     if p.get("pubkey") and p.get("fingerprint")]
            time.sleep(max(15, int(fed.get("poll_seconds", 60) or 60)))
            if not fatal:
                fatal = _announce_birth(cfg, force=False) == "terminal"
            if not url or not peers:
                continue                                 # nothing to service this tick
            try:
                worker.tick(svc.vault, cfg, rc.RelayClient(url), commit=_locked_commit)
            except Exception as e:  # noqa: BLE001 — a tick error must never kill serve
                print(f"vault-mcp: federation tick error — {e}")

    threading.Thread(target=loop, name="federation-worker", daemon=True).start()
    print("vault-mcp: federation worker on (reloads config + polls each interval)")


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(
        prog="python -m alexandria_vault.mcp",
        description="Serve one vault's MCP tool surface over streamable HTTP.")
    ap.add_argument("--vault", help="vault root (else $VAULT_DIR / walk-up)")
    ap.add_argument("--host", help="bind address (default 127.0.0.1 — remote "
                                   "seats come through Tailscale Serve, never a "
                                   "wide bind)")
    ap.add_argument("--port", type=int,
                    help="port (default config vault_mcp.port, else 8765; "
                         "0 = ephemeral — the ready line announces the real one)")
    ap.add_argument("--no-tailscale", action="store_true",
                    help="skip the Tailscale Serve wiring for this run")
    ap.add_argument("--no-sync", action="store_true",
                    help="don't own git (no heartbeat/interval sync) — debugging only")
    ap.add_argument("--exit-with-parent", action="store_true",
                    help="exit when stdin reaches EOF — for a spawning parent "
                         "(desktop sidecar) holding the pipe open; do NOT set "
                         "under launchd (stdin is /dev/null = immediate EOF)")
    args = ap.parse_args(argv)

    try:
        svc = VaultMCPService(args.vault)
    except SeatConfigError as e:
        # Not-yet-provisioned box: exit CLEAN so launchd (KeepAlive SuccessfulExit=false)
        # doesn't respawn-loop — same convention as the agent-listener when disabled.
        # Serving unauthenticated is never an option; provisioning the Bearer + a
        # `launchctl kickstart` (or the next reboot) brings it up.
        print(f"vault-mcp: {e}", file=sys.stderr)
        return 0
    except Exception as e:
        print(f"vault-mcp: cannot start — {e}", file=sys.stderr)
        return 2

    vm = svc.config.get("vault_mcp") or {}
    host = args.host or vm.get("host") or "127.0.0.1"
    # `is not None`, not truthiness: --port 0 (ephemeral) is a real request.
    port = args.port if args.port is not None else (vm.get("port") or 8765)
    interval = vm.get("sync_interval_seconds") or 600

    print(f"vault-mcp: serving {svc.vault} on "
          f"http://{host}:{port or '<ephemeral>'}/mcp ({len(svc.seats)} seat(s))",
          flush=True)
    if args.exit_with_parent:
        def _orphaned():
            _default_orphan(cleanup=None if args.no_sync
                            else svc.writer.stop_sync_loop)
        threading.Thread(target=_watch_parent_stdin, daemon=True,
                         kwargs={"on_orphan": _orphaned},
                         name="parent-watch").start()
    if not args.no_sync:
        svc.writer.sync_pass()                  # start from the remote's latest
        svc.writer.start_sync_loop(interval)    # own git while we run
        print(f"vault-mcp: owning git (sync every {interval}s; "
              "vault-sync stands down while the heartbeat is fresh)")
    if not args.no_tailscale:
        if port == 0:
            # An ephemeral port is a local-sidecar posture; a tailnet mount
            # needs the stable configured port.
            print("vault-mcp: tailscale skipped (ephemeral port)")
        else:
            print("vault-mcp: " + tailscale.ensure_serve(port, svc.config))

    if args.no_sync:
        # A server that does not own git must not run a committing worker: the
        # federation tick commits+pushes every applied envelope through the
        # git-writer, which on a --no-sync (read-only / supervised) server would
        # be a second, unlocked receiver beside whatever does own the vault.
        if ((svc.config or {}).get("federation") or {}).get("relay_url"):
            print("vault-mcp: federation worker off (--no-sync — this server does not own git)")
    else:
        _start_federation_worker(svc)

    def _on_ready(actual_port):
        print(_announce_line(actual_port, host, svc), flush=True)

    from . import server  # imports the mcp extra — after the cheap failures above
    try:
        server.serve(svc, host, port, ready=_on_ready)
    finally:
        if not args.no_sync:
            svc.writer.stop_sync_loop()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
