"""
ctxbaton CLI — the primary interface for agents and humans.

Usage by agents (via Bash tool):
  ctxbaton context            → full project context to resume work
  ctxbaton replay             → what changed since last/specific snapshot
  ctxbaton search "auth"      → semantic search
  ctxbaton snapshot save      → save a snapshot (called by Stop hook)

Usage by humans:
  ctxbaton snapshot list      → view history
  ctxbaton hooks install      → wire up Claude Code Stop hook
  ctxbaton serve              → start MCP server
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional

import typer
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.text import Text

app = typer.Typer(
    name="ctxbaton",
    help="Persistent agent memory with automatic lifecycle capture.",
    no_args_is_help=True,
)
snapshot_app = typer.Typer(help="Snapshot management", no_args_is_help=True)
memory_app   = typer.Typer(help="Structured memory management", no_args_is_help=True)
todo_app     = typer.Typer(help="Todo management", no_args_is_help=True)
hooks_app    = typer.Typer(help="Lifecycle hook management", no_args_is_help=True)
messages_app = typer.Typer(help="Transcript message history", no_args_is_help=True)

app.add_typer(snapshot_app, name="snapshot")
app.add_typer(memory_app,   name="memory")
app.add_typer(todo_app,     name="todo")
app.add_typer(hooks_app,    name="hooks")
app.add_typer(messages_app, name="messages")

console = Console()
err     = Console(stderr=True)


# ---------------------------------------------------------------------------
# Bootstrap — init DB for every command
# ---------------------------------------------------------------------------

def _bootstrap():
    from . import db
    project = os.environ.get("MEMORY_PROJECT", os.getcwd())
    db.init(project)


# ---------------------------------------------------------------------------
# context — full snapshot for resuming work (primary agent command)
# ---------------------------------------------------------------------------

@app.command()
def context(
    output: str = typer.Option("text", "--output", "-o", help="text | json"),
):
    """
    Print full project context: spec, open todos, latest handoff,
    recent decisions, and last snapshot. Agents call this at session start.
    """
    _bootstrap()
    from . import db
    from .snapshots import last_snapshot

    spec      = db.list_memories(type="spec",     limit=1)
    todos     = db.list_memories(type="todo",     status="pending",     limit=20)
    todos    += db.list_memories(type="todo",     status="in_progress", limit=20)
    handoff   = db.list_memories(type="handoff",  limit=1)
    decisions = db.list_memories(type="decision", limit=5)
    snap      = last_snapshot()

    data = {
        "spec":      spec[0]    if spec    else None,
        "todos":     todos,
        "handoff":   handoff[0] if handoff else None,
        "decisions": decisions,
        "snapshot":  {k: snap[k] for k in ("id","timestamp","git_hash","git_branch") if snap} if snap else None,
    }

    if output == "json":
        print(json.dumps(data, indent=2))
        return

    if data["spec"]:
        console.print(Panel(data["spec"]["content"], title="[bold]Spec[/bold]", border_style="blue"))

    if data["handoff"]:
        console.print(Panel(data["handoff"]["content"], title="[bold]Last Handoff[/bold]", border_style="yellow"))

    if data["todos"]:
        t = Table(title="Open Todos", show_header=True)
        t.add_column("Status", style="cyan", width=12)
        t.add_column("Content")
        for todo in data["todos"]:
            t.add_row(todo["status"], todo["content"])
        console.print(t)

    if data["decisions"]:
        t = Table(title="Recent Decisions", show_header=True)
        t.add_column("Content")
        for d in data["decisions"]:
            t.add_row(d["content"])
        console.print(t)

    if snap:
        console.print(f"\n[dim]Last snapshot: {snap['timestamp']}  git:{(snap.get('git_hash') or 'none')[:8]}  branch:{snap.get('git_branch') or 'none'}[/dim]")

    nothing = not any([data["spec"], data["todos"], data["handoff"], data["decisions"], snap])
    if nothing:
        project = os.environ.get("MEMORY_PROJECT", os.getcwd())
        console.print(f"[dim]No memory yet for[/dim] [bold]{project}[/bold]")
        console.print("[dim]Memories will be captured automatically via the Stop hook.[/dim]")
        console.print("[dim]Add memories manually: ctxbaton memory save --type spec \"your goal\"[/dim]")


# ---------------------------------------------------------------------------
# replay — reconstruct what happened since a point in time
# ---------------------------------------------------------------------------

@app.command()
def replay(
    snapshot_id: Optional[str] = typer.Option(None, "--snapshot", "-s", help="Snapshot ID to replay from"),
    since: Optional[str]       = typer.Option(None, "--since",    "-t", help="Timestamp or relative (e.g. '2h', '30m', 'yesterday')"),
    output: str                = typer.Option("text", "--output", "-o", help="text | json"),
):
    """
    Replay what happened since a snapshot or timestamp.
    Shows: what changed in code (git diff), todo changes, memories added.
    Use this when resuming after an unexpected session end.
    """
    _bootstrap()
    from .snapshots import list_snapshots, _read_all
    from . import db

    snaps = _read_all()
    if not snaps:
        err.print("[yellow]No snapshots found.[/yellow]")
        raise typer.Exit(1)

    # Find the anchor snapshot
    anchor = None
    if snapshot_id:
        anchor = next((s for s in snaps if s["id"] == snapshot_id), None)
        if not anchor:
            err.print(f"[red]Snapshot {snapshot_id} not found.[/red]")
            raise typer.Exit(1)
    elif since:
        cutoff = _parse_since(since)
        # find the last snapshot before the cutoff
        before = [s for s in snaps if s["timestamp"] <= cutoff.isoformat()]
        anchor = before[-1] if before else snaps[0]
    else:
        # default: last snapshot
        anchor = snaps[-1]

    cwd   = anchor.get("cwd", os.getcwd())
    since_hash = anchor.get("git_hash")

    # Git diff since anchor
    git_diff = None
    if since_hash:
        try:
            r = subprocess.run(
                ["git", "diff", "--stat", f"{since_hash}..HEAD"],
                cwd=cwd, capture_output=True, text=True, timeout=10
            )
            git_diff = r.stdout.strip() if r.returncode == 0 else None
        except Exception:
            pass

    git_diff_full = None
    if since_hash:
        try:
            r = subprocess.run(
                ["git", "diff", f"{since_hash}..HEAD"],
                cwd=cwd, capture_output=True, text=True, timeout=10
            )
            git_diff_full = r.stdout.strip() if r.returncode == 0 else None
        except Exception:
            pass

    # Todo changes since anchor
    anchor_todos = {t["id"]: t for t in anchor.get("todos_state", [])}
    from .snapshots import _current_todos, _compute_diff
    curr_todos   = _current_todos()
    todo_diff    = _compute_diff(list(anchor_todos.values()), curr_todos)

    # Snapshots since anchor
    snaps_since = [s for s in snaps if s["timestamp"] > anchor["timestamp"]]

    # Memories added since anchor
    memories_since = db.list_memories(limit=50)
    memories_since = [m for m in memories_since if m["created_at"] > anchor["timestamp"]]

    data = {
        "anchor": {
            "id":        anchor["id"],
            "timestamp": anchor["timestamp"],
            "git_hash":  anchor.get("git_hash"),
            "git_branch": anchor.get("git_branch"),
        },
        "snapshots_since": len(snaps_since),
        "git_diff_stat":   git_diff,
        "git_diff":        git_diff_full,
        "todo_changes":    todo_diff,
        "memories_added":  memories_since,
    }

    if output == "json":
        print(json.dumps(data, indent=2))
        return

    console.print(Panel(
        f"Replaying from [bold]{anchor['timestamp']}[/bold]  "
        f"git:[cyan]{(anchor.get('git_hash') or 'none')[:8]}[/cyan]  "
        f"branch:[cyan]{anchor.get('git_branch','?')}[/cyan]",
        title="[bold]Replay[/bold]",
        border_style="green",
    ))

    if git_diff:
        console.print(Panel(git_diff, title="[bold]Code changes (git diff --stat)[/bold]", border_style="blue"))
    else:
        console.print("[dim]No git diff available.[/dim]")

    if todo_diff:
        t = Table(title="Todo changes", show_header=True)
        t.add_column("Op",      style="cyan", width=8)
        t.add_column("Content/ID")
        t.add_column("Detail")
        for d in todo_diff:
            if d["op"] == "add":
                t.add_row("add", d.get("content", d["id"]), d.get("status", ""))
            elif d["op"] == "remove":
                t.add_row("remove", d["id"], "")
            else:
                detail = ", ".join(
                    f"{k}: {v['from']}→{v['to']}" for k, v in d.items()
                    if k not in ("op", "id") and isinstance(v, dict)
                )
                t.add_row("update", d["id"], detail)
        console.print(t)

    if memories_since:
        t = Table(title="Memories added since anchor", show_header=True)
        t.add_column("Type",    style="cyan", width=10)
        t.add_column("Content")
        for m in memories_since:
            t.add_row(m["type"], m["content"][:80])
        console.print(t)

    if not git_diff and not todo_diff and not memories_since:
        console.print("[dim]Nothing changed since this snapshot.[/dim]")


def _parse_since(s: str) -> datetime:
    """Parse relative time strings like '2h', '30m', 'yesterday'."""
    s = s.strip().lower()
    now = datetime.utcnow()
    if s == "yesterday":
        return now - timedelta(days=1)
    if s.endswith("h"):
        return now - timedelta(hours=float(s[:-1]))
    if s.endswith("m"):
        return now - timedelta(minutes=float(s[:-1]))
    if s.endswith("d"):
        return now - timedelta(days=float(s[:-1]))
    # try ISO
    try:
        return datetime.fromisoformat(s)
    except ValueError:
        err.print(f"[red]Cannot parse time '{s}'. Use: 2h, 30m, 1d, or ISO timestamp.[/red]")
        raise typer.Exit(1)


# ---------------------------------------------------------------------------
# search
# ---------------------------------------------------------------------------

@app.command()
def search(
    query:  str          = typer.Argument(...),
    type:   Optional[str] = typer.Option(None, help="Filter by type"),
    limit:  int           = typer.Option(10, help="Max results"),
    output: str           = typer.Option("text", "--output", "-o"),
):
    """Full-text search across memories in the current project."""
    _bootstrap()
    from . import db

    results = db.search_memories_fts(query, type=type, limit=limit)

    if output == "json":
        print(json.dumps(results, indent=2))
        return

    if not results:
        console.print("[dim]No results found.[/dim]")
        return

    t = Table(title=f"Search: {query}", show_header=True)
    t.add_column("Type",    style="green", width=10)
    t.add_column("Status",  style="cyan",  width=12)
    t.add_column("Content")
    for r in results:
        t.add_row(r["type"], r.get("status") or "", r["content"][:100])
    console.print(t)


# ---------------------------------------------------------------------------
# _do_capture — shared core for capture + snapshot save
# ---------------------------------------------------------------------------

def _do_capture(
    hook_data: dict,
    agent: str = "unknown",
    trigger: str = "stop",
    message_id: Optional[int] = None,
) -> dict:
    """Core capture logic. Saves snapshot, indexes transcript incrementally, upserts session."""
    from .snapshots import save_snapshot
    from . import db

    session_id      = hook_data.get("session_id")
    transcript_path = hook_data.get("transcript_path")
    cwd             = hook_data.get("cwd") or os.environ.get("MEMORY_PROJECT") or os.getcwd()
    agent           = hook_data.get("agent", agent)

    new_messages = 0
    if transcript_path:
        try:
            from .transcript import process_transcript
            offset               = db.get_transcript_cursor(transcript_path)
            inserted, new_offset = process_transcript(transcript_path, session_id=session_id, offset=offset)
            db.set_transcript_cursor(transcript_path, new_offset)
            new_messages = inserted
        except Exception:
            pass

    if session_id:
        db.upsert_session(
            id=session_id, agent=agent, project=cwd,
            transcript_path=transcript_path,
        )

    snap = save_snapshot(
        session_id=session_id, agent=agent,
        transcript_path=transcript_path, cwd=cwd,
        message_id=message_id, trigger=trigger,
    )

    return {
        "snapshot_id":      snap["id"],
        "timestamp":        snap["timestamp"],
        "messages_indexed": new_messages,
        "trigger":          trigger,
    }


def _read_hook_stdin() -> dict:
    if not sys.stdin.isatty():
        try:
            raw = sys.stdin.read().strip()
            if raw:
                return json.loads(raw)
        except Exception:
            pass
    return {}


# ---------------------------------------------------------------------------
# capture — canonical hook entry point
# ---------------------------------------------------------------------------

@app.command()
def capture(
    agent:   str = typer.Option("unknown", envvar="MEMORY_AGENT"),
    trigger: str = typer.Option("stop", help="stop | handoff | crash_recovery | manual"),
):
    """
    Capture current session state. Called automatically by lifecycle hooks.
    Reads hook JSON from stdin (Claude Code Stop hook format).
    """
    _bootstrap()
    result = _do_capture(_read_hook_stdin(), agent=agent, trigger=trigger)
    print(json.dumps(result))


# ---------------------------------------------------------------------------
# resume — canonical agent session-start command
# ---------------------------------------------------------------------------

@app.command()
def resume(
    output: str = typer.Option("text", "--output", "-o", help="text | json"),
):
    """
    Full resumption context: spec, todos, handoff, decisions, and replay since
    last snapshot. Call this at the start of every new agent session.
    """
    _bootstrap()
    from . import db
    from .snapshots import last_snapshot, _current_todos, _compute_diff
    import subprocess as _sp

    spec      = db.list_memories(type="spec",     limit=1)
    todos     = db.list_memories(type="todo",     status="pending",     limit=20)
    todos    += db.list_memories(type="todo",     status="in_progress", limit=20)
    handoff   = db.list_memories(type="handoff",  limit=1)
    decisions = db.list_memories(type="decision", limit=5)
    snap      = last_snapshot()

    git_diff       = None
    todo_diff:  list = []
    mem_since:  list = []
    if snap:
        cwd        = snap.get("cwd", os.getcwd())
        since_hash = snap.get("git_hash")
        if since_hash:
            try:
                r = _sp.run(
                    ["git", "diff", "--stat", f"{since_hash}..HEAD"],
                    cwd=cwd, capture_output=True, text=True, timeout=10,
                )
                git_diff = r.stdout.strip() if r.returncode == 0 else None
            except Exception:
                pass
        todo_diff = _compute_diff(snap.get("todos_state", []), _current_todos())
        mem_since = [m for m in db.list_memories(limit=20) if m["created_at"] > snap["timestamp"]]

    msg_count = db.count_messages()

    data = {
        "spec":          spec[0]    if spec    else None,
        "todos":         todos,
        "handoff":       handoff[0] if handoff else None,
        "decisions":     decisions,
        "snapshot":      {k: snap[k] for k in ("id","timestamp","git_hash","git_branch","trigger") if snap and k in snap} if snap else None,
        "replay":        {"git_diff_stat": git_diff, "todo_changes": todo_diff, "memories_added": mem_since},
        "message_count": msg_count,
    }

    if output == "json":
        print(json.dumps(data, indent=2))
        return

    if data["spec"]:
        console.print(Panel(data["spec"]["content"], title="[bold]Spec[/bold]", border_style="blue"))

    if data["handoff"]:
        console.print(Panel(data["handoff"]["content"], title="[bold]Latest Handoff[/bold]", border_style="yellow"))

    if data["todos"]:
        t = Table(title="Open Todos", show_header=True)
        t.add_column("Status", style="cyan", width=12)
        t.add_column("Content")
        for todo in data["todos"]:
            t.add_row(todo["status"], todo["content"])
        console.print(t)

    if data["decisions"]:
        t = Table(title="Recent Decisions", show_header=True)
        t.add_column("Content")
        for d in data["decisions"]:
            t.add_row(d["content"])
        console.print(t)

    # Replay panel
    if snap:
        parts: list[str] = []
        if git_diff:
            parts.append(f"[bold]Code changes:[/bold]\n{git_diff}")
        if todo_diff:
            lines = []
            for d in todo_diff:
                op = d["op"]
                label = d.get("content", d["id"]) if op in ("add","remove") else d["id"]
                lines.append(f"  {op}: {label}")
            parts.append("[bold]Todo changes:[/bold]\n" + "\n".join(lines))
        if mem_since:
            lines = [f"  [{m['type']}] {m['content'][:80]}" for m in mem_since]
            parts.append("[bold]Memories added:[/bold]\n" + "\n".join(lines))
        body = "\n\n".join(parts) if parts else "[dim]No changes since last snapshot.[/dim]"
        ts   = snap["timestamp"][:19].replace("T", " ")
        console.print(Panel(body, title=f"[bold]Since last snapshot[/bold] [dim]({ts})[/dim]", border_style="green"))

    git_short = (snap.get("git_hash") or "none")[:8] if snap else "none"
    console.print(f"\n[dim]Last snapshot: {snap['timestamp'][:19] if snap else 'none'}  git:{git_short}  | {msg_count} messages indexed[/dim]")

    if not any([data["spec"], data["todos"], data["handoff"], data["decisions"], snap]):
        project = os.environ.get("MEMORY_PROJECT", os.getcwd())
        console.print(f"[dim]No memory yet for[/dim] [bold]{project}[/bold]")
        console.print("[dim]Run `ctxbaton hooks install` then start a session.[/dim]")


# ---------------------------------------------------------------------------
# handoff — explicit cross-agent transfer
# ---------------------------------------------------------------------------

@app.command()
def handoff(
    message: str           = typer.Argument(..., help="What you did, what's next, any blockers"),
    agent:   str           = typer.Option("unknown", "--agent", envvar="MEMORY_AGENT", help="Agent name (claude, codex, opencode, cursor...)"),
    todo_id: Optional[str] = typer.Option(None, "--todo", help="Mark this todo as in_progress"),
):
    """
    Save a handoff note and capture session state.
    Call this before switching agents or closing a session mid-task.
    """
    _bootstrap()
    import uuid
    from . import db

    m = db.insert_memory(
        id=str(uuid.uuid4()), content=message, type="handoff",
    )

    if todo_id:
        db.update_memory(id=todo_id, status="in_progress")

    result = _do_capture({}, agent=agent, trigger="handoff")

    console.print(f"[green]Handoff saved.[/green]  memory:{m['id'][:8]}  snapshot:{result['snapshot_id'][:8]}")
    console.print(f"[dim]Next agent: ctxbaton resume[/dim]")


# ---------------------------------------------------------------------------
# snapshot commands
# ---------------------------------------------------------------------------

@snapshot_app.command("save")
def snapshot_save(
    agent:      str           = typer.Option("unknown", envvar="MEMORY_AGENT"),
    message_id: Optional[int] = typer.Option(None),
):
    """
    Save a snapshot of current state. Called automatically by the Stop hook.
    Reads hook JSON from stdin when available (Claude Code lifecycle format).
    """
    _bootstrap()
    result = _do_capture(_read_hook_stdin(), agent=agent, trigger="stop", message_id=message_id)
    print(json.dumps(result))


@snapshot_app.command("list")
def snapshot_list(
    limit:  int = typer.Option(20),
    output: str = typer.Option("text", "--output", "-o"),
):
    """List recent snapshots."""
    _bootstrap()
    from .snapshots import list_snapshots

    snaps = list_snapshots(limit=limit)
    if output == "json":
        print(json.dumps(snaps, indent=2))
        return

    if not snaps:
        console.print("[dim]No snapshots yet.[/dim]")
        return

    t = Table(title="Snapshots", show_header=True)
    t.add_column("Timestamp",        style="dim",   width=22)
    t.add_column("Git hash",         style="cyan",  width=10)
    t.add_column("Branch",           style="green", width=16)
    t.add_column("Todo changes",     width=14)
    t.add_column("ID",               style="dim",   width=10)

    for s in snaps:
        diff_summary = f"+{sum(1 for d in s.get('todo_diff',[]) if d['op']=='add')} " \
                       f"~{sum(1 for d in s.get('todo_diff',[]) if d['op']=='update')} " \
                       f"-{sum(1 for d in s.get('todo_diff',[]) if d['op']=='remove')}"
        t.add_row(
            s["timestamp"][:19],
            (s.get("git_hash") or "")[:8],
            s.get("git_branch") or "",
            diff_summary,
            s["id"][:8],
        )
    console.print(t)


@snapshot_app.command("show")
def snapshot_show(id: str, output: str = typer.Option("text", "--output", "-o")):
    """Show a specific snapshot in detail."""
    _bootstrap()
    from .snapshots import get_snapshot

    snap = get_snapshot(id) or next(
        (s for s in __import__("ctxbaton.snapshots", fromlist=["_read_all"])._read_all()
         if s["id"].startswith(id)), None
    )
    if not snap:
        err.print(f"[red]Snapshot {id} not found[/red]")
        raise typer.Exit(1)

    if output == "json":
        print(json.dumps(snap, indent=2))
        return

    console.print_json(json.dumps(snap, indent=2))


# ---------------------------------------------------------------------------
# memory commands
# ---------------------------------------------------------------------------

@memory_app.command("save")
def memory_save(
    content:  str           = typer.Argument(...),
    type:     str           = typer.Option("general"),
    summary:  Optional[str] = typer.Option(None),
    tags:     Optional[str] = typer.Option(None, help="Comma-separated"),
    agent:    Optional[str] = typer.Option(None, envvar="MEMORY_AGENT"),
    session:  Optional[str] = typer.Option(None),
):
    """Save a structured memory."""
    _bootstrap()
    import uuid
    from . import db

    tag_list = [t.strip() for t in tags.split(",")] if tags else []
    status = "pending" if type == "todo" else None
    m = db.insert_memory(
        id=str(uuid.uuid4()), content=content, summary=summary,
        type=type, status=status, tags=tag_list, agent=agent, session_id=session,
    )
    console.print(f"[green]Saved[/green] {m['id'][:8]} ({m['type']})")


@memory_app.command("list")
def memory_list(
    type:   Optional[str] = typer.Option(None),
    status: Optional[str] = typer.Option(None),
    limit:  int           = typer.Option(20),
    output: str           = typer.Option("text", "--output", "-o"),
):
    """List memories with optional filters."""
    _bootstrap()
    from . import db

    results = db.list_memories(type=type, status=status, limit=limit)
    if output == "json":
        print(json.dumps(results, indent=2))
        return

    t = Table(show_header=True)
    t.add_column("Type",    style="cyan",  width=10)
    t.add_column("Status",  style="green", width=12)
    t.add_column("Content")
    for m in results:
        t.add_row(m["type"], m.get("status") or "", m["content"][:100])
    console.print(t)


# ---------------------------------------------------------------------------
# todo commands
# ---------------------------------------------------------------------------

@todo_app.command("list")
def todo_list(
    status: Optional[str] = typer.Option(None, help="pending | in_progress | done"),
    output: str           = typer.Option("text", "--output", "-o"),
):
    """List todos."""
    _bootstrap()
    from . import db

    todos = db.list_memories(type="todo", status=status, limit=50)
    if output == "json":
        print(json.dumps(todos, indent=2))
        return

    t = Table(title="Todos", show_header=True)
    t.add_column("Status", style="cyan", width=14)
    t.add_column("Content")
    t.add_column("ID", style="dim", width=10)
    for todo in todos:
        color = {"pending": "yellow", "in_progress": "blue", "done": "green"}.get(todo["status"] or "", "white")
        t.add_row(f"[{color}]{todo['status']}[/{color}]", todo["content"], todo["id"][:8])
    console.print(t)


@todo_app.command("done")
def todo_done(id: str):
    """Mark a todo as done."""
    _bootstrap()
    from . import db

    updated = db.update_memory(id=id, status="done")
    if not updated:
        err.print(f"[red]Todo {id} not found[/red]")
        raise typer.Exit(1)
    console.print(f"[green]✓[/green] {updated['content'][:60]}")


@todo_app.command("start")
def todo_start(id: str):
    """Mark a todo as in_progress."""
    _bootstrap()
    from . import db

    updated = db.update_memory(id=id, status="in_progress")
    if not updated:
        err.print(f"[red]Todo {id} not found[/red]")
        raise typer.Exit(1)
    console.print(f"[blue]→[/blue] {updated['content'][:60]}")


# ---------------------------------------------------------------------------
# messages commands
# ---------------------------------------------------------------------------

@messages_app.command("list")
def messages_list(
    session: Optional[str] = typer.Option(None, "--session", "-s", help="Filter by session ID"),
    role:    Optional[str] = typer.Option(None, "--role",    "-r", help="user | assistant"),
    limit:   int           = typer.Option(50,   "--limit",   "-n"),
    offset:  int           = typer.Option(0,    "--offset"),
    output:  str           = typer.Option("text", "--output", "-o"),
):
    """List transcript messages."""
    _bootstrap()
    from . import db

    total  = db.count_messages(session_id=session)
    msgs   = db.list_messages(session_id=session, role=role, limit=limit, offset=offset)

    if output == "json":
        print(json.dumps(msgs, indent=2))
        return

    if not msgs:
        console.print("[dim]No messages found.[/dim]")
        return

    t = Table(title=f"Messages ({total} total)", show_header=True)
    t.add_column("Time",     style="dim",   width=19)
    t.add_column("Role",     style="cyan",  width=10)
    t.add_column("Text",                    width=55)
    t.add_column("Tools",    style="green", width=20)

    for m in msgs:
        ts    = (m["timestamp"] or "")[:19].replace("T", " ")
        tools = ", ".join(tc["name"] for tc in m["tool_calls"]) if m["tool_calls"] else ""
        text  = (m["text"] or "")[:100].replace("\n", " ")
        t.add_row(ts, m["role"], text, tools)

    console.print(t)
    if total > offset + limit:
        console.print(f"[dim]Showing {offset+1}–{offset+len(msgs)} of {total}. Use --offset to page.[/dim]")


@messages_app.command("search")
def messages_search(
    query:   str           = typer.Argument(...),
    session: Optional[str] = typer.Option(None, "--session", "-s", help="Filter by session ID"),
    limit:   int           = typer.Option(20, "--limit", "-n"),
    output:  str           = typer.Option("text", "--output", "-o"),
):
    """Full-text search over indexed transcript messages."""
    _bootstrap()
    from . import db

    results = db.search_messages_text(query, session_id=session, limit=limit)
    if output == "json":
        print(json.dumps(results, indent=2))
        return

    if not results:
        console.print("[dim]No results.[/dim]")
        return

    t = Table(title=f"Messages matching: {query}", show_header=True)
    t.add_column("Time",  style="dim",  width=19)
    t.add_column("Role",  style="cyan", width=10)
    t.add_column("Text",  width=70)
    for m in results:
        ts   = (m["timestamp"] or "")[:19].replace("T", " ")
        text = (m["text"] or "")[:140].replace("\n", " ")
        t.add_row(ts, m["role"], text)
    console.print(t)


@messages_app.command("index")
def messages_index(
    transcript: str = typer.Argument(..., help="Path to JSONL transcript file"),
    session:    Optional[str] = typer.Option(None, "--session", "-s"),
):
    """Manually index a transcript file."""
    _bootstrap()
    from .transcript import process_transcript

    n, _ = process_transcript(transcript, session_id=session)
    console.print(f"[green]Indexed[/green] {n} messages from {transcript}")


# ---------------------------------------------------------------------------
# hooks install
# ---------------------------------------------------------------------------

@hooks_app.command("install")
def hooks_install():
    """Install the Stop hook into Claude Code settings."""
    import shutil
    from pathlib import Path

    hooks_src = Path(__file__).parent.parent.parent / "hooks" / "stop.sh"
    hooks_dst = Path.home() / ".claude" / "hooks" / "ctxbaton-stop.sh"
    hooks_dst.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy(hooks_src, hooks_dst)
    hooks_dst.chmod(0o755)

    settings_path = Path.home() / ".claude" / "settings.json"
    settings: dict = {}
    if settings_path.exists():
        with open(settings_path) as f:
            settings = json.load(f)

    hooks = settings.setdefault("hooks", {})
    stop_hooks = hooks.setdefault("Stop", [])

    hook_entry = {
        "matcher": "",
        "hooks": [{"type": "command", "command": str(hooks_dst)}],
    }

    # avoid duplicates
    already = any(
        h.get("hooks", [{}])[0].get("command") == str(hooks_dst)
        for h in stop_hooks
        if h.get("hooks")
    )
    if not already:
        stop_hooks.append(hook_entry)
        with open(settings_path, "w") as f:
            json.dump(settings, f, indent=2)
        console.print(f"[green]✓[/green] Stop hook installed → {hooks_dst}")
    else:
        console.print("[dim]Stop hook already installed.[/dim]")


# ---------------------------------------------------------------------------
# init — write AGENTS.md into the current project
# ---------------------------------------------------------------------------

@app.command()
def init(
    force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing AGENTS.md"),
):
    """
    Write AGENTS.md into the current project directory.
    Claude Code, Codex, and OpenCode all read this file automatically.
    """
    from importlib.resources import files
    dest = Path(os.getcwd()) / "AGENTS.md"
    if dest.exists() and not force:
        console.print(f"[yellow]AGENTS.md already exists.[/yellow] Use --force to overwrite.")
        raise typer.Exit(1)
    src = files("ctxbaton.templates").joinpath("AGENTS.md")
    dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
    console.print(f"[green]Created[/green] AGENTS.md")
    console.print(f"[dim]Agents will now run `ctxbaton resume` at session start automatically.[/dim]")


# ---------------------------------------------------------------------------
# serve — start MCP server
# ---------------------------------------------------------------------------

@app.command()
def serve():
    """Start the MCP server."""
    _bootstrap()
    from .server import mcp
    mcp.run()


# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------

def main():
    app()


if __name__ == "__main__":
    main()
