#!/usr/bin/python3
"""
recap_mcp.py - one-click "Connect to your AI" concierge for the bundled MCP server.

Ported from Hush's hush_mcp.py (the fleet reference for local stdio connectors),
with the venv machinery removed: RecapMuse's server (engine/mcp/server.py) is pure
stdlib, so it runs on the system /usr/bin/python3 with no pip install, no venv, no
network at register time. That matters here: this app's wedge is that recordings
stay on the Mac, and a connect step that downloads packages would be off-brand.

Claude Desktop OWNS claude_desktop_config.json and rewrites it from its own
connector store on quit, silently dropping a hand-added local (stdio) MCP entry.
So registering or removing one MUST happen while Claude Desktop is NOT running:
quit it, patch the JSON, relaunch. This module does that dance from inside the app
so a click replaces a Terminal script.

Follows the app-permission-concierge doctrine: DETECT the real state, EXPLAIN why
(the Settings card owns the plain-English line), ACT, then VERIFY by reading the
config back and returning the real failure reason on any step that doesn't verify,
never a bare "should work".

One deliberate divergence from the Hush reference: after the graceful quit deadline
we FAIL with an honest message instead of escalating to pkill. Force-killing Claude
can interrupt real work in other sessions; that escalation is an open review item
on the Hush side and new code should not spread it.

CLI (for the Swift app): recap_mcp.py status|connect|disconnect  -> JSON on stdout.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import time

VERSION = "0.1.0"

APP_NAME = "RecapMuse"
CLAUDE_APP = "/Applications/Claude.app"
MCP_KEY = "recapmuse"
PYTHON = "/usr/bin/python3"

# The server lives in the same engine tree as this file, in the dev repo AND in the
# app bundle (build.sh copies engine/ wholesale), so deriving from __file__ is
# correct in both worlds and a future app rename can never leave a stale path.
SERVER_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mcp", "server.py")

CLAUDE_CFG = os.environ.get(
    "RECAP_MCP_CLAUDE_CFG",
    os.path.expanduser("~/Library/Application Support/Claude/claude_desktop_config.json"),
)


def _claude_app_present() -> bool:
    return os.path.isdir(CLAUDE_APP)


def _server_bundled() -> bool:
    return os.path.isfile(SERVER_PATH)


class ConfigUnreadable(Exception):
    """The config file EXISTS but could not be parsed. Never treat this as an empty
    config: that file is the only record of every OTHER connector the user has, and
    rewriting it from {} would silently delete all of them."""


def _read_cfg() -> dict:
    """A missing file is genuinely an empty config and is safe to build on. A file we
    cannot parse is NOT: it usually means malformed JSON or a transient read error, and
    the entries we cannot see are exactly the ones we would destroy by writing over them."""
    if not os.path.exists(CLAUDE_CFG):
        return {}
    try:
        with open(CLAUDE_CFG) as f:
            return json.load(f)
    except Exception as e:
        raise ConfigUnreadable(str(e)) from e


def _backup_cfg() -> str | None:
    """Snapshot before every mutation, so a bad write is always recoverable by hand."""
    if not os.path.exists(CLAUDE_CFG):
        return None
    dest = f"{CLAUDE_CFG}.bak-{time.strftime('%Y-%m-%d-%H%M%S')}"
    try:
        shutil.copy2(CLAUDE_CFG, dest)
        return dest
    except Exception:
        return None


def _write_cfg(cfg: dict) -> None:
    os.makedirs(os.path.dirname(CLAUDE_CFG), exist_ok=True)
    # Per-PID temp name, and fsync before the replace. This file is Claude Desktop's own
    # config, and FOUR of Jason's apps register themselves into it (RecapMuse, Hush,
    # Epiphani Lens, DisplayDial), every one of them previously staging through the SAME
    # "claude_desktop_config.json.tmp". Two registering at once (a login, or an update that
    # installs several) could interleave into that one temp file and then replace a spliced
    # document over his MCP config. Not hypothetical: the identical pattern corrupted
    # DisplayDial's own config on 2026-07-25, after which its engine refused to start.
    tmp = "%s.%d.tmp" % (CLAUDE_CFG, os.getpid())
    try:
        with open(tmp, "w") as f:
            json.dump(cfg, f, indent=2)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, CLAUDE_CFG)
    finally:
        if os.path.exists(tmp):
            try:
                os.remove(tmp)
            except OSError:
                pass


def _registered_entry() -> dict | None:
    return (_read_cfg().get("mcpServers") or {}).get(MCP_KEY)


def status() -> dict:
    """DETECT: everything the Settings card needs to render its state, read-only,
    safe to poll on every panel open."""
    base = {
        "version": VERSION,
        "server_bundled": _server_bundled(),
        "claude_app_present": _claude_app_present(),
        "server_path": SERVER_PATH,
        "config_path": CLAUDE_CFG,
    }
    try:
        entry = _registered_entry()
    except ConfigUnreadable as e:
        # Say so out loud rather than rendering "not connected": the card must not offer a
        # Connect that we would refuse anyway, and the honest state is "we cannot tell".
        return {**base, "registered": False, "stale_entry": False,
                "config_unreadable": True, "error": f"Claude Desktop's config is unreadable: {e}"}
    up_to_date = bool(entry) and entry.get("args") == [SERVER_PATH] and entry.get("command") == PYTHON
    return {
        **base,
        "registered": up_to_date,
        # a 'recapmuse' key exists but points at an older app path (e.g. before a move or
        # rename) - the card should offer "Reconnect", not silently claim it's connected.
        "stale_entry": bool(entry) and not up_to_date,
        "config_unreadable": False,
    }


def _claude_running() -> bool:
    return subprocess.run(["pgrep", "-x", "Claude"], capture_output=True).returncode == 0


def _quit_claude(timeout_s: float = 60.0) -> bool:
    """Graceful quit only. Claude Desktop tears down every open session on quit, so a
    busy app legitimately takes a while; 60s covers a healthy quit at many live
    sessions. If it is still up at the deadline we return False and the caller reports
    an honest failure - we never force-kill an app that may be mid-work."""
    subprocess.run(["osascript", "-e", 'tell application "Claude" to quit'],
                   capture_output=True, text=True)
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        if not _claude_running():
            return True
        time.sleep(0.5)
    return not _claude_running()


def _relaunch_claude() -> None:
    subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)


def connect() -> dict:
    """ACT + VERIFY. Quits Claude Desktop only if it is actually running, patches the
    config idempotently, relaunches, then reads the config back to confirm the entry
    landed pointing at THIS build's server path. Every failure path returns the real
    reason, never a bare 'connected'."""
    if not _server_bundled():
        return {
            "ok": False, "verified": False,
            "error": f"MCP server not found in this build at {SERVER_PATH}. Rebuild RecapMuse.",
        }
    if not _claude_app_present():
        return {"ok": False, "verified": False,
                "error": f"Claude Desktop is not installed at {CLAUDE_APP}."}

    if _claude_running() and not _quit_claude():
        return {
            "ok": False, "verified": False,
            "error": "Could not quit Claude Desktop. It owns its config file and would overwrite "
                     "this edit on its next quit, so registering while it's still running isn't safe. "
                     "Quit it yourself and try again.",
        }

    try:
        cfg = _read_cfg()
    except ConfigUnreadable as e:
        return {
            "ok": False, "verified": False,
            "error": f"Claude Desktop's config file could not be read ({e}), so every other "
                     "connector in it is invisible to us right now. Writing would delete them. "
                     f"Fix or remove {CLAUDE_CFG}, then try again.",
        }
    _backup_cfg()
    cfg.setdefault("mcpServers", {})
    cfg["mcpServers"][MCP_KEY] = {"command": PYTHON, "args": [SERVER_PATH], "env": {}}
    _write_cfg(cfg)

    _relaunch_claude()
    time.sleep(1.0)

    try:
        entry = _registered_entry()
    except ConfigUnreadable:
        entry = None
    verified = bool(entry) and entry.get("command") == PYTHON and entry.get("args") == [SERVER_PATH]
    if not verified:
        return {
            "ok": False, "verified": False,
            "error": f"Wrote the entry but re-reading {CLAUDE_CFG} did not show it as expected. "
                     "Check the file by hand.",
        }
    return {
        "ok": True, "verified": True,
        "message": "Connected. Open a new chat in Claude Desktop - RecapMuse's tools "
                   "(list_conversations, get_conversation, search_conversations, ask) are available.",
    }


def disconnect() -> dict:
    """Reverse of connect(): quit only if running, remove the key if present,
    relaunch, verify it's actually gone."""
    try:
        if not _registered_entry():
            return {"ok": True, "verified": True, "message": "Already disconnected."}
    except ConfigUnreadable as e:
        return {
            "ok": False, "verified": False,
            "error": f"Claude Desktop's config file could not be read ({e}), so we cannot safely "
                     f"edit it without risking your other connectors. Check {CLAUDE_CFG} by hand.",
        }

    if _claude_running() and not _quit_claude():
        return {
            "ok": False, "verified": False,
            "error": "Could not quit Claude Desktop to remove the connector. Quit it yourself and try again.",
        }

    try:
        cfg = _read_cfg()
    except ConfigUnreadable as e:
        return {
            "ok": False, "verified": False,
            "error": f"Claude Desktop's config file could not be read ({e}). Writing would delete "
                     f"your other connectors. Check {CLAUDE_CFG} by hand.",
        }
    _backup_cfg()
    (cfg.get("mcpServers") or {}).pop(MCP_KEY, None)
    _write_cfg(cfg)

    _relaunch_claude()
    time.sleep(1.0)

    try:
        verified = _registered_entry() is None
    except ConfigUnreadable:
        verified = False
    if not verified:
        return {
            "ok": False, "verified": False,
            "error": f"Removed the entry but re-reading {CLAUDE_CFG} still shows one. Check the file by hand.",
        }
    return {"ok": True, "verified": True, "message": "Disconnected."}


def main(argv: list[str]) -> None:
    cmd = argv[0] if argv else "status"
    fn = {"status": status, "connect": connect, "disconnect": disconnect}.get(cmd)
    if fn is None:
        sys.stdout.write(json.dumps({"ok": False, "error": f"unknown command {cmd!r} "
                                     "(use status | connect | disconnect)"}))
        return
    sys.stdout.write(json.dumps(fn(), ensure_ascii=False))


if __name__ == "__main__":
    main(sys.argv[1:])
