"""AgntSpace CLI — full command suite.

All commands that need a running server call the REST API over HTTP.
Lifecycle commands (init, start) run directly.
"""

from __future__ import annotations

import asyncio
import json
import tomllib
from pathlib import Path

import typer
from rich.console import Console
from rich.table import Table

app = typer.Typer(
    name="agntspace",
    help="Personal AI agent that runs your life alongside you.",
    no_args_is_help=True,
)
console = Console()


def _get_base_url() -> str:
    from agntspace.infra.config import get_settings
    s = get_settings()
    return f"http://{s.host}:{s.port}"


def _check_server(base_url: str) -> bool:
    import httpx
    try:
        httpx.get(f"{base_url}/api/health", timeout=3)
        return True
    except httpx.ConnectError:
        console.print(f"[red]Server not running at {base_url}[/red]")
        console.print("Start it with: [bold]agntspace start[/bold]")
        return False


def _get(path: str, params: dict | None = None, timeout: int = 10) -> dict | list:
    import httpx
    base = _get_base_url()
    resp = httpx.get(f"{base}{path}", params=params, timeout=timeout)
    resp.raise_for_status()
    return resp.json()


def _post(path: str, data: dict | None = None, timeout: int = 30) -> dict | list:
    import httpx
    base = _get_base_url()
    resp = httpx.post(f"{base}{path}", json=data, timeout=timeout)
    resp.raise_for_status()
    return resp.json()


def _put(path: str, data: dict | None = None, timeout: int = 10) -> dict | list:
    import httpx
    base = _get_base_url()
    resp = httpx.put(f"{base}{path}", json=data, timeout=timeout)
    resp.raise_for_status()
    return resp.json()


def _delete(path: str, timeout: int = 10) -> dict | list:
    import httpx
    base = _get_base_url()
    resp = httpx.delete(f"{base}{path}", timeout=timeout)
    resp.raise_for_status()
    return resp.json()


# ═══════════════════════════════════════════
# LIFECYCLE
# ═══════════════════════════════════════════


def _try_reclaim_port(host: str, port: int) -> bool:
    """Refuse to kill any process holding the port.

    Architectural decision (2026-04-25): we no longer probe-then-kill.
    The previous design had three failure modes that compounded into a
    crash loop:

      1. The /api/health probe used to require a substring match on
         "agntspace" in the response body — a string that never appears
         in /api/health. EVERY healthy server failed the check and was
         taskkill'd by the next launch attempt.
      2. Even after the substring bug was fixed to look for HTTP/1.x 200,
         a server busy with the graph build (loop.run_in_executor on
         the default ThreadPoolExecutor) couldn't respond within the
         18s retry budget and was killed mid-build.
      3. The killed server's incomplete graph build never persisted
         build_index.json, so the next boot did another full rebuild
         from scratch — which got killed by the next launch attempt.

    Across one afternoon of debugging this produced 48+ Python PIDs in
    24 hours with ZERO actual crashes in errors.log. Every "death" was
    self-inflicted by the launcher's reclaim logic.

    The right behavior is what every other long-running server does:
    if the port is busy, abort the new launch with a clear message.
    The user decides whether to ``agntspace stop`` the existing one,
    open it, or pick a different port. We never destroy work that
    another process is doing.

    Returns False unconditionally.
    """
    import subprocess
    import sys

    # Identify the port holder so we can show a useful message — but
    # never act on it.
    pid: int | None = None
    try:
        if sys.platform == "win32":
            out = subprocess.check_output(
                ["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL,
            )
            for line in out.splitlines():
                if f":{port}" in line and "LISTENING" in line:
                    try:
                        pid = int(line.strip().split()[-1])
                        break
                    except ValueError:
                        pass
        else:
            out = subprocess.check_output(
                ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
                text=True, stderr=subprocess.DEVNULL,
            ).strip()
            if out:
                try:
                    pid = int(out.splitlines()[0])
                except ValueError:
                    pass
    except Exception:
        pid = None

    pid_str = f" (PID {pid})" if pid is not None else ""
    console.print(
        f"\n  [bold yellow]A server is already running on http://{host}:{port}{pid_str}.[/]\n"
        f"  [dim]This launcher will NOT kill it — running servers are off-limits to\n"
        f"  prevent the crash-loop pattern where two launchers race to take the\n"
        f"  port and each kill the other before the graph build completes.[/]\n\n"
        f"  Open it:    [bold]http://{host}:{port}[/]\n"
        f"  Stop it:    [bold]agntspace stop[/]\n"
        f"  Or pick a different port:    [bold]agntspace start --port 8001[/]\n"
    )
    return False


@app.command()
def init(
    path: str | None = typer.Argument(None, help="Path to your vault folder (Obsidian vault, Dropbox, or any folder)"),
    minimal: bool = typer.Option(False, help="Non-interactive setup"),
) -> None:
    """Initialize AgntSpace in a vault folder.

    Creates agntspace-inbox/, agntspace-journal/, agntspace-knowledge/,
    agntspace-projects/ and agntspace/ (internals) inside the given folder.

    Example: agntspace init /path/to/my/vault
    """
    from agntspace.infra.logging import setup_logging
    from agntspace.setup.init import run_init
    setup_logging()

    if not path:
        console.print(
            "[red]Please specify a vault folder.[/red]\n"
            "  Usage: [bold]agntspace init /path/to/your/vault[/bold]\n"
            "  This can be an Obsidian vault, Dropbox folder, or any directory.\n"
            "  Your existing files will not be touched."
        )
        raise typer.Exit(1)

    vault_path = Path(path).expanduser().resolve()
    if not vault_path.is_dir():
        vault_path.mkdir(parents=True, exist_ok=True)

    root = run_init(vault_dir=vault_path, minimal=minimal)

    # Save root_dir to config so the server knows where the vault is
    config_dir = Path.home() / ".agntspace"
    config_dir.mkdir(parents=True, exist_ok=True)
    config_path = config_dir / "config.toml"
    if config_path.exists():
        import tomli_w
        with open(config_path, "rb") as _f:
            _data = tomllib.load(_f)
        _data["root_dir"] = str(vault_path)
        config_path.write_bytes(tomli_w.dumps(_data).encode())
    else:
        import tomli_w
        config_path.write_bytes(tomli_w.dumps({
            "root_dir": str(vault_path),
            "llm": {"mode": "cloud", "cloud_provider": "anthropic", "cloud_model": "balanced"},
            "server": {"host": "127.0.0.1", "port": 8000},
        }).encode())

    console.print(f"\n[green]Initialized at {root}[/green]")
    console.print(f"  Config saved: root_dir = {vault_path}")
    console.print("Next: [bold]agntspace start[/bold]")


@app.command()
def start(
    host: str = typer.Option("127.0.0.1", help="Bind address"),
    port: int = typer.Option(8000, help="Port"),
    reload: bool = typer.Option(False, help="Auto-reload on code changes"),
) -> None:
    """Start the AgntSpace server."""
    import socket
    import threading
    import time
    import webbrowser

    import uvicorn

    from agntspace.infra.config import get_settings
    settings = get_settings()

    # Fatal-signal handler: writes the Python stack trace to a file when the
    # process dies from SIGSEGV / SIGABRT / SIGBUS / SIGILL — the classes of
    # hard-fault that C extensions (fastembed, ONNX Runtime, faster-whisper,
    # pdfplumber native libs, ffmpeg subprocess) cause without producing a
    # normal Python traceback on stderr. pythonw.exe has no console so stderr
    # is lost; a dedicated file handle guarantees the trace survives even
    # when the scheduled task launched us detached from any terminal.
    # Opened with line-buffering so partial frames still flush before exit.
    try:
        import faulthandler
        import os

        _fh_path = settings.log_dir / "faulthandler.log"
        _fh_path.parent.mkdir(parents=True, exist_ok=True)
        # Append mode: successive crashes accumulate for post-mortem analysis
        # without a single dump clobbering the previous one.
        _fh_file = open(_fh_path, "a", buffering=1, encoding="utf-8")  # noqa: SIM115 — file stays open for process lifetime
        _fh_file.write(f"\n--- faulthandler armed pid={os.getpid()} ts={time.strftime('%Y-%m-%dT%H:%M:%S')} ---\n")
        _fh_file.flush()
        faulthandler.enable(file=_fh_file, all_threads=True)
    except Exception:  # noqa: BLE001 — fatal-signal diagnostics are opportunistic; never let setup failure block startup
        pass

    if not settings.home_dir.exists():
        from agntspace.setup.init import run_init
        run_init(vault_dir=settings.root_dir, minimal=True)

    # Check port availability — kill stale AgntSpace process if needed
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        if s.connect_ex((host, port)) == 0:
            if not _try_reclaim_port(host, port):
                console.print(f"[bold red]Port {port} is in use by another program.[/] Use --port to pick a different one.")
                raise typer.Exit(1)

    from agntspace.api.routes.health import _get_version, _parse_version
    ver = _get_version()
    console.print(f"""
  [bold green]    _    ____ _   _ _____ [/][bold white] ____  ____   _    ____ _____ [/]
  [bold green]   / \\  / ___| \\ | |_   _|[/][bold white]/ ___||  _ \\ / \\  / ___| ____|[/]
  [bold green]  / _ \\| |  _|  \\| | | | [/][bold white] \\___ \\| |_) / _ \\| |   |  _|  [/]
  [bold green] / ___ | |_| | |\\  | | | [/][bold white]  ___) |  __/ ___ | |___| |___ [/]
  [bold green]/_/   \\_\\____|_| \\_| |_| [/][bold white] |____/|_| /_/   \\_\\____|_____|[/]  [dim]v{ver}[/]
""")

    # Quick PyPI version check (non-blocking, 3s timeout)
    try:
        import httpx
        resp = httpx.get("https://pypi.org/pypi/agntspace/json", timeout=3.0)
        if resp.status_code == 200:
            latest = resp.json().get("info", {}).get("version", "")
            if latest and ver != "dev" and _parse_version(latest) > _parse_version(ver):
                console.print(f"  [bold yellow]Update available: v{ver} -> v{latest}[/]")
                console.print("  [dim]Run:[/] pip install --upgrade agntspace")
                console.print()
    except Exception:
        pass

    console.print(f"  [dim]Server[/]  http://{host}:{port}")
    console.print(f"  [dim]Vault[/]   {settings.root_dir}")
    console.print()

    # Determine which page to open and schedule browser launch in background
    # (uvicorn.run blocks, so we launch a thread that waits for the server)
    url = f"http://{host}:{port}"
    marker = Path.home() / ".agntspace" / ".browser-opened"

    def _open_browser() -> None:
        """Wait for the server to be ready, then open the right page."""
        for _ in range(30):  # up to 15s
            time.sleep(0.5)
            try:
                with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                    if s.connect_ex((host, port)) == 0:
                        break
            except Exception:
                pass
        else:
            return  # server never came up

        try:
            now = time.time()
            recent = marker.exists() and (now - marker.stat().st_mtime) < 86400

            # Detect state: no vault → setup, vault but not onboarded → onboarding, else dashboard
            has_vault = (
                settings.root_explicit
                or settings.root_dir != Path.home()
                or (settings.vault_dir / "system" / "identity" / "SOUL.md").exists()
            )

            if not has_vault:
                # Fresh install — always open setup
                webbrowser.open(f"{url}/setup")
            else:
                # Check onboarding status
                needs_onboarding = True
                try:
                    profile_path = settings.vault_dir / "system" / "identity" / "PROFILE.md"
                    if profile_path.exists():
                        content = profile_path.read_text(encoding="utf-8")
                        needs_onboarding = (
                            "[Your name]" in content or len(content.strip()) <= 200
                        )
                except Exception:
                    pass

                if needs_onboarding:
                    webbrowser.open(f"{url}/onboarding")
                elif not recent:
                    webbrowser.open(f"{url}/dashboard")

            marker.parent.mkdir(parents=True, exist_ok=True)
            marker.write_text(str(now))
        except Exception:
            pass

    threading.Thread(target=_open_browser, daemon=True).start()

    # Dev reload races with the vault singleton lock: when uvicorn
    # restarts the worker on file change, the new worker boots before
    # the dying one's lifespan teardown finishes — the new worker
    # would see a fresh-heartbeat lock and refuse to start. Skip the
    # lock entirely in reload mode; the dev workflow accepts the
    # one-process invariant being relaxed during iteration.
    if reload:
        import os as _os
        _os.environ["AGNTSPACE_SKIP_LOCK"] = "1"

    uvicorn.run("agntspace.main:create_app", factory=True, host=host, port=port, reload=reload, log_level="warning")


@app.command()
def status() -> None:
    """Show server status and key metrics."""
    base = _get_base_url()
    if not _check_server(base):
        return
    health = _get("/api/health")
    vault = _get("/api/vault/status")
    costs = _get("/api/costs/budget")
    agents = _get("/api/orchestrator/agents")
    projects = _get("/api/projects")

    console.print(f"\n[bold green]AgntSpace[/bold green] v{health.get('version', '?')} — {base}")
    console.print(f"  Onboarded: {'Yes' if vault.get('onboarded') else '[yellow]No[/yellow]'}")
    console.print(f"  Agents: {len(agents)}  |  Projects: {len(projects)}  |  Tasks: {vault.get('task_count', 0)}  |  Goals: {vault.get('goal_count', 0)}")
    console.print(f"  Today's cost: ${costs.get('daily_cost', 0):.2f} / ${costs.get('daily_budget', 10):.2f}")
    console.print()


@app.command()
def stop() -> None:
    """Stop the running AgntSpace server."""
    import os
    import signal
    import subprocess

    import httpx

    base = _get_base_url()
    try:
        httpx.get(f"{base}/api/health", timeout=2)
    except Exception:
        console.print("[yellow]Server not running.[/]")
        return

    from agntspace.infra.config import get_settings
    port = get_settings().port
    try:
        result = subprocess.run(["netstat", "-ano"], capture_output=True, text=True, timeout=5)
        for line in result.stdout.splitlines():
            if f":{port}" in line and "LISTENING" in line:
                pid = int(line.strip().split()[-1])
                os.kill(pid, signal.SIGTERM)
                console.print("[bold green]Stopped.[/]")
                return
        console.print("[yellow]Could not find server process.[/]")
    except Exception as e:
        console.print(f"[red]Failed to stop: {e}[/]")


def _list_agntspace_processes(*, exclude_self: bool = True) -> list[dict]:
    """Return one dict per running agntspace python process.

    Cross-platform implementation — Windows uses PowerShell's CIM
    interface (wmic is deprecated); POSIX uses ``ps -eo pid,lstart,command``.
    NEVER raises — returns an empty list when the platform tools are
    unavailable. NEVER kills anything; this is a pure diagnostic.

    Each dict has: ``pid: int``, ``started: str``, ``cmdline: str``.
    Filtered to processes whose command line contains either
    ``agntspace.cli`` or ``agntspace`` (the module form when running
    via ``python -m agntspace ...``).

    ``exclude_self`` (default True) filters out our own process so
    the ``ps`` command doesn't list itself as a stale entry.
    """
    import json
    import os
    import subprocess
    import sys

    self_pid = os.getpid() if exclude_self else None
    rows: list[dict] = []
    try:
        if sys.platform == "win32":
            # PowerShell ConvertTo-Json gives us structured output we can
            # parse without playing tab-stop games. -NoProfile keeps it
            # fast even on machines with heavy PS profiles.
            ps_cmd = (
                "Get-CimInstance Win32_Process -Filter \"Name='python.exe' OR Name='pythonw.exe'\" | "
                "Select-Object ProcessId, CommandLine, CreationDate | "
                "ConvertTo-Json -Compress"
            )
            out = subprocess.check_output(
                ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd],
                text=True, stderr=subprocess.DEVNULL, timeout=10,
            )
            if not out.strip():
                return []
            data = json.loads(out)
            # Single result is a dict, multiple is a list — normalize.
            entries = data if isinstance(data, list) else [data]
            for entry in entries:
                cmdline = entry.get("CommandLine") or ""
                if "agntspace" not in cmdline:
                    continue
                pid_val = entry.get("ProcessId")
                if pid_val is not None and self_pid is not None and int(pid_val) == self_pid:
                    continue
                created = entry.get("CreationDate") or ""
                # CIM CreationDate looks like "/Date(1714112883000)/"
                started = ""
                if isinstance(created, str) and "/Date(" in created:
                    try:
                        ts_ms = int(created.split("(")[1].split(")")[0].split("+")[0].rstrip("-"))
                        from datetime import datetime as _dt
                        started = _dt.fromtimestamp(ts_ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
                    except Exception:
                        started = ""
                if pid_val is not None:
                    rows.append({"pid": int(pid_val), "started": started, "cmdline": cmdline})
        else:
            # POSIX — ps with lstart is consistent across mac+linux
            out = subprocess.check_output(
                ["ps", "-eo", "pid,lstart,command"],
                text=True, stderr=subprocess.DEVNULL, timeout=10,
            )
            for line in out.splitlines()[1:]:  # skip header
                if "agntspace" not in line:
                    continue
                # Format: PID Mon DD HH:MM:SS YYYY COMMAND...
                parts = line.strip().split(None, 6)
                if len(parts) < 7:
                    continue
                try:
                    pid_val = int(parts[0])
                except ValueError:
                    continue
                if self_pid is not None and pid_val == self_pid:
                    continue
                started = " ".join(parts[1:6])
                cmdline = parts[6]
                rows.append({"pid": pid_val, "started": started, "cmdline": cmdline})
    except Exception:
        # Diagnostic command must never raise — return what we have.
        pass
    return rows


def _pid_holding_port(port: int) -> int | None:
    """Return the PID listening on ``port``, or None. Never raises."""
    import subprocess
    import sys

    try:
        if sys.platform == "win32":
            out = subprocess.check_output(
                ["netstat", "-ano"], text=True, stderr=subprocess.DEVNULL, timeout=5,
            )
            for line in out.splitlines():
                if f":{port}" in line and "LISTENING" in line:
                    try:
                        return int(line.strip().split()[-1])
                    except ValueError:
                        continue
        else:
            out = subprocess.check_output(
                ["lsof", "-ti", f"tcp:{port}", "-sTCP:LISTEN"],
                text=True, stderr=subprocess.DEVNULL, timeout=5,
            ).strip()
            if out:
                try:
                    return int(out.splitlines()[0])
                except ValueError:
                    return None
    except Exception:
        return None
    return None


@app.command(name="ps")
def ps_cmd() -> None:
    """List running AgntSpace processes (diagnostic — never kills).

    Shows every python process whose command line references AgntSpace,
    plus which one (if any) is bound to the configured server port.
    Use this to spot orphan processes left over from crashes,
    interrupted upgrades, or `taskkill` that didn't clean up the whole
    process tree. The fix is always manual: ``taskkill /F /PID <id>``
    on Windows or ``kill <id>`` on POSIX.

    Why no auto-kill: the launcher used to do that and produced 48+
    self-inflicted Python deaths in one afternoon. See the docstring
    on ``_try_reclaim_port`` for the post-mortem.
    """
    from agntspace.infra.config import get_settings

    port = get_settings().port
    procs = _list_agntspace_processes()
    bound_pid = _pid_holding_port(port)

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

    table = Table(title=f"AgntSpace processes (port {port})", show_lines=False)
    table.add_column("PID", justify="right", style="cyan")
    table.add_column("Started", style="dim")
    table.add_column("Port", justify="center")
    table.add_column("Command", style="white")

    # Sort: port-bound first, then by PID
    procs_sorted = sorted(procs, key=lambda p: (p["pid"] != bound_pid, p["pid"]))

    for proc in procs_sorted:
        on_port = proc["pid"] == bound_pid
        marker = "[bold green]LIVE[/]" if on_port else "[yellow]stale[/]"
        # Truncate cmdline so the table stays readable
        cmd = proc["cmdline"]
        if len(cmd) > 80:
            cmd = cmd[:77] + "..."
        table.add_row(
            str(proc["pid"]),
            proc["started"] or "—",
            marker,
            cmd,
        )

    console.print(table)

    stale_count = sum(1 for p in procs if p["pid"] != bound_pid)
    if stale_count > 0:
        console.print(
            f"\n  [yellow]{stale_count} stale process(es) detected[/] — "
            "not bound to the configured port. They may be orphans from\n"
            "  a previous run that didn't clean up. Kill manually if needed:\n"
            "  [bold]taskkill /F /PID <id>[/] (Windows)  |  [bold]kill <id>[/] (POSIX)\n"
        )
        console.print(
            "  [dim]The launcher deliberately doesn't auto-kill — it has been\n"
            "  too dangerous in the past (see cli.py:_try_reclaim_port docstring).[/]"
        )


# ═══════════════════════════════════════════
# CHAT
# ═══════════════════════════════════════════


@app.command()
def chat(
    message: str | None = typer.Argument(None, help="Single message (non-interactive)"),
) -> None:
    """Chat with the agent. Interactive if no message given."""
    import httpx
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)

    if message:
        data = _post("/api/chat", {"message": message})
        console.print(f"\n[bold green]Agent:[/bold green] {data.get('message', '')}\n")
        return

    async def _loop() -> None:
        async with httpx.AsyncClient(base_url=base, timeout=120) as client:
            conv_id: str | None = None
            console.print("[dim]Interactive chat. Type 'exit' to quit.[/dim]\n")
            while True:
                try:
                    user_input = console.input("[bold cyan]You:[/bold cyan] ")
                except (EOFError, KeyboardInterrupt):
                    break
                if user_input.strip().lower() in ("exit", "quit", "/quit"):
                    break
                if not user_input.strip():
                    continue
                try:
                    resp = await client.post("/api/chat", json={"message": user_input, "conversation_id": conv_id})
                    resp.raise_for_status()
                    data = resp.json()
                    conv_id = data["conversation_id"]
                    console.print(f"\n[bold green]Agent:[/bold green] {data['message']}\n")
                except httpx.HTTPError as e:
                    console.print(f"[red]Error: {e}[/red]")
    asyncio.run(_loop())


# ═══════════════════════════════════════════
# TASKS
# ═══════════════════════════════════════════


@app.command()
def tasks(
    status_filter: str | None = typer.Option(None, "--status", "-s", help="Filter by status"),
) -> None:
    """List tasks."""
    base = _get_base_url()
    if not _check_server(base):
        return
    params = {"status": status_filter} if status_filter else {}
    data = _get("/api/tasks", params)
    if not data:
        console.print("[dim]No tasks.[/dim]")
        return
    table = Table(title="Tasks")
    table.add_column("Title")
    table.add_column("Status")
    table.add_column("Mode")
    table.add_column("Domain")
    for t in data:
        table.add_row(t["title"], t["status"], t["mode"], t.get("domain", ""))
    console.print(table)


@app.command()
def delegate(
    description: str = typer.Argument(..., help="Task description"),
    mode: str = typer.Option("jdi", help="Mode: jdi, review, recommend, watch"),
) -> None:
    """Delegate a task to the agent."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post("/api/tasks", {"title": description[:80], "brief": description, "mode": mode})
    console.print(f"[green]Delegated:[/green] {data.get('path', '?')}")


# ═══════════════════════════════════════════
# PROJECTS
# ═══════════════════════════════════════════


@app.command()
def projects(
    status_filter: str | None = typer.Option(None, "--status", "-s", help="Filter by status"),
) -> None:
    """List projects."""
    base = _get_base_url()
    if not _check_server(base):
        return
    params = {"status": status_filter} if status_filter else {}
    data = _get("/api/projects", params)
    if not data:
        console.print("[dim]No projects.[/dim]")
        return
    table = Table(title="Projects")
    table.add_column("Title")
    table.add_column("Status")
    table.add_column("Target")
    table.add_column("Goals")
    table.add_column("Agents")
    for p in data:
        table.add_row(
            p["title"], p["status"], p.get("target_date", ""),
            ", ".join(p.get("related_goals", [])),
            ", ".join(p.get("assigned_agents", [])),
        )
    console.print(table)


@app.command(name="project-create")
def project_create(
    title: str = typer.Argument(..., help="Project title"),
    description: str = typer.Option("", help="Project description"),
) -> None:
    """Create a new project."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post("/api/projects", {"title": title, "description": description})
    console.print(f"[green]Created:[/green] {data.get('path', '?')}")


# ═══════════════════════════════════════════
# GOALS
# ═══════════════════════════════════════════


@app.command()
def goals() -> None:
    """List goals."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/goals")
    if not data:
        console.print("[dim]No goals.[/dim]")
        return
    table = Table(title="Goals")
    table.add_column("Title")
    table.add_column("Status")
    table.add_column("Type")
    table.add_column("Target Date")
    for g in data:
        table.add_row(g["title"], g["status"], g.get("type", ""), g.get("target_date", ""))
    console.print(table)


# ═══════════════════════════════════════════
# JOURNAL
# ═══════════════════════════════════════════


@app.command()
def journal(
    thought: str = typer.Argument(..., help="Quick thought to add"),
    section: str = typer.Option("Thoughts", help="Section: Focus, Thoughts, Decisions, Learnings, Reflections"),
) -> None:
    """Add a thought to today's journal."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    _post("/api/journal/append", {"text": thought, "section": section, "author": "HUMAN"})
    console.print(f"[green]Added to {section}.[/green]")


@app.command()
def jot(
    thought: str = typer.Argument(..., help="Quick thought to capture"),
    section: str = typer.Option("Thoughts", "--section", "-s", help="Section: Focus, Thoughts, Decisions, Learnings, Reflections"),
    tags: str = typer.Option("", "--tags", "-t", help="Comma-separated tags, inlined as #foo #bar"),
) -> None:
    """Quick-capture a thought into today's journal (defaults to Thoughts)."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    text = thought.strip()
    if tags:
        tag_str = " ".join(f"#{t.strip()}" for t in tags.split(",") if t.strip())
        if tag_str:
            text = f"{text} {tag_str}"
    _post("/api/journal/append", {"text": text, "section": section, "author": "HUMAN"})
    console.print(f"[green]Jotted to {section}.[/green]")


@app.command(name="journal-today")
def journal_today() -> None:
    """Show today's journal."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/journal/today")
    if data.get("content"):
        console.print(data["content"])
    else:
        console.print("[dim]No journal entry for today.[/dim]")


# ═══════════════════════════════════════════
# AGENTS
# ═══════════════════════════════════════════


@app.command()
def agents() -> None:
    """List subagents and their status."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/orchestrator/agents")
    table = Table(title="Subagents")
    table.add_column("Key")
    table.add_column("Name")
    table.add_column("Role")
    table.add_column("Status")
    table.add_column("Access")
    table.add_column("Skills")
    for a in data:
        table.add_row(a["key"], a["name"], a["role"], a["status"], a["access"], ", ".join(a.get("skills", [])))
    console.print(table)


@app.command(name="dispatch")
def dispatch_agent(
    agent: str = typer.Argument(..., help="Agent key (e.g. writer, researcher)"),
    task: str = typer.Argument(..., help="Task description"),
) -> None:
    """Dispatch a task to a specific subagent."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post("/api/orchestrator/dispatch", {"agent_name": agent, "task": task}, timeout=120)
    console.print(f"\n[bold green]{data.get('agent_name', agent)}:[/bold green] {data.get('content', '')}")
    console.print(f"\n[dim]{data.get('input_tokens', 0)} in + {data.get('output_tokens', 0)} out tokens[/dim]")


# ═══════════════════════════════════════════
# TRUST
# ═══════════════════════════════════════════


@app.command()
def trust(
    domain: str | None = typer.Option(None, help="Set level for this domain"),
    level: str | None = typer.Option(None, help="New level: Observer, Advisor, Assistant, Partner"),
) -> None:
    """Show or update trust levels."""
    base = _get_base_url()
    if not _check_server(base):
        return
    if domain and level:
        _put("/api/trust/level", {"domain": domain, "level": level})
        console.print(f"[green]{domain} → {level}[/green]")
        return
    data = _get("/api/trust")
    table = Table(title="Trust Levels")
    table.add_column("Domain")
    table.add_column("Level")
    table.add_column("Tasks")
    table.add_column("Good")
    table.add_column("Redo")
    for d in data.get("domains", []):
        table.add_row(d["domain"], d["level"], str(d["completed"]), str(d["good"]), str(d["redo"]))
    console.print(table)


# ═══════════════════════════════════════════
# WIKI
# ═══════════════════════════════════════════


@app.command()
def wiki(
    query: str | None = typer.Argument(None, help="Search query"),
) -> None:
    """Search wiki or show stats."""
    base = _get_base_url()
    if not _check_server(base):
        return
    if query:
        data = _get("/api/wiki/search", {"q": query})
        if not data:
            console.print(f"[dim]No results for '{query}'[/dim]")
            return
        for a in data[:10]:
            console.print(f"  [bold]{a.get('title', a['slug'])}[/bold] — {a.get('snippet', '')[:80]}")
        return
    stats = _get("/api/wiki/stats")
    console.print(f"Wiki: {stats.get('total_articles', 0)} articles, {stats.get('total_kbs', 0)} KBs")


@app.command(name="wiki-job")
def wiki_job(
    kb: str | None = typer.Option(None, help="KB slug (omit for all KBs)"),
) -> None:
    """Run the wiki pipeline (ingest, compile, lint)."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    console.print("[dim]Running wiki job...[/dim]")
    if kb:
        data = _post(f"/api/kb/{kb}/job", timeout=300)
    else:
        data = _post("/api/kb/job/all", timeout=300)
    console.print(f"[green]Done.[/green] {json.dumps(data, indent=2)[:500]}")


@app.command(name="skill-import")
def skill_import(
    path: str = typer.Argument(..., help="Path to a skill folder (must contain SKILL.md)"),
    category: str = typer.Option("private", "--category", "-c", help="Skill category (private, work, creative, core)"),
) -> None:
    """Import a skill from an external folder into the vault."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    from pathlib import Path as P
    resolved = str(P(path).resolve())
    data = _post("/api/skills/import", {"path": resolved, "category": category})
    console.print(f"[green]Imported skill:[/green] {data.get('title', data.get('name'))}")
    console.print(f"  Name: [bold]{data.get('name')}[/bold]")
    console.print(f"  Category: {data.get('category')}")
    console.print(f"  Path: [dim]{data.get('path')}[/dim]")
    console.print(f"  Files copied: {data.get('files_copied', 0)}")


@app.command(name="skills-status")
def skills_status() -> None:
    """Show skill-sync status — how many skills have shipped upgrades waiting."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _get("/api/skills/sync/status")
    console.print(f"Shipped files tracked: [bold]{data.get('shipped_count', 0)}[/bold]")
    console.print(f"Accepted in vault:     [bold]{data.get('tracked', 0)}[/bold]")
    pending = data.get("pending_count", 0)
    color = "yellow" if pending else "green"
    console.print(f"Pending review:        [bold {color}]{pending}[/bold {color}]")
    for p in data.get("pending_paths", []):
        console.print(f"  - {p}")


@app.command(name="skills-sync")
def skills_sync(
    accept_all: bool = typer.Option(False, "--accept-all", help="Accept every shipped upgrade (destroys local edits)"),
    keep_mine: bool = typer.Option(False, "--keep-mine", help="Keep every local edit (dismisses pending upgrades)"),
) -> None:
    """Run a skill-sync pass. Without flags: shows what would happen.

    With --accept-all or --keep-mine: resolves every pending upgrade in bulk.
    Use the /skills/sync UI for per-file diff review.
    """
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)

    if accept_all and keep_mine:
        console.print("[red]--accept-all and --keep-mine are mutually exclusive[/red]")
        raise typer.Exit(2)

    # Always run a sync pass so the pending list reflects the current state.
    report = _post("/api/skills/sync/run", {})
    summary = report.get("summary", {})
    console.print("[bold]Sync pass:[/bold]")
    for k, v in summary.items():
        if v:
            console.print(f"  {k}: {v}")

    if not (accept_all or keep_mine):
        if report.get("pending"):
            console.print(
                f"\n[yellow]{len(report['pending'])} skill(s) need review.[/yellow] "
                f"Open [bold]/skills/sync[/bold] in the UI to diff, or rerun with "
                f"--accept-all / --keep-mine."
            )
        return

    pending_paths = list(report.get("pending", []))
    if not pending_paths:
        console.print("[green]Nothing pending.[/green]")
        return

    endpoint = "/api/skills/sync/accept" if accept_all else "/api/skills/sync/keep-mine"
    label = "accepted" if accept_all else "kept local edit"
    for path in pending_paths:
        try:
            _post(endpoint, {"path": path})
            console.print(f"  [green]{label}:[/green] {path}")
        except Exception as exc:
            console.print(f"  [red]failed:[/red] {path} — {exc}")


@app.command(name="agents-status")
def agents_status() -> None:
    """Show agent-sync status — how many agent definitions have shipped upgrades waiting."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _get("/api/orchestrator/agents/sync/status")
    console.print(f"Shipped agents tracked: [bold]{data.get('shipped_count', 0)}[/bold]")
    console.print(f"Accepted in vault:      [bold]{data.get('tracked', 0)}[/bold]")
    pending = data.get("pending_count", 0)
    color = "yellow" if pending else "green"
    console.print(f"Pending review:         [bold {color}]{pending}[/bold {color}]")
    for p in data.get("pending_paths", []):
        console.print(f"  - {p}")


@app.command(name="agents-sync")
def agents_sync(
    accept_all: bool = typer.Option(False, "--accept-all", help="Accept every shipped agent upgrade (destroys local edits)"),
    keep_mine: bool = typer.Option(False, "--keep-mine", help="Keep every local edit (dismisses pending upgrades)"),
) -> None:
    """Run an agent-sync pass. Without flags: shows what would happen.

    With --accept-all or --keep-mine: resolves every pending upgrade in bulk.
    Use the /agents/sync UI for per-file diff review.
    """
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)

    if accept_all and keep_mine:
        console.print("[red]--accept-all and --keep-mine are mutually exclusive[/red]")
        raise typer.Exit(2)

    report = _post("/api/orchestrator/agents/sync/run", {})
    summary = report.get("summary", {})
    console.print("[bold]Agent sync pass:[/bold]")
    for k, v in summary.items():
        if v:
            console.print(f"  {k}: {v}")

    if not (accept_all or keep_mine):
        if report.get("pending"):
            console.print(
                f"\n[yellow]{len(report['pending'])} agent(s) need review.[/yellow] "
                f"Open [bold]/agents/sync[/bold] in the UI to diff, or rerun with "
                f"--accept-all / --keep-mine."
            )
        return

    pending_paths = list(report.get("pending", []))
    if not pending_paths:
        console.print("[green]Nothing pending.[/green]")
        return

    endpoint = "/api/orchestrator/agents/sync/accept" if accept_all else "/api/orchestrator/agents/sync/keep-mine"
    label = "accepted" if accept_all else "kept local edit"
    for path in pending_paths:
        try:
            _post(endpoint, {"path": path})
            console.print(f"  [green]{label}:[/green] {path}")
        except Exception as exc:
            console.print(f"  [red]failed:[/red] {path} — {exc}")


@app.command(name="kb-create")
def kb_create(
    topic: str = typer.Argument(..., help="Name/topic for the knowledge base"),
    description: str = typer.Option("", help="What this KB is about"),
    questions: list[str] = typer.Option([], "--question", "-q", help="Focus questions (repeat for multiple)"),
    source_types: list[str] = typer.Option([], "--source-type", "-s", help="Expected source types (repeat for multiple)"),
    notes: str = typer.Option("", help="Agent instructions for this domain"),
) -> None:
    """Create a new dedicated knowledge base."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post("/api/kb", {
        "topic": topic,
        "description": description,
        "focus_questions": questions,
        "source_types": source_types,
        "notes": notes,
    })
    console.print(f"[green]Created KB:[/green] {data.get('topic', topic)}")
    console.print(f"  Slug: [bold]{data.get('slug')}[/bold]")
    console.print(f"  Path: [dim]{data.get('path')}[/dim]")
    slug = data.get('slug', '')
    console.print(f"\n  Drop files into the KB inbox ([bold]agntspace-knowledge/{slug}/inbox/[/bold]) and run [bold]agntspace wiki-job --kb {slug}[/bold]")


@app.command(name="kb-layout")
def kb_layout(
    slug: str = typer.Argument(..., help="KB slug"),
    set_to: str = typer.Option("", "--set", help="New layout: thematic | type-date | flat"),
) -> None:
    """Get or change the library layout for a KB.

    Without ``--set``, prints the current layout. With ``--set`` writes it
    to SCHEMA.md. Does NOT move existing files — run ``agntspace kb-reorganize``
    to apply the new layout to library/.
    """
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    if not set_to:
        data = _get(f"/api/kb/{slug}/library/layout")
        console.print(f"Current layout for [cyan]{slug}[/cyan]: [bold]{data.get('layout')}[/bold]")
        return
    data = _put(f"/api/kb/{slug}/library/layout", {"layout": set_to})
    console.print(f"[green]Layout changed:[/green] {data.get('previous')} → [bold]{data.get('layout')}[/bold]")
    console.print("  Existing files were not moved. Run [bold]agntspace kb-reorganize " + slug + "[/bold] to apply.")


@app.command(name="kb-reorganize")
def kb_reorganize(
    slug: str = typer.Argument(..., help="KB slug"),
    execute: bool = typer.Option(False, "--execute", help="Execute the moves (omit for dry-run preview)"),
) -> None:
    """Reorganize a KB's library/ to match its current layout setting.

    Defaults to dry-run: shows the proposed moves without touching disk.
    Add ``--execute`` to actually move files. Per-file activity events
    are emitted; existing files are never overwritten (collisions become
    errors).
    """
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post(f"/api/kb/{slug}/library/reorganize", {"dry_run": not execute})
    layout = data.get("layout", "?")
    moves = data.get("moves", [])
    console.print(f"Layout: [bold]{layout}[/bold]")
    if not moves:
        console.print("[green]Already organized — nothing to move.[/green]")
        return
    if not execute:
        console.print(f"\n[yellow]Dry-run:[/yellow] {len(moves)} file(s) would move")
    else:
        executed = data.get("executed", 0)
        errors = data.get("errors", [])
        console.print(f"\n[green]Executed:[/green] {executed} / {len(moves)} move(s)")
        if errors:
            console.print(f"[red]Errors ({len(errors)}):[/red]")
            for err in errors[:10]:
                console.print(f"  - {err.get('file', '?')}: {err.get('reason', '?')}")
    table = Table(title="Moves" if execute else "Proposed moves")
    table.add_column("From", style="dim")
    table.add_column("To", style="cyan")
    table.add_column("Fallback", style="yellow")
    for m in moves[:50]:
        table.add_row(m.get("from", ""), m.get("to", ""), m.get("fallback_reason", "") or "-")
    console.print(table)
    if len(moves) > 50:
        console.print(f"  … and {len(moves) - 50} more")
    if not execute and moves:
        console.print(f"\n  Run [bold]agntspace kb-reorganize {slug} --execute[/bold] to apply.")


@app.command(name="kb-language-drift")
def kb_language_drift(
    slug: str = typer.Option("", help="Restrict to one KB slug (default: all KBs)"),
    reingest: bool = typer.Option(False, "--reingest", help="Actually re-ingest drifted sources (omit for dry-run)"),
) -> None:
    """List (or re-ingest) source pages whose recorded language policy
    no longer matches the current effective policy.

    Spec §19 item 3 — after changing a language policy at any layer (config,
    skill, SCHEMA, or frontmatter), use this to find pages that were written
    under the old policy and optionally re-process them.
    """
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)

    if reingest:
        params = f"?slug={slug}" if slug else ""
        data = _post(f"/api/kb/language-drift/reingest{params}", {})
        console.print(f"[green]Drifted sources:[/green] {data.get('drifted_count', 0)}")
        console.print(f"[green]Re-queued:[/green] {data.get('requeued', 0)}")
        kbs = data.get("kbs_ingested", [])
        if kbs:
            console.print(f"[green]Re-ingested KBs:[/green] {', '.join(kbs)}")
        errors = data.get("errors", [])
        if errors:
            console.print(f"[red]Errors ({len(errors)}):[/red]")
            for err in errors[:10]:
                console.print(f"  - {err}")
        return

    # Dry run — just list drifted pages
    params = {"slug": slug} if slug else None
    data = _get("/api/kb/language-drift", params=params)
    drifted = data.get("drifted", [])
    scanned = data.get("scanned", 0)
    if not drifted:
        console.print(f"[green]No drifted sources.[/green] Scanned {scanned} source page(s).")
        return
    table = Table(title=f"Language policy drift ({len(drifted)} / {scanned} scanned)")
    table.add_column("KB", style="cyan")
    table.add_column("Source")
    table.add_column("Recorded", style="dim")
    table.add_column("Current", style="green")
    for entry in drifted:
        rec = entry.get("recorded", {})
        cur = entry.get("current", {})
        table.add_row(
            entry.get("kb", ""),
            entry.get("source", ""),
            f"{rec.get('output_lang', '')}/{rec.get('policy', '')} ({rec.get('source', '')})",
            f"{cur.get('output_lang', '')}/{cur.get('policy', '')} ({cur.get('source', '')})",
        )
    console.print(table)
    console.print("\n  Run [bold]agntspace kb-language-drift --reingest[/bold] to re-process them.")


# ═══════════════════════════════════════════
# MEMORY
# ═══════════════════════════════════════════


@app.command()
def memory(
    generate: bool = typer.Option(False, help="Generate today's memory"),
    compact: bool = typer.Option(False, help="Run temporal compaction"),
) -> None:
    """Show permanent memory, or generate/compact."""
    base = _get_base_url()
    if not _check_server(base):
        return
    if generate:
        data = _post("/api/memory/generate", timeout=120)
        console.print(f"[green]Generated:[/green] {data.get('path', '?')}")
        return
    if compact:
        data = _post("/api/memory/compact", timeout=120)
        console.print(f"[green]Compacted.[/green] {data.get('status', '')}")
        return
    data = _get("/api/memory/permanent/read")
    if data.get("content"):
        console.print(data["content"])
    else:
        console.print("[dim]No permanent memory yet.[/dim]")


# ═══════════════════════════════════════════
# COSTS
# ═══════════════════════════════════════════


@app.command()
def costs(
    days: int = typer.Option(7, help="Period in days"),
) -> None:
    """Show cost tracking summary."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/costs", {"days": days})
    budget = _get("/api/costs/budget")
    console.print(f"\n[bold]Cost Summary ({days} days)[/bold]")
    console.print(f"  Total: ${data.get('total_cost', 0):.2f}  |  Requests: {data.get('total_requests', 0)}")
    console.print(f"  Today: ${budget.get('daily_cost', 0):.2f} / ${budget.get('daily_budget', 10):.2f}")
    console.print(f"  Month: ${budget.get('monthly_cost', 0):.2f} / ${budget.get('monthly_budget', 100):.2f}")
    by_model = data.get("by_model", {})
    if by_model:
        console.print("\n  By model:")
        for model, info in by_model.items():
            console.print(f"    {model}: ${info['cost']:.2f} ({info['requests']} calls)")
    console.print()


# ═══════════════════════════════════════════
# CONTRACT
# ═══════════════════════════════════════════


@app.command()
def contract() -> None:
    """Show contract permissions summary."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/contract/structured")
    perms = data.get("permissions", {})
    settings = data.get("settings", {})
    console.print("\n[bold]Contract[/bold]")
    console.print(f"  Privacy: {settings.get('privacy_mode', '?')}  |  Proactivity: {settings.get('proactivity', '?')}")
    console.print(f"  Allowed: {len(perms.get('allow', []))}  |  Confirm: {len(perms.get('confirm', []))}  |  Blocked: {len(perms.get('block', []))}")
    if perms.get("block"):
        console.print(f"  Blocked: {', '.join(perms['block'])}")
    console.print()


# ═══════════════════════════════════════════
# SEARCH
# ═══════════════════════════════════════════


@app.command()
def search(
    query: str = typer.Argument(..., help="Search query"),
    limit: int = typer.Option(10, help="Max results"),
) -> None:
    """Search the vault."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/search", {"q": query, "limit": limit})
    if not data:
        console.print(f"[dim]No results for '{query}'[/dim]")
        return
    for r in data:
        console.print(f"  [bold]{r.get('title', r.get('path', '?'))}[/bold]")
        if r.get("snippet"):
            console.print(f"    {r['snippet'][:100]}")


# ═══════════════════════════════════════════
# EMERGENCY
# ═══════════════════════════════════════════


@app.command(name="emergency-stop")
def emergency_stop() -> None:
    """Halt all agent activity immediately."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    _post("/api/emergency-stop")
    console.print("[red bold]AGENT FROZEN — all activity halted.[/red bold]")


@app.command()
def resume() -> None:
    """Resume agent after emergency stop."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    _post("/api/resume")
    console.print("[green]Agent resumed.[/green]")


# ═══════════════════════════════════════════
# UTILITIES
# ═══════════════════════════════════════════


@app.command()
def forget(
    topic: str = typer.Argument(..., help="Topic to purge from search index"),
) -> None:
    """Purge a topic from the search index."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    results = _get("/api/search", {"q": topic, "limit": 20})
    console.print(f"Found {len(results)} matching files.")
    _post("/api/search/reindex", timeout=30)
    console.print("[green]Search index rebuilt.[/green]")


@app.command()
def reindex() -> None:
    """Rebuild the vault search index."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post("/api/search/reindex", timeout=60)
    console.print(f"[green]Reindexed {data.get('indexed', 0)} files.[/green]")


@app.command()
def export(
    output: str = typer.Option("agntspace-export.tar.gz", help="Output file"),
) -> None:
    """Export all user data as a portable archive."""
    import shutil

    from agntspace.infra.config import get_settings
    settings = get_settings()
    if not settings.home_dir.exists():
        console.print("[red]Nothing to export.[/red]")
        raise typer.Exit(1)
    output_path = Path(output).with_suffix("")
    shutil.make_archive(str(output_path), "gztar", str(settings.home_dir.parent), settings.home_dir.name)
    console.print(f"[green]Exported to {output_path.with_suffix('.tar.gz')}[/green]")


@app.command()
def audit(
    today: bool = typer.Option(False, help="Only today's entries"),
    limit: int = typer.Option(20, help="Max entries"),
) -> None:
    """Show audit log."""
    base = _get_base_url()
    if not _check_server(base):
        return
    endpoint = "/api/audit/today" if today else f"/api/audit?limit={limit}"
    entries = _get(endpoint)
    if not entries:
        console.print("[dim]No audit entries.[/dim]")
        return
    table = Table(title="Audit Trail")
    table.add_column("Time", style="dim")
    table.add_column("Action")
    table.add_column("Target")
    table.add_column("Approved")
    for e in entries:
        table.add_row(e.get("ts", "")[:19], e.get("action", ""), e.get("target", ""), e.get("approved", ""))
    console.print(table)


@app.command()
def autopilot(
    on: bool = typer.Option(False, help="Enable"),
    off: bool = typer.Option(False, help="Disable"),
) -> None:
    """Enable/disable/check autopilot."""
    base = _get_base_url()
    if not _check_server(base):
        return
    if on:
        _post("/api/autopilot", {"enabled": True})
        console.print("[green]Autopilot ON[/green]")
    elif off:
        _post("/api/autopilot", {"enabled": False})
        console.print("[green]Autopilot OFF[/green]")
    else:
        data = _get("/api/autopilot")
        console.print(f"Autopilot: {'[green]ON[/green]' if data.get('enabled') else '[red]OFF[/red]'}")


@app.command(name="obsidian")
def obsidian_cmd(
    path: str = typer.Argument(..., help="Path to Obsidian vault"),
) -> None:
    """Connect to an Obsidian vault (direct mode).

    Prefers the REST endpoint (`POST /api/sync/adopt`) when the server is
    running so the action flows through contract enforcement and emits a
    user-activity event. Falls back to direct VaultSync when the server is
    down (first-run bootstrap).
    """
    obs_path = Path(path).expanduser().resolve()
    if not obs_path.is_dir():
        console.print(f"[red]Path does not exist: {obs_path}[/red]")
        raise typer.Exit(1)

    base = _get_base_url()
    if _check_server(base):
        # Server is up — go through the REST API so middleware logs the action
        result = _post("/api/sync/adopt", {"path": str(obs_path)})
        if isinstance(result, dict) and "error" in result:
            console.print(f"[red]{result['error']}[/red]")
            raise typer.Exit(1)
        console.print(f"[green]Connected to {obs_path} (server will restart)[/green]")
        return

    # Server down — direct mode (first-run bootstrap)
    from agntspace.infra.config import get_settings
    from agntspace.infra.settings_service import SettingsService
    from agntspace.vault.sync import VaultSync

    settings = get_settings()
    sync = VaultSync(vault_dir=settings.vault_dir)
    result = sync.adopt_obsidian_vault(str(obs_path))
    if "error" in result:
        console.print(f"[red]{result['error']}[/red]")
        raise typer.Exit(1)
    svc = SettingsService(settings.config_path)
    svc.update_section("sync", {"mode": "direct", "external_path": str(obs_path)})
    console.print(f"[green]Connected to {obs_path}[/green]")
    console.print("[dim]Start the server with: agntspace start[/dim]")


@app.command(name="mcp-serve")
def mcp_serve_cmd() -> None:
    """Run AgntSpace as an MCP server over stdio.

    Exposes vault memory, knowledge graph, tasks, projects, goals, journal
    and KB query tools to any MCP-compatible client (Claude Desktop,
    Cursor, IDE extensions).

    Not for interactive use. Wire it into your MCP client config, e.g.:

        {
          "mcpServers": {
            "agntspace": {
              "command": "agntspace",
              "args": ["mcp-serve"]
            }
          }
        }
    """
    from agntspace.mcp.__main__ import main as _mcp_main
    _mcp_main()


@app.command(name="install-daemon")
def install_daemon_cmd(host: str = typer.Option("127.0.0.1"), port: int = typer.Option(8000)) -> None:
    """Install as a system service."""
    from agntspace.automation.daemon import install_daemon
    console.print(install_daemon(host=host, port=port))


@app.command(name="uninstall-daemon")
def uninstall_daemon_cmd() -> None:
    """Remove system service."""
    from agntspace.automation.daemon import uninstall_daemon
    console.print(uninstall_daemon())


@app.command(name="daemon-status")
def daemon_status_cmd() -> None:
    """Check daemon status."""
    from agntspace.automation.daemon import daemon_status
    info = daemon_status()
    console.print(f"Installed: {'Yes' if info['installed'] else 'No'}  |  Running: {'Yes' if info['running'] else 'No'}")


# ═══════════════════════════════════════════
# ADDITIONAL COMMANDS
# ═══════════════════════════════════════════


@app.command()
def restart(
    host: str = typer.Option("127.0.0.1", help="Bind address"),
    port: int = typer.Option(8000, help="Port"),
) -> None:
    """Stop and restart the AgntSpace server."""
    import subprocess
    import sys
    import time
    # Stop first
    stop()
    time.sleep(2)
    cmd = ["agntspace", "start", "--host", host, "--port", str(port)]
    if sys.platform == "win32":
        # Windows: spawn in a new console window so the user sees logs
        subprocess.Popen(
            cmd,
            creationflags=subprocess.CREATE_NEW_CONSOLE,  # type: ignore[attr-defined]
        )
        console.print("[green]Restarting in new window...[/green]")
    else:
        # macOS / Linux: detach from the current shell so the server keeps
        # running after this command returns. Logs go to ~/.agntspace/logs/.
        subprocess.Popen(
            cmd,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            stdin=subprocess.DEVNULL,
            start_new_session=True,
        )
        console.print(f"[green]Restarting AgntSpace on http://{host}:{port}[/green]")
        console.print("[dim]Server detached — logs at ~/.agntspace/logs/agntspace.log[/dim]")


@app.command()
def version() -> None:
    """Show AgntSpace version."""
    from agntspace.api.routes.health import _get_version
    console.print(f"AgntSpace v{_get_version()}")


@app.command()
def graph(
    entity: str | None = typer.Argument(None, help="Entity to look up"),
    path_to: str | None = typer.Option(None, "--path-to", help="Find path to another entity"),
) -> None:
    """Query the knowledge graph."""
    base = _get_base_url()
    if not _check_server(base):
        return
    if entity and path_to:
        data = _get(f"/api/graph/path/{entity}/{path_to}")
        paths = data.get("paths", [])
        if not paths:
            console.print(f"[dim]No path between {entity} and {path_to}[/dim]")
            return
        for p in paths:
            console.print(" -> ".join(p))
        return
    if entity:
        data = _get(f"/api/graph/entity/{entity}/triples")
        if not data.get("found"):
            console.print(f"[dim]No data for '{entity}'[/dim]")
            return
        ent = data.get("entity", {})
        console.print(f"\n[bold]{ent.get('name', entity)}[/bold] ({ent.get('type', '?')})")
        for pred, facts in data.get("facts", {}).items():
            for f in facts:
                console.print(f"  {pred}: {f.get('value', '?')}  [dim]({f.get('authority', '?')})[/dim]")
        console.print()
        return
    # No args — show stats
    stats = _get("/api/graph/stats")
    console.print("\n[bold]Knowledge Graph[/bold]")
    console.print(f"  Nodes: {stats.get('total_nodes', 0)}  |  Edges: {stats.get('total_edges', 0)}")
    console.print(f"  Wiki entities: {stats.get('wiki_entities', 0)}  |  Files: {stats.get('files', 0)}")
    console.print(f"  Wikilink edges: {stats.get('wikilink_edges', 0)}  |  SPO edges: {stats.get('spo_edges', 0)}")
    console.print()


@app.command()
def workflow(
    workflow_id: str = typer.Argument(..., help="Workflow ID (e.g. wiki-management, daily-cycle)"),
    context: str = typer.Option("", help="Additional context for the workflow"),
) -> None:
    """Run a supervisor-driven workflow."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    console.print(f"[dim]Running workflow: {workflow_id}...[/dim]")
    data = _post(f"/api/orchestrator/workflow/{workflow_id}", {"context": context} if context else None, timeout=300)
    console.print(f"[green]Done.[/green] Status: {data.get('status', '?')}")
    steps = data.get("steps_completed", [])
    if steps:
        for s in steps:
            console.print(f"  {s}")
    console.print()


@app.command()
def scrape(
    url: str = typer.Argument(..., help="URL to scrape"),
    kb: str = typer.Option("general", help="Target KB slug"),
    filename: str | None = typer.Option(None, help="Custom filename"),
) -> None:
    """Scrape a URL into a knowledge base."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    console.print(f"[dim]Scraping {url}...[/dim]")
    payload: dict = {"url": url}
    if filename:
        payload["filename"] = filename
    data = _post(f"/api/kb/{kb}/scrape", payload, timeout=60)
    console.print(f"[green]Saved:[/green] {data.get('path', data.get('filename', '?'))}")


@app.command()
def heartbeat(
    limit: int = typer.Option(20, help="Number of recent pulses"),
) -> None:
    """Show recent heartbeat pulses."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/heartbeat/history", {"limit": limit})
    pulses = data if isinstance(data, list) else data.get("pulses", [])
    if not pulses:
        console.print("[dim]No heartbeat data.[/dim]")
        return
    table = Table(title="Heartbeat")
    table.add_column("Time", style="dim")
    table.add_column("Type")
    table.add_column("Status")
    table.add_column("Tasks")
    for p in pulses[-limit:]:
        table.add_row(
            p.get("ts", "")[:19],
            p.get("pulse_type", ""),
            p.get("status", ""),
            str(p.get("active_tasks", 0)),
        )
    console.print(table)


@app.command()
def plugins() -> None:
    """List plugins and their status."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/plugins")
    items = data.get("plugins", []) if isinstance(data, dict) else data
    if not items:
        console.print("[dim]No plugins installed.[/dim]")
        return
    table = Table(title="Plugins")
    table.add_column("Name")
    table.add_column("Version")
    table.add_column("Enabled")
    table.add_column("Tools")
    for p in items:
        tools = p.get("provides", {}).get("tools", [])
        table.add_row(
            p.get("title", p.get("name", "?")),
            p.get("version", "?"),
            "[green]Yes[/green]" if p.get("enabled") else "[red]No[/red]",
            str(len(tools)),
        )
    console.print(table)


@app.command(name="onenote-import")
def onenote_import_cmd(
    target_folder: str = typer.Option("OneNote", help="Vault subfolder for imports."),
    notebook: list[str] = typer.Option(
        None,
        "--notebook",
        "-n",
        help="Restrict to these notebook names (repeatable). Omit for all.",
    ),
    full: bool = typer.Option(
        False,
        "--full",
        help="Force a full re-import (ignore the incremental state file).",
    ),
    follow: bool = typer.Option(
        True,
        "--follow/--no-follow",
        help="Poll /progress and print live counters until the job finishes.",
    ),
) -> None:
    """Import OneNote notebooks (Windows-only) and optionally follow progress.

    Kicks off an async import via the onenote-import plugin's /run endpoint,
    then polls /progress/{job_id} every second. Ctrl-C stops following but
    the import continues in the background — re-attach with
    ``agntspace onenote-progress <job_id>``.
    """

    base = _get_base_url()
    if not _check_server(base):
        return
    payload = {
        "target_folder": target_folder,
        "notebooks": list(notebook) if notebook else None,
        "incremental": not full,
    }
    resp = _post("/api/plugins/onenote-import/run", payload)
    job_id = resp.get("job_id") if isinstance(resp, dict) else None
    if not job_id:
        console.print(f"[red]Failed to start import:[/red] {resp}")
        return
    console.print(
        f"[bold green]Import started[/bold green]  "
        f"[dim]job_id={job_id}  target={target_folder}[/dim]"
    )
    if not follow:
        console.print(
            f"[dim]Poll with:[/dim] agntspace onenote-progress {job_id}"
        )
        return

    try:
        _follow_onenote_job(base, job_id)
    except KeyboardInterrupt:
        console.print(
            f"\n[yellow]Detached.[/yellow] Import continues in the background. "
            f"Reattach: agntspace onenote-progress {job_id}"
        )


@app.command(name="onenote-progress")
def onenote_progress_cmd(
    job_id: str = typer.Argument(..., help="Job id returned by `onenote-import`."),
) -> None:
    """Follow an already-running OneNote import job until it finishes."""
    base = _get_base_url()
    if not _check_server(base):
        return
    try:
        _follow_onenote_job(base, job_id)
    except KeyboardInterrupt:
        console.print("\n[yellow]Detached.[/yellow]")


def _follow_onenote_job(base: str, job_id: str) -> None:
    """Poll the plugin's /progress endpoint and print live counters.

    Uses carriage-return line refresh (no rich progress bar) so it works
    cleanly inside subprocess captures, docker logs, and SSH sessions
    where animated widgets render as garbage.
    """
    import sys
    import time

    last_state = ""
    while True:
        data = _get(f"/api/plugins/onenote-import/progress/{job_id}")
        if isinstance(data, dict) and "detail" in data and "state" not in data:
            console.print(f"[red]{data['detail']}[/red]")
            return
        state = data.get("state", "?")
        imported = data.get("pages_imported", 0)
        skipped = data.get("pages_skipped", 0)
        errs = data.get("errors_count", 0)
        total = data.get("pages_total", 0)
        processed = data.get("pages_processed", 0)
        current = data.get("current_page", "") or "—"
        notebook = data.get("current_notebook", "") or "—"
        eta = data.get("eta_s")
        eta_s = f"ETA {int(eta)}s" if isinstance(eta, (int, float)) else "ETA —"
        bar_width = 24
        ratio = data.get("progress_ratio", 0.0) or 0.0
        filled = int(bar_width * ratio)
        bar = "█" * filled + "░" * (bar_width - filled)
        line = (
            f"[{bar}] {processed}/{total}  "
            f"✓{imported} skip:{skipped} err:{errs}  "
            f"{eta_s}  [{notebook} / {current[:30]}]"
        )
        sys.stdout.write("\r" + line.ljust(120))
        sys.stdout.flush()

        if state in {"completed", "failed", "cancelled"}:
            sys.stdout.write("\n")
            sys.stdout.flush()
            summary = data.get("summary") or {}
            if state == "completed":
                console.print(
                    f"[bold green]Completed[/bold green]  "
                    f"imported={summary.get('pages_imported', imported)} "
                    f"skipped={summary.get('pages_skipped', skipped)} "
                    f"errors={summary.get('errors_count', errs)}"
                )
            elif state == "cancelled":
                console.print(
                    f"[yellow]Cancelled[/yellow]  "
                    f"imported={imported} skipped={skipped}"
                )
            else:
                console.print(
                    f"[red]Failed[/red]  "
                    f"reason={data.get('error', 'unknown')}"
                )
            return
        if state != last_state:
            last_state = state
        time.sleep(1.0)


@app.command()
def video(
    target: str = typer.Argument(..., help="wiki:slug | kb:slug | project:slug | journal:YYYY-MM-DD | url:https://..."),
    duration: int = typer.Option(60, "--duration", "-d", help="Target duration in seconds (5-300)"),
    voice: str = typer.Option("liam", "--voice", "-v", help="Voice: liam | ava | guy | clara | ryan | sonia"),
    style: str = typer.Option("walkthrough", "--style", "-s", help="Style: walkthrough | tutorial | promo"),
    no_follow: bool = typer.Option(False, "--no-follow", help="Return immediately after queuing; don't poll"),
) -> None:
    """Render a narrated .mp4 video from any vault artifact or URL.

    Kicks off a headless-browser recording, generates voiceover narration
    via edge-tts (fallback to piper/pyttsx3), and composes the final file.
    Requires the [video] extra: pip install 'agntspace[video]'.
    """
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    try:
        job = _post("/api/video", {
            "target": target,
            "duration_seconds": duration,
            "voice": voice,
            "style": style,
        })
    except Exception as exc:
        console.print(f"[red]Failed to queue render:[/red] {exc}")
        raise typer.Exit(1) from exc
    job_id = job.get("job_id", "")
    console.print(f"[green]Queued[/green] job [bold]{job_id}[/bold] for [cyan]{target}[/cyan]")
    if no_follow:
        console.print(f"Poll: [bold]agntspace video-progress {job_id}[/bold]")
        return
    _follow_video_job(job_id)


@app.command(name="video-progress")
def video_progress(job_id: str = typer.Argument(..., help="Job id returned by 'agntspace video'")) -> None:
    """Reattach to a running video render and stream live progress."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    _follow_video_job(job_id)


def _follow_video_job(job_id: str) -> None:
    """Poll /api/video/jobs/{id} until terminal state; print a one-line live progress bar."""
    import time
    last_phase = ""
    while True:
        try:
            data = _get(f"/api/video/jobs/{job_id}")
        except Exception as exc:
            console.print(f"[red]Poll failed:[/red] {exc}")
            raise typer.Exit(1) from exc
        state = data.get("state", "?")
        phase = data.get("phase", "")
        ratio = float(data.get("progress_ratio", 0.0))
        bar_len = 24
        filled = int(ratio * bar_len)
        bar = "█" * filled + "░" * (bar_len - filled)
        label = phase or state
        print(f"\r[{bar}] {int(ratio*100):>3}%  {label:<14} job={job_id}", end="", flush=True)

        if state == "completed":
            print()
            path = data.get("path", "")
            dur = data.get("duration_actual", 0.0)
            words = data.get("narration_word_count", 0)
            provider = data.get("tts_provider_used", "")
            console.print(f"[green]Done.[/green] {path} ({dur:.1f}s, {words} words, TTS: {provider})")
            return
        if state in ("failed", "cancelled"):
            print()
            console.print(f"[red]{state.capitalize()}:[/red] {data.get('error', 'unknown')}")
            raise typer.Exit(1)
        if phase and phase != last_phase:
            last_phase = phase
        time.sleep(1.5)


# ═══════════════════════════════════════════
# FINANCE — Phase 1 (receipts → drafts → ledger)
# ═══════════════════════════════════════════

@app.command(name="finance-list")
def finance_list(
    status: str = typer.Option("", "--status", help="draft | verified | rejected | archived (default: visible only)"),
    since: str = typer.Option("", "--since", help="ISO YYYY-MM-DD lower bound"),
    until: str = typer.Option("", "--until", help="ISO YYYY-MM-DD upper bound"),
    merchant: str = typer.Option("", "--merchant", help="Merchant slug filter"),
    project: str = typer.Option("", "--project", help="Project slug filter"),
    limit: int = typer.Option(50, "--limit"),
) -> None:
    """List ledger transactions, newest-first."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    params = {"limit": limit}
    if status:
        params["status"] = status
    if since:
        params["since"] = since
    if until:
        params["until"] = until
    if merchant:
        params["merchant_slug"] = merchant
    if project:
        params["project_slug"] = project
    data = _get("/api/finance/transactions", params=params)
    rows = data.get("transactions", []) if isinstance(data, dict) else []
    if not rows:
        console.print("[dim]No transactions match.[/dim]")
        return
    table = Table(title=f"Transactions ({data.get('count', len(rows))})")
    table.add_column("ID")
    table.add_column("Date")
    table.add_column("Merchant")
    table.add_column("Total", justify="right")
    table.add_column("Status")
    table.add_column("Project")
    for r in rows:
        status_color = {
            "verified": "green",
            "draft": "yellow",
            "rejected": "red",
            "archived": "dim",
            "superseded": "dim",
        }.get(r.get("status", ""), "white")
        table.add_row(
            r.get("txn_id", "")[:10],
            r.get("txn_date", ""),
            r.get("merchant_slug", "") or "[dim](unresolved)[/dim]",
            f"{r.get('total', '')} {r.get('currency', '')}",
            f"[{status_color}]{r.get('status', '')}[/{status_color}]",
            r.get("project_slug", "") or "[dim]—[/dim]",
        )
    console.print(table)


@app.command(name="finance-show")
def finance_show(
    txn_id: str = typer.Argument(..., help="Transaction or draft ID"),
) -> None:
    """Show one transaction with all line items."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    txn = _get(f"/api/finance/transactions/{txn_id}")
    if not isinstance(txn, dict):
        console.print(f"[red]Unexpected response: {txn}[/red]")
        raise typer.Exit(1)
    console.print(f"\n[bold]{txn.get('merchant_slug') or '(unresolved merchant)'}[/bold]  "
                  f"— [bold]{txn.get('total')} {txn.get('currency')}[/bold]  "
                  f"on [cyan]{txn.get('txn_date')}[/cyan]")
    console.print(f"  Status: [bold]{txn.get('status')}[/bold]   "
                  f"Project: {txn.get('project_slug') or '[dim]unassigned[/dim]'}   "
                  f"Tax: {txn.get('tax')}")
    if txn.get("payment_method"):
        console.print(f"  Payment: [dim]{txn['payment_method']}[/dim]")
    items = txn.get("items", [])
    if items:
        items_table = Table(title="Line items", show_header=True)
        items_table.add_column("Description")
        items_table.add_column("Amount", justify="right")
        items_table.add_column("Category")
        items_table.add_column("Project")
        items_table.add_column("Deductible")
        for item in items:
            items_table.add_row(
                item.get("description", ""),
                item.get("amount", ""),
                item.get("category_slug", "") or "[dim]—[/dim]",
                item.get("project_slug", "") or "[dim]—[/dim]",
                "[green]✓[/green]" if item.get("tax_deductible") else "[dim]—[/dim]",
            )
        console.print(items_table)


@app.command(name="finance-pending")
def finance_pending(limit: int = typer.Option(50, "--limit")) -> None:
    """List drafts awaiting approval."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _get("/api/finance/pending", params={"limit": limit})
    rows = data.get("drafts", []) if isinstance(data, dict) else []
    if not rows:
        console.print("[green]No pending drafts.[/green]")
        return
    table = Table(title=f"Pending drafts ({data.get('count', len(rows))})")
    table.add_column("ID")
    table.add_column("Date")
    table.add_column("Merchant")
    table.add_column("Total", justify="right")
    table.add_column("Items", justify="right")
    for r in rows:
        table.add_row(
            r.get("txn_id", "")[:10],
            r.get("txn_date", ""),
            r.get("merchant_slug", "") or "[dim](unresolved)[/dim]",
            f"{r.get('total', '')} {r.get('currency', '')}",
            str(r.get("item_count", 0)),
        )
    console.print(table)
    console.print("\n[dim]Approve with[/dim] [bold]agntspace finance-approve <id>[/bold]   "
                  "[dim]or reject with[/dim] [bold]agntspace finance-reject <id> --reason '...'[/bold]")


@app.command(name="finance-ingest")
def finance_ingest(
    path: str = typer.Argument(..., help="Path to a receipt file (logical or physical)"),
) -> None:
    """Ingest a receipt and produce a draft transaction (no commit)."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post("/api/finance/ingest", {"path": path})
    if not isinstance(data, dict) or not data.get("ok", False):
        err = data.get("error", "unknown") if isinstance(data, dict) else "unknown"
        console.print(f"[red]Ingest failed:[/red] {err}")
        raise typer.Exit(1)
    if data.get("deduplicated"):
        console.print(f"[yellow]Already ingested:[/yellow] draft id [bold]{data.get('draft_id')}[/bold]")
        return
    console.print(f"[green]Drafted:[/green] [bold]{data.get('draft_id')}[/bold]")
    console.print(f"  Merchant: {data.get('merchant_raw') or '[dim](unreadable)[/dim]'}  "
                  f"({data.get('merchant_slug') or '[dim]unresolved[/dim]'})")
    console.print(f"  Total: [bold]{data.get('total')} {data.get('currency')}[/bold]")
    console.print(f"  Items: {data.get('item_count')}")
    flags = data.get("audit_flags") or []
    if flags:
        console.print(f"  [yellow]Audit flags:[/yellow] {', '.join(flags)}")
    console.print(f"\n[dim]Review with[/dim] [bold]agntspace finance-show {data.get('draft_id')}[/bold]")


@app.command(name="finance-approve")
def finance_approve(
    txn_id: str = typer.Argument(..., help="Draft ID to approve"),
) -> None:
    """Approve a draft → promote to verified ledger entry."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post(f"/api/finance/transactions/{txn_id}/approve", {})
    if isinstance(data, dict) and data.get("ok"):
        console.print(f"[green]Approved:[/green] {data.get('txn_id')} → status [bold]{data.get('status')}[/bold]")
    else:
        console.print(f"[red]Approve failed:[/red] {data}")
        raise typer.Exit(1)


@app.command(name="finance-reject")
def finance_reject(
    txn_id: str = typer.Argument(..., help="Draft ID to reject"),
    reason: str = typer.Option("", "--reason", help="Why you're rejecting (saved for audit)"),
) -> None:
    """Reject a draft → archive without committing."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _post(f"/api/finance/transactions/{txn_id}/reject", {"reason": reason})
    if isinstance(data, dict) and data.get("ok"):
        console.print(f"[yellow]Rejected:[/yellow] {data.get('txn_id')} → status [bold]{data.get('status')}[/bold]")
    else:
        console.print(f"[red]Reject failed:[/red] {data}")
        raise typer.Exit(1)


@app.command(name="finance-merchants")
def finance_merchants(limit: int = typer.Option(100, "--limit")) -> None:
    """List canonical merchants in the registry."""
    base = _get_base_url()
    if not _check_server(base):
        raise typer.Exit(1)
    data = _get("/api/finance/merchants", params={"limit": limit})
    rows = data.get("merchants", []) if isinstance(data, dict) else []
    if not rows:
        console.print("[dim]No merchants registered yet.[/dim]")
        return
    table = Table(title=f"Merchants ({data.get('count', len(rows))})")
    table.add_column("Slug")
    table.add_column("Canonical name")
    table.add_column("Aliases", justify="right")
    table.add_column("Country")
    table.add_column("Default category")
    table.add_column("Last seen")
    for m in rows:
        table.add_row(
            m.get("merchant_slug", ""),
            m.get("canonical_name", ""),
            str(len(m.get("aliases") or [])),
            m.get("country", "") or "[dim]—[/dim]",
            m.get("default_category", "") or "[dim]—[/dim]",
            m.get("last_seen", "") or "[dim]—[/dim]",
        )
    console.print(table)


@app.command()
def channels() -> None:
    """List messaging channels and connection status."""
    base = _get_base_url()
    if not _check_server(base):
        return
    data = _get("/api/channels")
    ch_list = data if isinstance(data, list) else data.get("channels", [])
    if not ch_list:
        console.print("[dim]No channels configured.[/dim]")
        return
    table = Table(title="Channels")
    table.add_column("Channel")
    table.add_column("Status")
    for ch in ch_list:
        name = ch.get("name", "?")
        connected = ch.get("connected", False)
        status_str = "[green]Connected[/green]" if connected else "[dim]Disconnected[/dim]"
        table.add_row(name, status_str)
    console.print(table)


@app.command()
def doctor() -> None:
    """Run a comprehensive health audit of the AgntSpace installation.

    Checks config, credentials, LLM connectivity, skills, agents, contract,
    channels, work queue, database, and vault integrity. Works both with
    a running server (via REST API) and offline (direct file checks).
    """
    from rich.panel import Panel

    base_url = _get_base_url()
    server_up = False
    try:
        import httpx
        httpx.get(f"{base_url}/api/health", timeout=3)
        server_up = True
    except Exception:
        pass

    console.print(Panel("[bold]AgntSpace Doctor[/bold]", subtitle="Health Audit"))
    checks: list[tuple[str, str, str]] = []  # (name, status, detail)

    def ok(name: str, detail: str = "") -> None:
        checks.append((name, "[green]OK[/green]", detail))

    def warn(name: str, detail: str) -> None:
        checks.append((name, "[yellow]WARN[/yellow]", detail))

    def fail(name: str, detail: str) -> None:
        checks.append((name, "[red]FAIL[/red]", detail))

    # ── 1. Config ──
    from agntspace.infra.config import get_settings
    try:
        settings = get_settings()
        config_path = Path(settings.app_dir) / "config.toml"
        if config_path.exists():
            ok("Config", str(config_path))
        else:
            warn("Config", f"config.toml not found at {config_path}")
    except Exception as exc:
        fail("Config", str(exc))
        settings = None

    # ── 2. Vault ──
    vault_dir = None
    if settings:
        vault_dir = Path(settings.root_dir) if getattr(settings, "root_dir", None) else None
    if vault_dir and vault_dir.is_dir():
        ok("Vault root", str(vault_dir))
        # Check identity files
        identity_dir = vault_dir / "agntspace" / "system" / "identity"
        for fname in ("SOUL.md", "CONTRACT.md", "USER.md"):
            fpath = identity_dir / fname
            if fpath.exists():
                size = fpath.stat().st_size
                if size > 200:
                    ok(f"  {fname}", f"{size} bytes")
                else:
                    warn(f"  {fname}", f"only {size} bytes — likely placeholder")
            else:
                fail(f"  {fname}", "missing")
    elif vault_dir:
        fail("Vault root", f"{vault_dir} does not exist")
    else:
        warn("Vault root", "not configured (setup mode)")

    # ── 3. Database ──
    if settings:
        db_path = Path(settings.app_dir) / "agntspace.db"
        if db_path.exists():
            size_mb = db_path.stat().st_size / (1024 * 1024)
            ok("Database", f"{db_path.name} ({size_mb:.1f} MB)")
        else:
            warn("Database", "agntspace.db not found")

    # ── 4. Credentials ──
    if settings:
        cred_path = Path(settings.app_dir) / "credentials.json"
        if cred_path.exists():
            try:
                cred_data = json.loads(cred_path.read_text(encoding="utf-8"))
                key_count = len(cred_data) if isinstance(cred_data, dict) else 0
                ok("Credentials", f"{key_count} stored key(s)")
            except Exception:
                warn("Credentials", "file exists but unreadable")
        else:
            warn("Credentials", "no credentials.json — LLM keys may be missing")

    # ── Server-dependent checks (REST API) ──
    if server_up:
        console.print(f"\n[dim]Server running at {base_url}[/dim]\n")

        # ── 5. LLM Health ──
        try:
            llm = _get("/api/llm/status")
            if isinstance(llm, dict):
                healthy = llm.get("healthy", False)
                model = llm.get("model", "?")
                if healthy:
                    ok("LLM", f"{model} — healthy")
                else:
                    error = llm.get("last_error", "unknown")
                    fail("LLM", f"{model} — {error}")
        except Exception:
            warn("LLM", "could not check (endpoint unavailable)")

        # ── 6. Skills ──
        try:
            skills = _get("/api/skills")
            if isinstance(skills, list):
                total = len(skills)
                quarantined = [s for s in skills if s.get("status") == "quarantined"]
                if quarantined:
                    warn("Skills", f"{total} loaded, {len(quarantined)} quarantined: {', '.join(s.get('name', '?') for s in quarantined)}")
                else:
                    ok("Skills", f"{total} loaded")
        except Exception:
            warn("Skills", "could not check")

        # ── 7. Agents ──
        try:
            agents = _get("/api/orchestrator/agents")
            if isinstance(agents, list):
                total = len(agents)
                with_issues = [a for a in agents if a.get("known_issues")]
                if with_issues:
                    issue_count = sum(len(a["known_issues"]) for a in with_issues)
                    warn("Agents", f"{total} loaded, {issue_count} known issue(s) across {len(with_issues)} agent(s)")
                else:
                    ok("Agents", f"{total} loaded, no issues")
        except Exception:
            warn("Agents", "could not check")

        # ── 8. Contract ──
        try:
            contract = _get("/api/contract")
            if isinstance(contract, dict):
                actions = contract.get("actions", {})
                blocked = [k for k, v in actions.items() if v == "block"]
                ok("Contract", f"{len(actions)} actions ({len(blocked)} blocked)")
        except Exception:
            warn("Contract", "could not read")

        # ── 9. Channels ──
        try:
            channels = _get("/api/channels")
            ch_list = channels if isinstance(channels, list) else channels.get("channels", []) if isinstance(channels, dict) else []
            connected = [c for c in ch_list if c.get("connected")]
            if ch_list:
                ok("Channels", f"{len(connected)}/{len(ch_list)} connected")
            else:
                ok("Channels", "none configured")
        except Exception:
            warn("Channels", "could not check")

        # ── 10. Work Queue ──
        try:
            wq = _get("/api/work-queue")
            if isinstance(wq, dict):
                pending = wq.get("pending", 0)
                dead = wq.get("dead_letter", 0)
                if dead:
                    warn("Work Queue", f"{pending} pending, {dead} dead-letter")
                elif pending:
                    ok("Work Queue", f"{pending} pending, draining")
                else:
                    ok("Work Queue", "empty — all caught up")
        except Exception:
            warn("Work Queue", "could not check")

        # ── 11. Knowledge Bases ──
        try:
            kbs = _get("/api/kb")
            if isinstance(kbs, list):
                total_articles = sum(k.get("articles", 0) for k in kbs)
                total_sources = sum(k.get("sources", 0) for k in kbs)
                ok("Knowledge", f"{len(kbs)} KB(s), {total_articles} articles, {total_sources} sources")
        except Exception:
            warn("Knowledge", "could not check")

        # ── 12. Memory Graph ──
        try:
            graph = _get("/api/graph/stats")
            if isinstance(graph, dict):
                nodes = graph.get("total_nodes", 0)
                edges = graph.get("total_edges", 0)
                ok("Graph", f"{nodes} nodes, {edges} edges")
        except Exception:
            warn("Graph", "could not check")

    else:
        console.print("\n[dim]Server not running — skipping API checks[/dim]\n")
        warn("Server", f"not reachable at {base_url}")

    # ── Print results ──
    console.print()
    table = Table(title="Health Report", show_header=True)
    table.add_column("Check", style="bold", min_width=15)
    table.add_column("Status", min_width=8)
    table.add_column("Details")

    for name, status, detail in checks:
        table.add_row(name, status, detail)

    console.print(table)

    # Summary
    ok_count = sum(1 for _, s, _ in checks if "green" in s)
    warn_count = sum(1 for _, s, _ in checks if "yellow" in s)
    fail_count = sum(1 for _, s, _ in checks if "red" in s)
    total = len(checks)

    if fail_count:
        console.print(f"\n[red bold]{fail_count} FAILED[/red bold], {warn_count} warnings, {ok_count}/{total} healthy")
    elif warn_count:
        console.print(f"\n[yellow]{warn_count} warning(s)[/yellow], {ok_count}/{total} healthy")
    else:
        console.print(f"\n[green bold]All {total} checks passed![/green bold]")


if __name__ == "__main__":
    app()
