#!/usr/bin/env python3
"""AgentCue hook for Claude Code.

Called by Claude Code for every hook event. Does two things:
1. Writes session state to SQLite (persistent, works without the app)
2. POSTs to AgentCue's HTTP server (real-time updates + blocking permissions)

If the HTTP server is down, SQLite still captures everything.
If this script fails, Claude Code continues normally.
"""
import argparse
import ctypes
import ctypes.util
import json
import os
import sqlite3
import struct
import sys
import time
import urllib.request

# --- Configuration ---
DEFAULT_PORT = 49152
DB_DIR = os.path.expanduser("~/Library/Application Support/Cue")
DB_PATH = os.path.join(DB_DIR, "cue.db")

# Map hook events to session states for SQLite
STATE_MAP = {
    "SessionStart": "working",
    "UserPromptSubmit": "working",
    "PreToolUse": "working",
    "PostToolUse": "working",
    "PostToolUseFailure": "working",
    "Stop": "ready",
    "SessionEnd": "ready",
    "TaskCompleted": "ready",
    "SubagentStart": "working",
    "SubagentStop": "working",
    "PreCompact": "working",
}

# Notification subtypes that change session state
NOTIFY_STATE_MAP = {
    "permission_prompt": "needs_permission",
    "idle_prompt": "ready",
    "task_complete": "ready",
    "elicitation_dialog": "has_question",
    "auth_success": "working",
}


def get_pid_start_time(pid):
    """Get process start time via sysctl (macOS). Returns epoch float or None."""
    try:
        mib = (ctypes.c_int * 4)(1, 14, 1, pid)
        buf_size = ctypes.c_size_t(4096)
        buf = ctypes.create_string_buffer(buf_size.value)
        libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True)
        ret = libc.sysctl(mib, 4, buf, ctypes.byref(buf_size), None, 0)
        if ret != 0 or buf_size.value == 0:
            return None
        raw = buf.raw
        tv_sec, tv_usec = struct.unpack("=li", raw[128:128 + 12])
        return float(tv_sec) + float(tv_usec) / 1_000_000
    except Exception:
        return None


def _get_ppid(pid):
    """Get the parent PID of a process using sysctl (macOS)."""
    try:
        import subprocess
        out = subprocess.check_output(
            ["ps", "-o", "ppid=", "-p", str(pid)],
            stderr=subprocess.DEVNULL,
        )
        ppid = int(out.strip())
        return ppid if ppid > 1 else None
    except Exception:
        return None


def _get_process_path(pid):
    """Get the executable path for a PID using libproc (macOS)."""
    try:
        buf = ctypes.create_string_buffer(4096)
        libproc = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
        ret = libproc.proc_pidpath(pid, buf, ctypes.c_uint32(4096))
        return buf.value.decode("utf-8") if ret > 0 else ""
    except Exception:
        return ""


def detect_terminal():
    """Detect terminal type from env vars and parent process path (source of truth).

    When Claude runs as an IDE extension, TERM_PROGRAM isn't set.
    The parent process binary path is the reliable signal for IDE detection.
    """
    # First check the parent process path — catches IDE extensions where
    # env vars like ITERM_SESSION_ID leak from the shell that launched the IDE.
    parent_path = _get_process_path(os.getppid())
    if ".vscode" in parent_path or "Visual Studio Code" in parent_path:
        return "vscode", ""
    if ".cursor" in parent_path or "Cursor.app" in parent_path:
        return "cursor", ""
    if "windsurf" in parent_path.lower():
        return "windsurf", ""

    tp = os.environ.get("TERM_PROGRAM", "")

    if tp == "vscode":
        return "vscode", ""
    if "cursor" in tp.lower():
        return "cursor", ""

    iterm = os.environ.get("ITERM_SESSION_ID", "")
    if iterm:
        return "iterm2", iterm.split(":", 1)[1] if ":" in iterm else iterm

    if os.environ.get("KITTY_WINDOW_ID"):
        return "kitty", os.environ["KITTY_WINDOW_ID"]
    if os.environ.get("WARP_SESSION_ID"):
        return "warp", os.environ["WARP_SESSION_ID"]
    if os.environ.get("ALACRITTY_WINDOW_ID"):
        return "alacritty", os.environ["ALACRITTY_WINDOW_ID"]

    if tp == "Apple_Terminal":
        return "terminal", os.environ.get("TERM_SESSION_ID", "")
    if tp and "warp" in tp.lower():
        return "warp", ""
    if os.environ.get("GHOSTTY_RESOURCES_DIR"):
        return "ghostty", ""

    return tp.lower() if tp else "unknown", ""


def ensure_db():
    """Open the database and create the v2 sessions table if needed."""
    os.makedirs(DB_DIR, exist_ok=True)
    conn = sqlite3.connect(DB_PATH, timeout=5)
    conn.execute("PRAGMA busy_timeout = 3000")
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("""
        CREATE TABLE IF NOT EXISTS sessions (
            id TEXT PRIMARY KEY,
            session_id TEXT,
            agent_tool TEXT NOT NULL DEFAULT 'claude-code',
            project_dir TEXT,
            project_name TEXT,
            status TEXT,
            terminal_type TEXT,
            terminal_session_id TEXT,
            terminal_pid INTEGER,
            shell_pid INTEGER,
            active_subagent_count INTEGER DEFAULT 0,
            is_compacting INTEGER DEFAULT 0,
            last_tool_name TEXT,
            last_prompt TEXT,
            last_message TEXT,
            transcript_path TEXT,
            pid_start_time REAL,
            git_branch TEXT,
            telegram_topic_id INTEGER,
            created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
            updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
            ended_at TEXT
        )
    """)
    conn.execute(
        "CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)"
    )
    conn.execute(
        "CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)"
    )
    # FTS5 search table — same schema as Swift side
    try:
        conn.execute("""
            CREATE VIRTUAL TABLE IF NOT EXISTS session_search USING fts5(
                session_id,
                project_name,
                project_dir,
                git_branch,
                prompts,
                tool_history,
                summary
            )
        """)
    except Exception:
        pass  # FTS5 may already exist or not be available
    conn.commit()
    return conn


def upsert(conn, sid, **kw):
    """Insert or update a session row. Only overwrites non-None values."""
    now = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
    row = conn.execute("SELECT id FROM sessions WHERE id = ?", (sid,)).fetchone()
    if row is None:
        conn.execute(
            """INSERT INTO sessions
                (id, session_id, agent_tool, project_dir, project_name, status,
                 terminal_type, terminal_session_id, terminal_pid, shell_pid,
                 last_tool_name, last_prompt, last_message, transcript_path,
                 pid_start_time, created_at, updated_at, ended_at)
            VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
            """,
            (
                sid, sid, "claude-code",
                kw.get("project_dir"), kw.get("project_name"),
                kw.get("status", "working"),
                kw.get("terminal_type"), kw.get("terminal_session_id"),
                kw.get("pid"), kw.get("shell_pid"),
                kw.get("tool_name"), kw.get("last_prompt"),
                kw.get("last_message"), kw.get("transcript_path"),
                kw.get("pid_start_time"),
                now, now, kw.get("ended_at"),
            ),
        )
    else:
        sets, vals = ["updated_at = ?"], [now]
        field_map = {
            "project_dir": "project_dir",
            "project_name": "project_name",
            "status": "status",
            "terminal_type": "terminal_type",
            "terminal_session_id": "terminal_session_id",
            "pid": "terminal_pid",
            "shell_pid": "shell_pid",
            "tool_name": "last_tool_name",
            "last_prompt": "last_prompt",
            "last_message": "last_message",
            "transcript_path": "transcript_path",
            "pid_start_time": "pid_start_time",
            "ended_at": "ended_at",
        }
        for kw_key, col in field_map.items():
            v = kw.get(kw_key)
            if v is not None:
                sets.append(f"{col} = ?")
                vals.append(v)
        # Clear tool name (e.g. new prompt, interrupt)
        if kw.get("clear_tool"):
            sets.append("last_tool_name = NULL")
        vals.append(sid)
        conn.execute(
            f"UPDATE sessions SET {', '.join(sets)} WHERE id = ?", vals
        )
    conn.commit()


def update_search_index(conn, sid, prompt=None, tool=None, summary=None):
    """Append prompt/tool/summary to the FTS5 search index for a session.

    FTS5 doesn't support partial column updates, so we read-modify-write.
    This is called per-event, keeping the index always up to date.
    """
    try:
        # Check if entry exists
        row = conn.execute(
            "SELECT prompts, tool_history, summary FROM session_search WHERE session_id = ?",
            (sid,),
        ).fetchone()

        if row is None:
            # Get project info from sessions table for the initial FTS entry
            info = conn.execute(
                "SELECT project_name, project_dir, git_branch FROM sessions WHERE id = ?",
                (sid,),
            ).fetchone()
            pname = info[0] if info else None
            pdir = info[1] if info else None
            branch = info[2] if info else None
            conn.execute(
                """INSERT INTO session_search(session_id, project_name, project_dir, git_branch, prompts, tool_history, summary)
                VALUES (?, ?, ?, ?, ?, ?, ?)""",
                (sid, pname, pdir, branch, prompt or "", tool or "", summary or ""),
            )
        else:
            old_prompts, old_tools, old_summary = row
            new_prompts = old_prompts or ""
            new_tools = old_tools or ""
            new_summary = old_summary or ""
            if prompt:
                new_prompts = (new_prompts + "\n" + prompt).strip()
            if tool:
                new_tools = (new_tools + " " + tool).strip()
            if summary is not None:
                new_summary = summary
            # FTS5 content update: delete + re-insert
            conn.execute("DELETE FROM session_search WHERE session_id = ?", (sid,))
            # Re-read project info in case it changed
            info = conn.execute(
                "SELECT project_name, project_dir, git_branch FROM sessions WHERE id = ?",
                (sid,),
            ).fetchone()
            conn.execute(
                """INSERT INTO session_search(session_id, project_name, project_dir, git_branch, prompts, tool_history, summary)
                VALUES (?, ?, ?, ?, ?, ?, ?)""",
                (sid, info[0] if info else None, info[1] if info else None,
                 info[2] if info else None, new_prompts, new_tools, new_summary),
            )
        conn.commit()
    except Exception:
        pass  # Never break Claude Code


def post_to_server(data, port, timeout=5):
    """POST the event JSON to AgentCue's HTTP server.
    Returns the response body (important for PermissionRequest blocking).
    Returns None if the server is unreachable."""
    url = f"http://localhost:{port}/hook"
    try:
        req = urllib.request.Request(
            url, json.dumps(data).encode(), {"Content-Type": "application/json"}
        )
        resp = urllib.request.urlopen(req, timeout=timeout)
        return resp.read().decode()
    except Exception:
        return None


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", type=int, default=DEFAULT_PORT)
    args = parser.parse_args()

    # Read stdin JSON from Claude Code
    try:
        raw = sys.stdin.read()
        data = json.loads(raw) if raw.strip() else {}
    except Exception:
        data = {}

    sid = data.get("session_id", "unknown")
    hook = data.get("hook_event_name", "")
    cwd = data.get("cwd") or os.environ.get("PWD", "")
    # CLAUDE_PROJECT_DIR is the stable project root — use it for name and dir.
    # cwd changes as Claude navigates; project dir stays constant.
    project_dir = os.environ.get("CLAUDE_PROJECT_DIR") or cwd
    pname = os.path.basename(project_dir) if project_dir else None
    tpath = data.get("transcript_path")

    # Source of truth: env vars for terminal context
    ttype, tsid = detect_terminal()

    # PID of the Claude Code process (our parent).
    # getppid() returns 1 (launchd) when the parent already exited — reject that.
    raw_pid = os.getppid()
    pid = raw_pid if raw_pid > 1 else None
    pid_start_time = get_pid_start_time(pid) if pid else None

    # Shell PID: the parent of the Claude process. Needed for IDE terminal tab focus
    # (VS Code's terminal.processId = the shell PID, not the Claude node PID).
    shell_pid = _get_ppid(pid) if pid else None

    # Compute state and extract fields based on event type
    state = STATE_MAP.get(hook)
    msg = None
    tool = None
    prompt = None
    ended_at = None
    clear_tool = False

    if hook == "Notification":
        ntype = data.get("notification_type", "idle_prompt")
        state = NOTIFY_STATE_MAP.get(ntype, "ready")
        msg = data.get("message", "")
        tool = data.get("tool_name")
    elif hook == "Stop":
        msg = data.get("last_assistant_message", "")
        if msg and len(msg) > 200:
            msg = msg[:200] + "..."
    elif hook == "UserPromptSubmit":
        prompt = data.get("message", "")
        if prompt and len(prompt) > 500:
            prompt = prompt[:500] + "..."
        clear_tool = True
    elif hook in ("PreToolUse", "PostToolUse", "PostToolUseFailure"):
        tool = data.get("tool_name")
        ti = data.get("tool_input")
        if isinstance(ti, dict):
            if tool == "Bash" and ti.get("command"):
                c = ti["command"]
                msg = c[:200] + ("..." if len(c) > 200 else "")
            elif ti.get("file_path"):
                msg = ti["file_path"]
        # User interrupted (Ctrl+C / Escape) — Claude is now idle
        if hook == "PostToolUseFailure" and data.get("is_interrupt"):
            state = "ready"
            msg = "Interrupted"
            tool = None
            clear_tool = True
    elif hook == "PermissionRequest":
        tool = data.get("tool_name")
        state = "needs_permission"
    elif hook == "SessionEnd":
        ended_at = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime())
    elif hook == "TaskCompleted":
        msg = "Task completed"

    # --- 1. Write to SQLite (always — this is the brain) ---
    try:
        conn = ensure_db()
        kw = {
            "status": state,
            "terminal_type": ttype,
            "terminal_session_id": tsid or None,
            "pid": pid,
            "pid_start_time": pid_start_time,
            "shell_pid": shell_pid,
        }
        if project_dir:
            kw["project_dir"] = project_dir
        if pname:
            kw["project_name"] = pname
        if msg:
            kw["last_message"] = msg
        if tool:
            kw["tool_name"] = tool
        if prompt:
            kw["last_prompt"] = prompt
        if tpath:
            kw["transcript_path"] = tpath
        if ended_at:
            kw["ended_at"] = ended_at
        if clear_tool:
            kw["clear_tool"] = True
        upsert(conn, sid, **kw)

        # Update FTS search index — index prompts, tools, and summaries as they arrive
        fts_prompt = prompt if hook == "UserPromptSubmit" and prompt else None
        fts_tool = data.get("tool_name") if hook == "PostToolUse" else None
        fts_summary = msg if hook in ("TaskCompleted", "SessionEnd", "Stop") else None
        if hook == "SessionStart" or fts_prompt or fts_tool or fts_summary:
            update_search_index(conn, sid, prompt=fts_prompt, tool=fts_tool, summary=fts_summary)

        conn.close()
    except Exception:
        pass

    # --- 2. POST to HTTP server (for real-time updates) ---
    # Inject terminal context and stable project dir into the JSON payload
    data["terminal_type"] = ttype
    data["terminal_session_id"] = tsid or ""
    data["pid"] = pid
    data["pid_start_time"] = pid_start_time
    data["shell_pid"] = shell_pid
    if project_dir:
        data["project_dir"] = project_dir

    if hook == "PermissionRequest":
        # Block until AgentCue responds (up to 600s).
        # If the server is down, returns None and Claude Code shows its normal dialog.
        resp = post_to_server(data, args.port, timeout=600)
        if resp:
            # Forward the response to stdout so Claude Code receives it
            try:
                parsed = json.loads(resp)
                if parsed:
                    print(json.dumps(parsed))
            except Exception:
                pass
    else:
        # Fire and forget (5s timeout)
        post_to_server(data, args.port, timeout=5)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        # Never break Claude Code
        pass
