#!/usr/bin/python3
"""
RecapMuse MCP server - LOCAL, zero-dependency stdio JSON-RPC 2.0.

Exposes the recorded-day corpus (the organizer's diary.json plus the per-clip
.organized/<file>.json sidecars) to the user's AI assistant, so Claude can list
conversations, read transcripts, search, and ask questions about recorded days.

HARD BOUNDARY: this is a LOCAL stdio server. It reads files under the app's
recordings folder and never writes anything. It never opens a network socket.
The `ask` tool runs engine/ask.py, which answers with a local model (Ollama) or
an on-device extractive fallback - no cloud on any path in this server.
(The app's separate Diary organizer can use OpenAI when cloud mode is chosen;
that happens at organize time in the app, never through this server.)

No pip install, no venv, no third-party packages. Runs on the macOS system
/usr/bin/python3. Speaks newline-delimited JSON-RPC 2.0 on stdio (the MCP stdio
transport). stdout carries ONLY protocol JSON; all diagnostics go to stderr.

Same zero-dependency pattern as textgalley/mcp/galleytext-mcp.cjs; same
detect/explain/act/verify concierge as Hush (engine/recap_mcp.py registers this
file with Claude Desktop from the app's Settings).
"""
from __future__ import annotations

import datetime
import json
import os
import plistlib
import sys

SERVER_NAME = "recapmuse"
SERVER_VERSION = "0.1.0"
FALLBACK_PROTOCOL = "2024-11-05"

HOME = os.path.expanduser("~")
DEFAULT_REC = os.path.join(HOME, "Recordings", "Epiphani")
PREFS_PLIST = os.path.join(HOME, "Library", "Preferences", "app.epiphani.mac.plist")

# The engine dir (our parent) holds ask.py and friends; import them lazily.
ENGINE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ENGINE_DIR not in sys.path:
    sys.path.insert(0, ENGINE_DIR)


def log(*args):
    sys.stderr.write("recapmuse-mcp: " + " ".join(str(a) for a in args) + "\n")
    sys.stderr.flush()


# --------------------------------------------------------------------------- corpus access
def rec_dir() -> str:
    """The same folder the app records into: env override (tests) -> the app's
    persisted outputDir (UserDefaults plist) -> the default. Resolved per call
    so a folder change in Settings is picked up without restarting the server."""
    env = os.environ.get("RECAPMUSE_REC_DIR")
    if env:
        return os.path.expanduser(env)
    try:
        with open(PREFS_PLIST, "rb") as f:
            out = plistlib.load(f).get("outputDir")
        if out and os.path.isdir(os.path.expanduser(out)):
            return os.path.expanduser(out)
    except Exception:
        pass
    return DEFAULT_REC


def load_diary(rd: str) -> dict | None:
    try:
        with open(os.path.join(rd, "diary.json"), encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError):
        return None


def sidecar(rd: str, fname: str) -> dict:
    try:
        with open(os.path.join(rd, ".organized", fname + ".json"), encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError):
        return {}


def conv_transcript(rd: str, conv: dict) -> str:
    """Full transcript of a conversation: its clips' sidecar transcripts, in order."""
    parts = []
    for c in conv.get("clips") or []:
        t = (sidecar(rd, c.get("file", "")).get("transcript") or "").strip()
        if t:
            parts.append(t)
    return "\n\n".join(parts)


def scope_dates(scope: str) -> tuple[datetime.date | None, datetime.date | None, str]:
    """Translate a scope word into an inclusive [start, end] date range.
    Returns (None, None, 'all') for everything."""
    today = datetime.date.today()
    s = (scope or "all").strip().lower()
    if s in ("", "all", "everything"):
        return None, None, "all"
    if s == "today":
        return today, today, "today"
    if s == "yesterday":
        y = today - datetime.timedelta(days=1)
        return y, y, "yesterday"
    if s in ("this week", "week"):
        start = today - datetime.timedelta(days=today.weekday())
        return start, today, "this week"
    try:
        d = datetime.date.fromisoformat(s)
        return d, d, s
    except ValueError:
        return None, None, "all"


def iter_days(diary: dict, scope: str):
    start, end, _label = scope_dates(scope)
    for day in diary.get("days") or []:
        try:
            d = datetime.date.fromisoformat(day.get("date") or "")
        except ValueError:
            continue
        if start and d < start:
            continue
        if end and d > end:
            continue
        yield day


def conv_brief(day: dict, conv: dict) -> dict:
    return {
        "date": day.get("date"),
        "title": conv.get("title"),
        "time": conv.get("time_range"),
        "who_with": conv.get("who_with"),
        "category": conv.get("category"),
        "summary": conv.get("summary"),
        "action_items": conv.get("action_items") or [],
        "clip_count": len(conv.get("clips") or []),
    }


# --------------------------------------------------------------------------- tools
def tool_status(args: dict) -> dict:
    rd = rec_dir()
    diary = load_diary(rd)
    org = os.path.join(rd, ".organized")
    try:
        sidecars = len([f for f in os.listdir(org) if f.endswith(".json")])
    except OSError:
        sidecars = 0
    days = (diary or {}).get("days") or []
    return {
        "server": SERVER_NAME,
        "version": SERVER_VERSION,
        "recordings_dir": rd,
        "reachable": os.path.isdir(rd),
        "diary_present": diary is not None,
        "diary_generated_at": (diary or {}).get("generated_at"),
        "days": len(days),
        "conversations": sum(len(d.get("conversations") or []) for d in days),
        "transcribed_clips": sidecars,
        "privacy": "This server reads local files only and never uses the network. "
                   "The ask tool answers with a local model or an on-device fallback.",
    }


def tool_list_conversations(args: dict) -> dict:
    rd = rec_dir()
    diary = load_diary(rd)
    if diary is None:
        return {"ok": False, "error": f"No diary.json at {rd}. Open RecapMuse and run "
                                      "the organizer (Diary) first."}
    scope = args.get("scope") or "all"
    limit = max(1, min(int(args.get("limit") or 50), 200))
    out = []
    for day in iter_days(diary, scope):
        for conv in day.get("conversations") or []:
            out.append(conv_brief(day, conv))
    _, _, label = scope_dates(scope)
    return {"ok": True, "scope": label, "total": len(out), "conversations": out[:limit]}


def tool_get_conversation(args: dict) -> dict:
    rd = rec_dir()
    diary = load_diary(rd)
    if diary is None:
        return {"ok": False, "error": f"No diary.json at {rd}."}
    want_date = (args.get("date") or "").strip()
    want_title = (args.get("title") or "").strip().lower()
    if not want_title:
        return {"ok": False, "error": "title is required (use list_conversations first)."}
    for day in diary.get("days") or []:
        if want_date and day.get("date") != want_date:
            continue
        for conv in day.get("conversations") or []:
            if (conv.get("title") or "").strip().lower() == want_title:
                brief = conv_brief(day, conv)
                brief["transcript"] = conv_transcript(rd, conv) or \
                    "(no transcript on file for this conversation's clips)"
                return {"ok": True, "conversation": brief}
    return {"ok": False, "error": f"No conversation titled {args.get('title')!r}"
                                  + (f" on {want_date}" if want_date else "") + "."}


def tool_search_conversations(args: dict) -> dict:
    rd = rec_dir()
    diary = load_diary(rd)
    if diary is None:
        return {"ok": False, "error": f"No diary.json at {rd}."}
    q = (args.get("query") or "").strip().lower()
    if not q:
        return {"ok": False, "error": "query is required."}
    limit = max(1, min(int(args.get("limit") or 10), 50))
    hits = []
    for day in diary.get("days") or []:
        for conv in day.get("conversations") or []:
            hay_summary = " ".join(str(conv.get(k) or "") for k in
                                   ("title", "who_with", "summary")).lower()
            transcript = conv_transcript(rd, conv)
            where = None
            snippet = ""
            if q in hay_summary:
                where = "summary"
                snippet = conv.get("summary") or ""
            elif q in transcript.lower():
                where = "transcript"
                i = transcript.lower().index(q)
                snippet = transcript[max(0, i - 120):i + 180].strip()
            if where:
                h = conv_brief(day, conv)
                h["matched_in"] = where
                h["snippet"] = snippet[:400]
                hits.append(h)
            if len(hits) >= limit:
                break
        if len(hits) >= limit:
            break
    return {"ok": True, "query": args.get("query"), "hits": len(hits), "results": hits}


def tool_ask(args: dict) -> dict:
    question = (args.get("question") or "").strip()
    if not question:
        return {"ok": False, "error": "question is required."}
    try:
        import ask  # engine/ask.py, on-device only by design
    except Exception as e:
        return {"ok": False, "error": f"ask engine unavailable: {e}"}
    scope = args.get("scope") or None
    return ask.ask(rec_dir(), question, scope=scope,
                   with_transcripts=bool(args.get("with_transcripts")))


TOOLS = [
    {
        "name": "status",
        "description": "Health check: recordings folder, diary present, day / conversation / "
                       "transcribed-clip counts, and the privacy contract.",
        "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
        "handler": tool_status,
    },
    {
        "name": "list_conversations",
        "description": "List recorded conversations from the diary: title, time, who with, "
                       "summary, action items. Scope: 'today', 'yesterday', 'this week', a "
                       "YYYY-MM-DD date, or 'all'.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "scope": {"type": "string", "description": "today | yesterday | this week | YYYY-MM-DD | all"},
                "limit": {"type": "integer", "description": "Max conversations to return (default 50)."},
            },
            "additionalProperties": False,
        },
        "handler": tool_list_conversations,
    },
    {
        "name": "get_conversation",
        "description": "Fetch one conversation in full, including its transcript, by title "
                       "(and optionally date) from list_conversations.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "title": {"type": "string", "description": "Exact conversation title."},
                "date": {"type": "string", "description": "Optional YYYY-MM-DD to disambiguate."},
            },
            "required": ["title"],
            "additionalProperties": False,
        },
        "handler": tool_get_conversation,
    },
    {
        "name": "search_conversations",
        "description": "Search conversation titles, summaries, and full transcripts for a "
                       "phrase; returns matches with a context snippet.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Text to find."},
                "limit": {"type": "integer", "description": "Max hits (default 10)."},
            },
            "required": ["query"],
            "additionalProperties": False,
        },
        "handler": tool_search_conversations,
    },
    {
        "name": "ask",
        "description": "Ask a question about recorded days ('what did I talk about last "
                       "night?'). Answers on device: a local model if installed, otherwise an "
                       "extractive fallback. Never uses the network.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "question": {"type": "string", "description": "The question."},
                "scope": {"type": "string", "description": "Optional: today | yesterday | this week | a weekday | all."},
                "with_transcripts": {"type": "boolean", "description": "Include full transcripts as context (slower, richer)."},
            },
            "required": ["question"],
            "additionalProperties": False,
        },
        "handler": tool_ask,
    },
]

TOOL_BY_NAME = {t["name"]: t for t in TOOLS}


# --------------------------------------------------------------------------- JSON-RPC plumbing
def send(msg: dict) -> None:
    sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n")
    sys.stdout.flush()


def reply(rid, result) -> None:
    send({"jsonrpc": "2.0", "id": rid, "result": result})


def reply_error(rid, code: int, message: str) -> None:
    send({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}})


def handle_message(msg: dict) -> None:
    is_notification = msg.get("id") is None
    method, params, rid = msg.get("method"), msg.get("params") or {}, msg.get("id")
    try:
        if method == "initialize":
            reply(rid, {
                "protocolVersion": params.get("protocolVersion") or FALLBACK_PROTOCOL,
                "capabilities": {"tools": {"listChanged": False}},
                "serverInfo": {"name": SERVER_NAME, "version": SERVER_VERSION},
                "instructions": "Local, on-device access to RecapMuse's recorded-day corpus "
                                "(conversations, summaries, transcripts). Start with status or "
                                "list_conversations; use ask for natural-language questions. "
                                "Transcripts may contain private conversations; keep everything local.",
            })
            return
        if method in ("notifications/initialized", "initialized"):
            return
        if method == "ping":
            reply(rid, {})
            return
        if method == "tools/list":
            reply(rid, {"tools": [{k: t[k] for k in ("name", "description", "inputSchema")}
                                  for t in TOOLS]})
            return
        if method == "tools/call":
            tool = TOOL_BY_NAME.get(params.get("name"))
            if not tool:
                reply_error(rid, -32602, f"Unknown tool: {params.get('name')}")
                return
            try:
                result = tool["handler"](params.get("arguments") or {})
            except Exception as e:
                reply(rid, {"content": [{"type": "text",
                                         "text": f"Error running {params.get('name')}: {e}"}],
                            "isError": True})
                return
            reply(rid, {"content": [{"type": "text",
                                     "text": json.dumps(result, ensure_ascii=False, indent=2)}],
                        "structuredContent": result})
            return
        if not is_notification:
            reply_error(rid, -32601, f"Method not found: {method}")
    except Exception as e:
        log("handler exception:", e)
        if not is_notification:
            reply_error(rid, -32603, f"Internal error: {e}")


def main() -> None:
    log(f"ready; recordings at {rec_dir()}")
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
        except ValueError:
            log("bad JSON line skipped:", line[:120])
            continue
        if isinstance(msg, list):
            for m in msg:
                handle_message(m)
        else:
            handle_message(msg)


if __name__ == "__main__":
    main()
