#!/usr/bin/env python3
"""Single-file terminal entry for the project management workspace.

Usage:
  ./app                       # default: start API + Web, drop into agent chat REPL
  ./app chat                  # open a fresh chat session against the running API
  ./app chat --resume <id>    # resume a saved session (./app sessions to list)
  ./app sessions              # list saved chat sessions
  ./app services              # start API + Web only; stream logs to this terminal
  ./app stop                  # kill anything left from a previous run
  ./app status                # show service health and ports
  ./app build [--goal "..."]  # run the autonomous harness build
  ./app inspect               # harness workspace inspection
  ./app plan   --goal "..."   # harness planner only
  ./app resume                # harness resume from last checkpoint
  ./app report                # harness regenerate final-status.md

The default flow:
  - ensures node_modules is installed (runs ``npm install`` once if missing),
  - spawns the API (apps/api on :41821) and Web (apps/web on :41822) in the
    background, with their logs at .agent/logs/api.log and .agent/logs/web.log,
  - polls /healthz and prints READY once both endpoints respond,
  - opens the web app in your default browser (the PM interface),
  - drops your terminal into a Claude-Code-style chat REPL connected to the
    agent backend. /quit or Ctrl-D exits the REPL and stops the services.
    Set KEEP_RUNNING=1 to keep them up after exit (then ``./app stop`` to
    tear them down manually).

No third-party Python deps are required.
"""

from __future__ import annotations

import json
import os
import select
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
from pathlib import Path
from typing import Dict, List, Optional, Tuple


ROOT = Path(__file__).resolve().parent
API_DIR = ROOT / "apps" / "api"
WEB_DIR = ROOT / "apps" / "web"
PID_FILE = ROOT / ".agent" / "state" / "dev.pid"

API_PORT = int(os.environ.get("API_PORT", "41821"))
WEB_PORT = int(os.environ.get("WEB_PORT", "41822"))


# ---------- ANSI helpers ----------------------------------------------------


def _supports_color() -> bool:
    return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None


_COLORS = {
    "api": "\033[34m",   # blue
    "web": "\033[32m",   # green
    "sys": "\033[35m",   # magenta
    "err": "\033[31m",   # red
    "ok": "\033[36m",    # cyan
    "reset": "\033[0m",
}


def colored(tag: str, text: str) -> str:
    if not _supports_color():
        return text
    c = _COLORS.get(tag, "")
    return f"{c}{text}{_COLORS['reset']}"


def prefix(tag: str) -> str:
    width = 5
    return colored(tag, f"[{tag:>{width}}]")


def sys_print(msg: str) -> None:
    print(f"{prefix('sys')} {msg}", flush=True)


def err_print(msg: str) -> None:
    print(f"{prefix('err')} {msg}", file=sys.stderr, flush=True)


# ---------- deps -----------------------------------------------------------


def _ensure_node_modules() -> None:
    if (ROOT / "node_modules").exists():
        return
    if shutil.which("npm") is None:
        err_print("npm not found on PATH. Install Node ≥20 and retry.")
        sys.exit(127)
    sys_print("first run — installing node dependencies (npm install)...")
    rc = subprocess.call(["npm", "install", "--no-audit", "--no-fund"], cwd=ROOT)
    if rc != 0:
        err_print(f"npm install failed (exit {rc})")
        sys.exit(rc)


def _port_in_use(port: int) -> bool:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.2)
        try:
            s.connect(("127.0.0.1", port))
            return True
        except OSError:
            return False


# ---------- dev runner -----------------------------------------------------


class TaggedProcess:
    """Spawn a subprocess and emit its merged output line-prefixed."""

    def __init__(self, tag: str, argv: List[str], cwd: Path, env: Dict[str, str]) -> None:
        self.tag = tag
        self.argv = argv
        self.cwd = cwd
        self.env = env
        self.proc: Optional[subprocess.Popen[str]] = None
        self._reader: Optional[threading.Thread] = None

    def start(self) -> None:
        self.proc = subprocess.Popen(
            self.argv,
            cwd=str(self.cwd),
            env={**os.environ, **self.env},
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
        )
        self._reader = threading.Thread(target=self._pump, daemon=True)
        self._reader.start()

    def _pump(self) -> None:
        assert self.proc is not None and self.proc.stdout is not None
        for line in self.proc.stdout:
            line = line.rstrip("\n")
            if not line:
                continue
            print(f"{prefix(self.tag)} {line}", flush=True)

    def terminate(self) -> None:
        if self.proc is None or self.proc.poll() is not None:
            return
        try:
            self.proc.terminate()
            try:
                self.proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                self.proc.kill()
        except ProcessLookupError:
            pass

    @property
    def returncode(self) -> Optional[int]:
        return self.proc.poll() if self.proc else None


def _await_healthy(url: str, timeout_s: float = 30.0) -> bool:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        try:
            with urllib.request.urlopen(url, timeout=2) as resp:
                if 200 <= resp.status < 300:
                    return True
        except (urllib.error.URLError, ConnectionError, TimeoutError, OSError):
            pass
        time.sleep(0.4)
    return False


def _load_env_file(path: Path) -> Dict[str, str]:
    """Tiny .env parser: ignores comments and blank lines, supports KEY=VALUE.

    Quote stripping and ${VAR} expansion intentionally omitted — the .env files
    are local and the agent path doesn't need shell-style features.
    """

    out: Dict[str, str] = {}
    if not path.exists():
        return out
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        key = key.strip()
        value = value.strip().strip("'").strip('"')
        if key:
            out[key] = value
    return out


def _start_services_background() -> Optional[Tuple[subprocess.Popen, subprocess.Popen]]:
    """Spawn API and Web with logs going to .agent/logs/{api,web}.log.

    Returns the (api, web) Popen handles, or None if startup failed. The
    caller is responsible for terminating them on exit.
    """

    if not API_DIR.exists() or not WEB_DIR.exists():
        err_print("apps/api or apps/web missing. Re-scaffold before running ./app.")
        return None

    _ensure_node_modules()

    for port, name in ((API_PORT, "api"), (WEB_PORT, "web")):
        if _port_in_use(port):
            err_print(
                f"port {port} ({name}) is already in use. "
                f"Run `./app stop` or free the port and try again."
            )
            return None

    logs_dir = ROOT / ".agent" / "logs"
    logs_dir.mkdir(parents=True, exist_ok=True)
    PID_FILE.parent.mkdir(parents=True, exist_ok=True)

    api_env_file = _load_env_file(API_DIR / ".env.local")
    if api_env_file:
        sys_print(f"loaded {len(api_env_file)} vars from apps/api/.env.local")

    api_log = open(logs_dir / "api.log", "w", encoding="utf-8")
    web_log = open(logs_dir / "web.log", "w", encoding="utf-8")

    sys_print(f"starting api on :{API_PORT} and web on :{WEB_PORT}")
    api_proc = subprocess.Popen(
        ["npm", "run", "dev", "--silent"],
        cwd=str(API_DIR),
        env={**os.environ, "PORT": str(API_PORT), **api_env_file},
        stdout=api_log,
        stderr=subprocess.STDOUT,
    )
    web_proc = subprocess.Popen(
        ["npm", "run", "dev", "--silent"],
        cwd=str(WEB_DIR),
        env={**os.environ, "VITE_API_TARGET": f"http://localhost:{API_PORT}"},
        stdout=web_log,
        stderr=subprocess.STDOUT,
    )

    PID_FILE.write_text(json.dumps({"api": api_proc.pid, "web": web_proc.pid}))
    return api_proc, web_proc


def cmd_dev(_args: List[str]) -> int:
    """Default: start services in the background, open the web app, drop into chat."""

    pair = _start_services_background()
    if pair is None:
        return 1
    api_proc, web_proc = pair

    api_url = f"http://localhost:{API_PORT}/healthz"
    web_url = f"http://localhost:{WEB_PORT}/"
    api_ok = _await_healthy(api_url, timeout_s=30)
    web_ok = _await_healthy(web_url, timeout_s=30)

    if not (api_ok and web_ok):
        err_print(
            f"services did not become healthy in time (api_ok={api_ok}, web_ok={web_ok}). "
            f"Inspect .agent/logs/api.log and .agent/logs/web.log."
        )
        for p in (web_proc, api_proc):
            try:
                p.terminate()
            except ProcessLookupError:
                pass
        PID_FILE.unlink(missing_ok=True)
        return 1

    print(
        f"{prefix('sys')} {colored('ok', 'READY')}  "
        f"web=http://localhost:{WEB_PORT}  "
        f"api=http://localhost:{API_PORT}/healthz",
        flush=True,
    )

    if os.environ.get("NO_OPEN") not in ("1", "true", "yes"):
        sys_print(f"opening http://localhost:{WEB_PORT} (PM web app) in your default browser")
        try:
            webbrowser.open(f"http://localhost:{WEB_PORT}", new=2)
        except Exception as exc:  # noqa: BLE001
            err_print(f"could not open browser automatically: {exc}")

    keep_running = os.environ.get("KEEP_RUNNING") in ("1", "true", "yes")
    if keep_running:
        sys_print(_dim("logs: .agent/logs/api.log  .agent/logs/web.log    KEEP_RUNNING=1 — services stay up after /quit"))
    else:
        sys_print(_dim("logs: .agent/logs/api.log  .agent/logs/web.log    services stop on /quit (KEEP_RUNNING=1 to keep them up)"))
    print()

    def _cleanup() -> None:
        if keep_running:
            return
        # Tear down the API + Web processes we spawned. Without this the
        # ports stay bound, and the next `./app` run errors out with
        # "port already in use".
        _stop_services(quiet=True)
        for p in (web_proc, api_proc):
            try:
                p.terminate()
            except (ProcessLookupError, OSError):
                pass

    # SIGHUP fires when the terminal window is closed (parent shell hangs up
    # the controlling tty); SIGTERM is what `kill <pid>` or a system shutdown
    # sends. Without these handlers, Python exits before the `finally` block
    # runs, leaving the API + Web processes orphaned with their ports bound.
    def _signal_exit(signum: int, _frame: object) -> None:
        _cleanup()
        sys.exit(128 + signum)

    for _sig in (signal.SIGHUP, signal.SIGTERM):
        try:
            signal.signal(_sig, _signal_exit)
        except (ValueError, OSError):
            pass  # SIGHUP not available on Windows; harmless to skip.

    try:
        chat_loop()
    except KeyboardInterrupt:
        print()
    finally:
        if keep_running:
            sys_print(_dim(
                f"services still running at http://localhost:{API_PORT} / http://localhost:{WEB_PORT} — `./app stop` to tear down"
            ))
        else:
            _cleanup()
            sys_print(_dim(f"stopped api :{API_PORT} and web :{WEB_PORT}"))
    return 0


def cmd_services(_args: List[str]) -> int:
    """Old behavior: foreground services with tagged logs streamed to this terminal."""

    if not API_DIR.exists() or not WEB_DIR.exists():
        err_print("apps/api or apps/web missing. Re-scaffold before running ./app services.")
        return 1

    _ensure_node_modules()

    for port, name in ((API_PORT, "api"), (WEB_PORT, "web")):
        if _port_in_use(port):
            err_print(
                f"port {port} ({name}) is already in use. "
                f"Run `./app stop` or free the port and try again."
            )
            return 1

    PID_FILE.parent.mkdir(parents=True, exist_ok=True)

    api_env_file = _load_env_file(API_DIR / ".env.local")
    if api_env_file:
        sys_print(f"loaded {len(api_env_file)} vars from apps/api/.env.local")

    api = TaggedProcess(
        tag="api",
        argv=["npm", "run", "dev", "--silent"],
        cwd=API_DIR,
        env={"PORT": str(API_PORT), **api_env_file},
    )
    web = TaggedProcess(
        tag="web",
        argv=["npm", "run", "dev", "--silent"],
        cwd=WEB_DIR,
        env={"VITE_API_TARGET": f"http://localhost:{API_PORT}"},
    )

    sys_print(f"starting api on :{API_PORT} and web on :{WEB_PORT}")
    api.start()
    web.start()

    PID_FILE.write_text(
        json.dumps({"api": api.proc.pid if api.proc else None,
                    "web": web.proc.pid if web.proc else None})
    )

    stop_event = threading.Event()

    def _shutdown(*_signal_args: object) -> None:
        if stop_event.is_set():
            return
        stop_event.set()
        sys_print("shutting down...")
        web.terminate()
        api.terminate()

    signal.signal(signal.SIGINT, _shutdown)
    signal.signal(signal.SIGTERM, _shutdown)

    # Async health probe so the foreground stays responsive.
    def _probe() -> None:
        api_ok = _await_healthy(f"http://localhost:{API_PORT}/healthz", 30)
        web_ok = _await_healthy(f"http://localhost:{WEB_PORT}/", 30)
        if stop_event.is_set():
            return
        if api_ok and web_ok:
            url = f"http://localhost:{WEB_PORT}/agent"
            print(
                f"{prefix('sys')} {colored('ok', 'READY')}  "
                f"web=http://localhost:{WEB_PORT}  "
                f"api=http://localhost:{API_PORT}/healthz",
                flush=True,
            )
            if os.environ.get("NO_OPEN") not in ("1", "true", "yes"):
                sys_print(f"opening {url} in your default browser")
                try:
                    webbrowser.open(url, new=2)
                except Exception as exc:
                    err_print(f"could not open browser automatically: {exc}")
        else:
            err_print(
                f"healthcheck timeout (api_ok={api_ok}, web_ok={web_ok}). "
                f"Check the logs above."
            )

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

    # Wait until either child exits or a signal is received.
    try:
        while not stop_event.is_set():
            if api.returncode is not None or web.returncode is not None:
                _shutdown()
                break
            time.sleep(0.25)
    finally:
        _shutdown()
        # Final reap
        if api.proc:
            api.proc.wait(timeout=3)
        if web.proc:
            web.proc.wait(timeout=3)
        if PID_FILE.exists():
            PID_FILE.unlink(missing_ok=True)

    rc_api = api.returncode if api.returncode is not None else 0
    rc_web = web.returncode if web.returncode is not None else 0
    # Ctrl-C is success.
    if stop_event.is_set() and rc_api in (None, 0, -2, 130) and rc_web in (None, 0, -2, 130):
        return 0
    return rc_api or rc_web or 0


# ---------- stop -----------------------------------------------------------


def _stop_services(quiet: bool = False) -> bool:
    """Tear down whatever the last `./app dev` spawned. Returns True if it
    sent at least one SIGTERM. Used by both `./app stop` and the cmd_dev
    auto-cleanup-on-exit path."""

    if not PID_FILE.exists():
        if not quiet:
            sys_print("no dev pidfile found.")
        return False
    try:
        data = json.loads(PID_FILE.read_text())
    except json.JSONDecodeError:
        data = {}
    sent_any = False
    for name, pid in data.items():
        if not pid:
            continue
        try:
            os.kill(int(pid), signal.SIGTERM)
            if not quiet:
                sys_print(f"sent SIGTERM to {name} (pid {pid})")
            sent_any = True
        except ProcessLookupError:
            pass
        except PermissionError:
            err_print(f"permission denied killing pid {pid} ({name})")
    PID_FILE.unlink(missing_ok=True)
    return sent_any


def cmd_stop(_args: List[str]) -> int:
    _stop_services(quiet=False)
    return 0


# ---------- status ---------------------------------------------------------


def _check(url: str) -> Tuple[bool, str]:
    try:
        with urllib.request.urlopen(url, timeout=1) as resp:
            return 200 <= resp.status < 300, f"HTTP {resp.status}"
    except urllib.error.HTTPError as exc:
        return False, f"HTTP {exc.code}"
    except (urllib.error.URLError, OSError) as exc:
        return False, str(exc)


def cmd_status(_args: List[str]) -> int:
    api_ok, api_msg = _check(f"http://localhost:{API_PORT}/healthz")
    web_ok, web_msg = _check(f"http://localhost:{WEB_PORT}/")
    sys_print(f"api  :{API_PORT}  {'OK' if api_ok else 'DOWN'}  {api_msg}")
    sys_print(f"web  :{WEB_PORT}  {'OK' if web_ok else 'DOWN'}  {web_msg}")
    return 0 if api_ok and web_ok else 1


# ---------- chat REPL -----------------------------------------------------
#
# Claude-Code-style chat client. Speaks SSE against the agent backend, renders
# streaming text + tool calls inline. Keeps history client-side and replays
# the full conversation on every turn (matches the wire format the route
# expects). Uses only stdlib — readline gives us history and editing.


CHAT_HELP = """\
Mode + scope:
  /mode ask|code|plan         ask=read-only, plan=propose then go, code=write+exec
  /add <path>                 add a file to the explicit scope
  /drop <path>                remove a file from the explicit scope
  /files                      list files in scope

Inspection:
  /diff                       git diff HEAD~..HEAD (last commit's changes)
  /undo                       roll back the most recent harness commit
  /provider                   show the active model + provider
  /search <query>             FTS5 search across prior session transcripts
  /sessions [N]               list the N most recent sessions (default 10)

Session:
  /quit, /exit, Ctrl-D        leave and stop services (KEEP_RUNNING=1 keeps them up)
  /reset, /new                start a new conversation (clears scope too)
  /effort <low|med|high|xhigh|max>   set effort
  /thinking on|off            toggle adaptive thinking
  /web                        open the PM web app in the default browser
  /help                       this message

Anything else is sent to the agent. Multi-line: end a line with `\\` to continue.
"""


def _ansi(code: str, text: str) -> str:
    if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
        return text
    return f"\033[{code}m{text}\033[0m"


def _dim(text: str) -> str:
    return _ansi("2", text)


def _bold(text: str) -> str:
    return _ansi("1", text)


def _color(text: str, color: str) -> str:
    codes = {"red": "31", "green": "32", "yellow": "33", "blue": "34", "magenta": "35", "cyan": "36", "gray": "90"}
    return _ansi(codes.get(color, "0"), text)


def _read_multiline_input(prompt: str) -> Optional[str]:
    """Read a line; backslash-continuation joins lines. Returns None on EOF."""

    try:
        first = input(prompt)
    except EOFError:
        return None
    parts = [first]
    while parts[-1].endswith("\\"):
        parts[-1] = parts[-1][:-1]
        try:
            cont = input(_dim("... "))
        except EOFError:
            break
        parts.append(cont)
    return "\n".join(parts).strip()


def _api_origin() -> str:
    return f"http://127.0.0.1:{API_PORT}"


def _post_json(path: str, body: Dict[str, object]) -> Dict[str, object]:
    """Simple POST helper used by the permission-decision round-trip."""

    import http.client
    payload = json.dumps(body).encode("utf-8")
    conn = http.client.HTTPConnection("127.0.0.1", API_PORT, timeout=10)
    conn.request("POST", path, body=payload, headers={"Content-Type": "application/json"})
    resp = conn.getresponse()
    raw = resp.read().decode("utf-8", errors="replace")
    conn.close()
    if resp.status >= 400:
        raise RuntimeError(f"HTTP {resp.status}: {raw[:200]}")
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return {"raw": raw}


def _get_json(path: str) -> Dict[str, object]:
    """Simple GET helper. Used by /search and /sessions."""

    import http.client
    conn = http.client.HTTPConnection("127.0.0.1", API_PORT, timeout=10)
    conn.request("GET", path)
    resp = conn.getresponse()
    raw = resp.read().decode("utf-8", errors="replace")
    conn.close()
    if resp.status >= 400:
        raise RuntimeError(f"HTTP {resp.status}: {raw[:200]}")
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return {"raw": raw}


def _post_sse(path: str, body: Dict[str, object]):
    """Yield (event, data) tuples from an SSE stream. Generator."""

    import http.client

    payload = json.dumps(body).encode("utf-8")
    conn = http.client.HTTPConnection("127.0.0.1", API_PORT, timeout=None)
    conn.request(
        "POST", path,
        body=payload,
        headers={"Content-Type": "application/json", "Accept": "text/event-stream"},
    )
    resp = conn.getresponse()
    if resp.status != 200:
        detail = resp.read().decode("utf-8", errors="replace")
        conn.close()
        raise RuntimeError(f"HTTP {resp.status}: {detail[:500]}")

    buf = b""
    while True:
        chunk = resp.read1(4096) if hasattr(resp, "read1") else resp.read(4096)
        if not chunk:
            break
        buf += chunk
        while b"\n\n" in buf:
            frame, buf = buf.split(b"\n\n", 1)
            event = "message"
            data_lines: List[str] = []
            for raw_line in frame.split(b"\n"):
                line = raw_line.decode("utf-8", errors="replace")
                if line.startswith(":"):
                    continue
                if line.startswith("event:"):
                    event = line[6:].strip()
                elif line.startswith("data:"):
                    data_lines.append(line[5:].strip())
            if not data_lines:
                continue
            try:
                data = json.loads("\n".join(data_lines))
            except json.JSONDecodeError:
                continue
            yield event, data
    conn.close()


def _run_cmd(argv: List[str], *, echo: bool = True) -> int:
    """Shell out and stream to the current terminal. Returns the exit code."""

    if echo:
        sys_print("$ " + " ".join(argv))
    try:
        return subprocess.call(argv)
    except FileNotFoundError as exc:
        err_print(str(exc))
        return 127


# ---------- Session persistence (Claw-style) ------------------------------
#
# Each chat session is one JSON file under .agent/sessions/<id>.json:
#
#   { "id": "...", "created_at": "...", "provider": "...", "model": "...",
#     "messages": [ ... wire-shaped ChatMessages ... ],
#     "journal":  [ { "kind": "tool_use"|"tool_result", "name": ..., ... } ] }
#
# The REPL writes to it after every assistant turn so a crash, /quit, or
# `./app stop` leaves a complete record on disk that `./app chat --resume`
# can pick up.


def _sessions_dir() -> Path:
    p = ROOT / ".agent" / "sessions"
    p.mkdir(parents=True, exist_ok=True)
    return p


def _new_session_id() -> str:
    import secrets
    return time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(3)


def _session_path(sid: str) -> Path:
    return _sessions_dir() / f"{sid}.json"


def _save_session(sid: str, *, provider: str, model: str, messages: List[Dict[str, object]], journal: List[Dict[str, object]]) -> None:
    body = {
        "id": sid,
        "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "provider": provider,
        "model": model,
        "messages": messages,
        "journal": journal,
    }
    path = _session_path(sid)
    tmp = path.with_suffix(".json.tmp")
    tmp.write_text(json.dumps(body, indent=2), encoding="utf-8")
    tmp.replace(path)


def _load_session(sid: str) -> Optional[Dict[str, object]]:
    path = _session_path(sid)
    if not path.exists():
        return None
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return None


def _list_sessions() -> List[Dict[str, object]]:
    """Return session summaries newest-first."""

    out: List[Dict[str, object]] = []
    if not (ROOT / ".agent" / "sessions").exists():
        return out
    for path in sorted(_sessions_dir().glob("*.json"), reverse=True):
        try:
            data = json.loads(path.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            continue
        msgs = data.get("messages", []) or []
        # First user message becomes the title preview.
        title = ""
        for m in msgs:
            if isinstance(m, dict) and m.get("role") == "user":
                c = m.get("content", "")
                if isinstance(c, str):
                    title = c.strip().split("\n", 1)[0][:80]
                break
        out.append({
            "id": data.get("id", path.stem),
            "updated_at": data.get("updated_at", ""),
            "messages": len(msgs),
            "tool_calls": sum(1 for j in (data.get("journal") or []) if j.get("kind") == "tool_use"),
            "title": title or "(no user message)",
        })
    return out


def _undo_last_harness_commit() -> None:
    """Aider-style /undo. Rolls back the most recent commit *if* it was
    authored by the harness, so a misfire can't unwind a user commit."""

    res = subprocess.run(
        ["git", "-C", str(ROOT), "log", "-1", "--pretty=format:%an|%H|%s"],
        capture_output=True, text=True,
    )
    if res.returncode != 0:
        err_print("not a git repo (or git missing)")
        return
    parts = res.stdout.strip().split("|", 2)
    if len(parts) != 3:
        print(_dim("  (no commits yet)"))
        return
    author, sha, subject = parts
    if author != "harness":
        print(_color(f"  refusing: latest commit is yours ({author} — {sha[:8]} {subject!r}).", "yellow"))
        print(_color("  /undo only rolls back commits authored by `harness`.", "yellow"))
        return
    print(_dim(f"  rolling back {sha[:8]} {subject!r}"))
    rc = subprocess.call(["git", "-C", str(ROOT), "reset", "--soft", "HEAD~1"])
    if rc == 0:
        print(_color("  reverted (changes are still staged — `git restore --staged .` to drop, or amend manually).", "green"))
    else:
        err_print(f"git reset failed ({rc})")


def _format_tool_input(name: str, input_data: Dict[str, object]) -> str:
    """One-line summary of a tool call. Truncates long strings."""

    pieces: List[str] = []
    for k, v in list(input_data.items())[:2]:
        sval = v if isinstance(v, str) else json.dumps(v)
        if len(sval) > 80:
            sval = sval[:80] + "…"
        pieces.append(f"{k}={sval}")
    return f"{name}({', '.join(pieces)})"


def _format_tool_result(content: str) -> str:
    """Indent each line of a tool result and trim to a sensible preview."""

    lines = content.splitlines()
    if len(lines) > 14:
        head = lines[:12]
        head.append(_dim(f"  …[{len(lines) - 12} more lines]"))
        lines = head
    return "\n".join("  " + ln for ln in lines)


def chat_loop(
    *,
    thinking: bool = False,
    effort: str = "high",
    resume_id: Optional[str] = None,
) -> int:
    """Run the interactive chat REPL. Returns process exit code."""

    # readline for history + editable input. Best-effort: macOS Full Disk
    # Access / ACLs can yield EPERM on .agent/state — skip history quietly in
    # that case rather than crashing the chat. Catch every OSError for both
    # read and atexit-write.
    try:
        import readline  # noqa: F401
    except ImportError:
        pass
    else:
        history = ROOT / ".agent" / "state" / "chat.history"
        try:
            history.parent.mkdir(parents=True, exist_ok=True)
            try:
                readline.read_history_file(str(history))
            except FileNotFoundError:
                pass
        except OSError as exc:
            err_print(f"chat history disabled ({exc.__class__.__name__}: {exc.strerror or exc})")
            history = None  # type: ignore[assignment]
        if history is not None:
            import atexit

            def _save_history() -> None:
                try:
                    readline.write_history_file(str(history))
                except OSError:
                    pass

            atexit.register(_save_history)

    if not _check(f"{_api_origin()}/healthz")[0]:
        err_print(f"api is not reachable at {_api_origin()}. Start it with `./app services` (or `./app`).")
        return 1

    # Probe provider info up-front so we can tell the user what's wired.
    try:
        with urllib.request.urlopen(f"{_api_origin()}/api/agent/info", timeout=3) as resp:
            info = json.loads(resp.read().decode("utf-8"))
    except (urllib.error.URLError, OSError) as exc:
        err_print(f"could not reach /api/agent/info: {exc}")
        return 1

    if not info.get("active"):
        err_print("no agent provider is configured.")
        for p in info.get("providers", []):
            mark = _color("✓", "green") if p.get("ready") else _color("·", "gray")
            note = "" if p.get("ready") else f"  — {p.get('reason', '')}"
            print(f"  {mark} {p.get('label')}  ({p.get('id')}){note}")
        print()
        print("Set one in apps/api/.env.local (see apps/api/.env.example) and restart `./app`.")
        return 1

    active = info["active"]
    print()
    print(_bold(_color("◆ chat", "magenta")) + _dim(f"  provider={active['id']}  model={active['model']}"))
    print(_dim("type /help for commands, /quit (or Ctrl-D) to exit. PM web app is open in your browser."))
    print()

    messages: List[Dict[str, object]] = []
    journal: List[Dict[str, object]] = []
    mode: str = "code"               # Aider-style: 'code' | 'ask' | 'plan'
    scope_files: List[str] = []      # explicit file scope, set via /add /drop

    # Session persistence. Either resume an existing one (load messages +
    # journal back into memory) or mint a fresh id we'll write to on every
    # assistant turn.
    if resume_id:
        loaded = _load_session(resume_id)
        if loaded is None:
            err_print(f"no session {resume_id!r} (./app sessions to list)")
            return 1
        messages = list(loaded.get("messages") or [])  # type: ignore[assignment]
        journal = list(loaded.get("journal") or [])    # type: ignore[assignment]
        sys_print(f"resumed session {resume_id} — {len(messages)} message(s), {len(journal)} journal entries")
        session_id = resume_id
    else:
        session_id = _new_session_id()
        sys_print(_dim(f"session {session_id}  ·  resume later with `./app chat --resume {session_id}`"))

    while True:
        try:
            user_input = _read_multiline_input(_color("you", "cyan") + " > ")
        except KeyboardInterrupt:
            print()
            continue

        if user_input is None:  # Ctrl-D
            print()
            return 0
        if not user_input:
            continue

        if user_input.startswith("/"):
            cmd, _, arg = user_input[1:].partition(" ")
            arg = arg.strip()
            if cmd in ("quit", "exit", "q"):
                return 0
            if cmd in ("reset", "new"):
                messages = []
                scope_files = []
                print(_dim("(new conversation; scope cleared)"))
                continue
            if cmd == "mode":
                if arg in ("ask", "code", "plan"):
                    mode = arg
                    color = "green" if mode == "code" else "yellow" if mode == "plan" else "cyan"
                    print(_dim("  mode = ") + _color(mode, color))
                    if mode == "ask":
                        print(_dim("  (read-only — agent cannot write files or run commands)"))
                    elif mode == "plan":
                        print(_dim("  (agent will propose a numbered plan; reply `go` to switch to code)"))
                else:
                    print(_color("  usage: /mode ask|code|plan", "yellow"))
                continue
            if cmd == "add":
                if not arg:
                    print(_color("  usage: /add <path>", "yellow")); continue
                p = arg.strip()
                full = (ROOT / p).resolve()
                if not str(full).startswith(str(ROOT)):
                    print(_color(f"  refused: {p} is outside the repo", "red")); continue
                if not full.exists():
                    print(_color(f"  warning: {p} does not exist", "yellow"))
                if p not in scope_files:
                    scope_files.append(p)
                print(_dim(f"  scope = {len(scope_files)} file(s); added {p}"))
                continue
            if cmd == "drop":
                if not arg:
                    print(_color("  usage: /drop <path>", "yellow")); continue
                before = len(scope_files)
                scope_files = [p for p in scope_files if p != arg.strip()]
                if len(scope_files) == before:
                    print(_dim(f"  (not in scope: {arg})"))
                else:
                    print(_dim(f"  scope = {len(scope_files)} file(s); dropped {arg}"))
                continue
            if cmd == "files":
                if not scope_files:
                    print(_dim("  (no files in scope — /add <path> to set explicit scope)"))
                else:
                    for p in scope_files:
                        mark = "✓" if (ROOT / p).exists() else "?"
                        print(f"  {_color(mark, 'green' if mark == '✓' else 'yellow')}  {p}")
                continue
            if cmd == "diff":
                _run_cmd(["git", "-C", str(ROOT), "diff", "HEAD~..HEAD", "--stat"], echo=False)
                _run_cmd(["git", "-C", str(ROOT), "diff", "HEAD~..HEAD"], echo=False)
                continue
            if cmd == "undo":
                _undo_last_harness_commit()
                continue
            if cmd == "provider":
                print(_dim(f"  active: {active['id']}  model: {active['model']}"))
                continue
            if cmd == "effort":
                if arg in ("low", "medium", "high", "xhigh", "max"):
                    effort = arg
                    print(_dim(f"  effort = {effort}"))
                else:
                    print(_color("  usage: /effort <low|medium|high|xhigh|max>", "yellow"))
                continue
            if cmd == "thinking":
                if arg in ("on", "true", "1"):
                    thinking = True
                elif arg in ("off", "false", "0"):
                    thinking = False
                else:
                    thinking = not thinking
                print(_dim(f"  thinking = {'on' if thinking else 'off'}"))
                continue
            if cmd == "web":
                try:
                    webbrowser.open(f"http://localhost:{WEB_PORT}", new=2)
                except Exception as exc:
                    err_print(str(exc))
                continue
            if cmd == "search":
                # Hermes-style FTS5 search across .agent/sessions/*.json. Skips
                # the LLM — answers locally in <50ms — so the user can quickly
                # answer "have we discussed X before?".
                if not arg:
                    print(_color("  usage: /search <query>   (FTS5 syntax: phrase, term*, OR, NOT)", "yellow"))
                    continue
                try:
                    path = f"/api/agent/sessions/search?q={urllib.parse.quote(arg)}&limit=10"
                    res = _get_json(path)
                except Exception as exc:  # noqa: BLE001
                    err_print(f"search failed: {exc}")
                    continue
                hits = res.get("hits") if isinstance(res, dict) else None
                if not hits:
                    print(_dim("  (no matches across prior sessions)"))
                    continue
                for i, h in enumerate(hits, start=1):
                    when = (h.get("updated_at") or h.get("created_at") or "")[:19].replace("T", " ")
                    prov = f"{h.get('provider','')}/{h.get('model','')}"
                    head = f"  {i}. " + _bold(str(h.get("session_id", ""))) + _dim(f"  {when}  {prov}  ({h.get('message_count', 0)} msgs)")
                    print(head)
                    first = (h.get("first_user") or "").replace("\n", " ").strip()
                    if first:
                        print(_dim("     first: ") + first[:120])
                    snip = (h.get("snippet") or "").replace("\n", " ").strip()
                    # Colorize the <<…>> markers FTS5 emits around matches.
                    snip = snip.replace("<<", "\x1b[1;33m").replace(">>", "\x1b[0m")
                    if snip:
                        print(_dim("     match: ") + snip[:240])
                    print(_dim(f"     file:  {h.get('path','')}"))
                continue
            if cmd == "sessions":
                # List recent sessions; `arg` if present limits the count.
                try:
                    limit = int(arg) if arg.isdigit() else 10
                except Exception:  # noqa: BLE001
                    limit = 10
                try:
                    res = _get_json(f"/api/agent/sessions/recent?limit={limit}")
                except Exception as exc:  # noqa: BLE001
                    err_print(f"sessions failed: {exc}"); continue
                rows = res.get("sessions") if isinstance(res, dict) else None
                if not rows:
                    print(_dim("  (no prior sessions)")); continue
                for i, r in enumerate(rows, start=1):
                    when = (r.get("updated_at") or r.get("created_at") or "")[:19].replace("T", " ")
                    prov = f"{r.get('provider','')}/{r.get('model','')}"
                    print(f"  {i}. " + _bold(str(r.get("session_id", ""))) + _dim(f"  {when}  {prov}  ({r.get('message_count',0)} msgs)"))
                    first = (r.get("first_user") or "").replace("\n", " ").strip()
                    if first:
                        print(_dim("     ") + first[:120])
                continue
            if cmd in ("help", "?"):
                print(CHAT_HELP)
                continue
            print(_color(f"  unknown command: /{cmd}. /help for the list.", "yellow"))
            continue

        messages.append({"role": "user", "content": user_input})
        try:
            assistant_blocks, tool_results = _run_one_turn(messages, thinking, effort, mode, scope_files)
        except KeyboardInterrupt:
            print(_color("\n  (interrupted; partial reply discarded)", "yellow"))
            messages.pop()  # drop the user turn so the next try is clean
            continue
        except Exception as exc:  # noqa: BLE001
            err_print(f"chat failed: {exc}")
            messages.pop()
            continue

        # Append the assistant turn + any tool results so the next turn replays
        # the full conversation back to the model (matches the API wire shape).
        if assistant_blocks:
            messages.append({"role": "assistant", "content": assistant_blocks})
        for tr in tool_results:
            messages.append(tr)

        # Journal: capture every tool call + result that landed this turn so a
        # later `/diff` (or a forensic look at the .agent/sessions file) can
        # tell exactly what the agent did. Claw-style snapshot id; simpler
        # here because we don't materialize a git tree per turn.
        snapshot = time.strftime("%Y%m%d%H%M%S", time.gmtime())
        for block in assistant_blocks:
            if block.get("type") == "tool_use":
                journal.append({
                    "kind": "tool_use",
                    "snapshot": snapshot,
                    "id": block.get("id"),
                    "name": block.get("name"),
                    "input": block.get("input"),
                    "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
                })
        for tr in tool_results:
            journal.append({
                "kind": "tool_result",
                "snapshot": snapshot,
                "tool_use_id": tr.get("tool_use_id"),
                "is_error": tr.get("is_error", False),
                "content_preview": str(tr.get("content", ""))[:240],
                "at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            })

        # Persist the session to disk after every assistant turn.
        try:
            _save_session(
                session_id,
                provider=active.get("id", ""),
                model=active.get("model", ""),
                messages=messages,
                journal=journal,
            )
        except OSError as exc:
            err_print(f"session persist failed: {exc}")


def _run_one_turn(
    messages: List[Dict[str, object]],
    thinking: bool,
    effort: str,
    mode: str,
    scope_files: List[str],
) -> Tuple[List[Dict[str, object]], List[Dict[str, object]]]:
    """Stream one ./app chat turn. Returns (assistant_blocks, tool_result_msgs)."""

    assistant_blocks: List[Dict[str, object]] = []
    tool_results: List[Dict[str, object]] = []
    # Track tool calls keyed by id so we can attach results.
    tool_use_by_id: Dict[str, Dict[str, object]] = {}
    current_text = ""
    in_thinking = False
    printed_agent_header = False

    def _ensure_agent_header() -> None:
        nonlocal printed_agent_header
        if not printed_agent_header:
            sys.stdout.write(_color("agent", "magenta") + " > ")
            sys.stdout.flush()
            printed_agent_header = True

    body: Dict[str, object] = {
        "messages": messages,
        "thinking": thinking,
        "effort": effort,
        "mode": mode,
        "confirm": True,   # REPL always gates write/exec; web AI panel doesn't
    }
    if scope_files:
        body["scope_files"] = scope_files

    heartbeat_active = False  # whether a heartbeat line is currently on screen
    def _clear_heartbeat() -> None:
        nonlocal heartbeat_active
        if heartbeat_active and sys.stdout.isatty() and not os.environ.get("NO_COLOR"):
            sys.stdout.write("\r\033[K")  # \r + clear-to-EOL
            sys.stdout.flush()
        heartbeat_active = False

    for event, data in _post_sse("/api/agent/chat", body):
        if event == "started":
            continue
        if event == "heartbeat":
            # OpenClaude-style live status line. Only render while we're idle
            # mid-turn (no text streaming, no thinking, no tool in flight) so
            # we don't overwrite the model's own output.
            if in_thinking or printed_agent_header and current_text:
                continue
            idle = int(data.get("idle_s", 0))
            if idle < 2:
                continue
            if sys.stdout.isatty() and not os.environ.get("NO_COLOR"):
                sys.stdout.write("\r\033[K" + _dim(f"  · thinking…  {idle}s idle"))
                sys.stdout.flush()
                heartbeat_active = True
            continue
        if event == "thinking_delta":
            _clear_heartbeat()
            if not in_thinking:
                _ensure_agent_header()
                sys.stdout.write("\n" + _dim("  ┊ thinking…  "))
                in_thinking = True
            sys.stdout.write(_dim(data.get("text", "")))
            sys.stdout.flush()
            continue
        if event == "text_delta":
            _clear_heartbeat()
            if in_thinking:
                sys.stdout.write("\n")
                in_thinking = False
            _ensure_agent_header()
            sys.stdout.write(data.get("text", ""))
            sys.stdout.flush()
            current_text += str(data.get("text", ""))
            continue
        if event == "tool_use":
            _clear_heartbeat()
            if in_thinking:
                sys.stdout.write("\n")
                in_thinking = False
            if current_text:
                assistant_blocks.append({"type": "text", "text": current_text})
                current_text = ""
            tid = str(data.get("id", ""))
            name = str(data.get("name", ""))
            input_data = data.get("input", {}) or {}
            tool_use_by_id[tid] = {"id": tid, "name": name, "input": input_data}
            assistant_blocks.append({"type": "tool_use", "id": tid, "name": name, "input": input_data})
            print()
            print(_color("  ⚙ ", "blue") + _bold(_format_tool_input(name, input_data)))  # type: ignore[arg-type]
            continue
        if event == "permission_required":
            _clear_heartbeat()
            token = str(data.get("token", ""))
            tool = str(data.get("tool", ""))
            preview = str(data.get("preview", tool))
            # Plandex-style preview: header on the first line, an indented
            # unified diff below. Colorize `-`/`+` lines red/green so the
            # change pops at a glance.
            print()
            lines = preview.split("\n")
            if lines:
                print(_color("  ⚠ ", "yellow") + _bold(lines[0]))
                for raw in lines[1:]:
                    # Each diff line is indented "  " then `+`/`-`/` ` then text.
                    stripped = raw.lstrip()
                    if stripped.startswith("+"):
                        print(_color(raw, "green"))
                    elif stripped.startswith("-"):
                        print(_color(raw, "red"))
                    elif stripped.startswith("⋯") or stripped.startswith("…("):
                        print(_dim(raw))
                    elif stripped.startswith("$ "):
                        print(_bold(raw))
                    else:
                        print(_dim(raw))
            print(_dim("    allow? [y/N/m=deny with message] "), end="")
            sys.stdout.flush()
            try:
                resp = input("").strip().lower()
            except (EOFError, KeyboardInterrupt):
                resp = "n"
            decision: Dict[str, object]
            if resp in ("y", "yes"):
                decision = {"decision": "allow"}
                print(_color("    ✓ allowed", "green"))
            elif resp.startswith("m"):
                msg = input(_dim("    deny message: ")).strip() or "user declined"
                decision = {"decision": "deny", "message": msg}
                print(_color(f"    ✗ denied — {msg}", "red"))
            else:
                decision = {"decision": "deny", "message": "user declined"}
                print(_color("    ✗ denied", "red"))
            # Post the decision; runner unblocks and either runs the tool
            # or sends an is_error tool_result back.
            try:
                _post_json("/api/agent/permission", {"token": token, **decision})
            except Exception as exc:  # noqa: BLE001
                err_print(f"failed to send permission decision: {exc}")
            continue
        if event == "tool_result":
            tid = str(data.get("id", ""))
            content = str(data.get("content", ""))
            is_error = bool(data.get("is_error"))
            label = _color("  ✗ ", "red") if is_error else _color("  ✓ ", "green")
            print(label + _dim(_format_tool_result(content)))
            tool_results.append({
                "role": "tool",
                "tool_use_id": tid,
                "content": content,
                "is_error": is_error,
            })
            continue
        if event == "message_stop":
            continue
        if event == "usage":
            in_t = data.get("input_tokens")
            out_t = data.get("output_tokens")
            cr = data.get("cache_read_input_tokens")
            if in_t is not None or out_t is not None:
                bits = []
                if in_t is not None:
                    bits.append(f"in={in_t}")
                if out_t is not None:
                    bits.append(f"out={out_t}")
                if cr:
                    bits.append(f"cache={cr}")
                print(_dim("  " + "  ".join(bits)))
            continue
        if event == "error":
            print()
            err_print(str(data.get("message", "unknown error")))
            continue
        if event == "done":
            if in_thinking:
                sys.stdout.write("\n")
            if printed_agent_header and not current_text and not assistant_blocks:
                # Agent didn't say anything visible — empty newline so the next prompt is clean.
                sys.stdout.write("\n")
            elif printed_agent_header:
                sys.stdout.write("\n")
            sys.stdout.flush()
            break

    if current_text:
        assistant_blocks.append({"type": "text", "text": current_text})
    return assistant_blocks, tool_results


def cmd_chat(args: List[str]) -> int:
    resume_id: Optional[str] = None
    i = 0
    while i < len(args):
        a = args[i]
        if a in ("--resume", "-r"):
            if i + 1 >= len(args):
                err_print("--resume needs a session id (./app sessions to list)")
                return 2
            resume_id = args[i + 1]
            i += 2
            continue
        err_print(f"unknown chat flag: {a}")
        return 2
    return chat_loop(resume_id=resume_id)


def cmd_sessions(_args: List[str]) -> int:
    rows = _list_sessions()
    if not rows:
        sys_print("no sessions yet — run `./app` and chat once to create one.")
        return 0
    for r in rows[:30]:
        line = f"  {r['id']}  {_dim(str(r['updated_at']))}  "
        line += _dim(f"{r['messages']} msg · {r['tool_calls']} tool calls")
        line += "  " + _color(str(r['title']), 'cyan')
        print(line)
    if len(rows) > 30:
        print(_dim(f"  …{len(rows) - 30} older sessions"))
    print()
    print(_dim("resume with: ./app chat --resume <id>"))
    return 0


# ---------- harness passthroughs ------------------------------------------


def _python_bin() -> str:
    venv_py = ROOT / ".venv" / "bin" / "python"
    if venv_py.exists():
        return str(venv_py)
    return sys.executable


def _harness(argv: List[str]) -> int:
    cmd = [_python_bin(), "-m", "harness.cli", *argv]
    return subprocess.call(cmd, cwd=ROOT, env={**os.environ, "PATH": f"{ROOT/'node_modules'/'.bin'}:{os.environ.get('PATH', '')}"})


def cmd_build(args: List[str]) -> int:
    if not any(a == "--goal" for a in args):
        args = ["--goal", "Build the dogfood CRUD app", *args]
    return _harness(["build", *args])


def cmd_inspect(args: List[str]) -> int:
    return _harness(["inspect", *args])


def cmd_plan(args: List[str]) -> int:
    return _harness(["plan", *args])


def cmd_resume(args: List[str]) -> int:
    return _harness(["resume", *args])


def cmd_report(args: List[str]) -> int:
    return _harness(["report", *args])


# ---------- dispatcher -----------------------------------------------------


COMMANDS = {
    "dev": cmd_dev,
    "services": cmd_services,
    "chat": cmd_chat,
    "sessions": cmd_sessions,
    "stop": cmd_stop,
    "status": cmd_status,
    "build": cmd_build,
    "inspect": cmd_inspect,
    "plan": cmd_plan,
    "resume": cmd_resume,
    "report": cmd_report,
}


def main(argv: List[str]) -> int:
    if not argv or argv[0] in ("-h", "--help", "help"):
        if argv and argv[0] in ("-h", "--help", "help"):
            print(__doc__)
            return 0
        # Default with no args → dev.
        return cmd_dev([])

    cmd = argv[0]
    rest = argv[1:]
    fn = COMMANDS.get(cmd)
    if fn is None:
        err_print(f"unknown command: {cmd}")
        print(__doc__)
        return 2
    return fn(rest)


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