"""ask-gpt — one prompt to the owner's ChatGPT session, answer printed on stdout."""

from __future__ import annotations

import argparse
import datetime as dt
import http.client
import json
import os
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path

import chat
import convlog
import registry
from chat import EFFORTS, ChatError, ask
from browser import MarionetteError, Session

DAEMON_PORT = int(os.environ.get("SOLWEBD_PORT", "8791"))
TOKEN_FILE = Path(os.environ.get("SOLWEBD_TOKEN_FILE",
                                 Path.home() / ".overdeck" / "gptbridge" / "solwebd.token"))


class DaemonError(RuntimeError):
    pass


class OldDaemonError(DaemonError):
    pass


class ZipError(RuntimeError):
    pass


def _git_root(start: Path) -> Path:
    result = subprocess.run(
        ["git", "-C", str(start), "rev-parse", "--show-toplevel"],
        capture_output=True, text=True)
    if result.returncode != 0:
        raise ZipError(f"{start} is not inside a git repository")
    return Path(result.stdout.strip())


def _default_branch(root: Path) -> str:
    for ref in ("main", "master"):
        if subprocess.run(["git", "-C", str(root), "rev-parse", "--verify", "-q", ref],
                           capture_output=True).returncode == 0:
            return ref
    result = subprocess.run(
        ["git", "-C", str(root), "symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"],
        capture_output=True, text=True)
    if result.returncode == 0 and result.stdout.strip():
        return result.stdout.strip().rsplit("/", 1)[-1]
    raise ZipError(f"{root}: no main/master branch and no origin/HEAD to fall back to")


def zip_main_checkout(start: Path, dest_dir: Path, stamp: str) -> Path:
    """Zip of `start`'s repo as landed on its default branch — no local edits."""
    root = _git_root(start)
    branch = _default_branch(root)
    dest = dest_dir / f"{stamp}-{root.name}-{branch}.zip"
    result = subprocess.run(
        ["git", "-C", str(root), "archive", "--format=zip", "-o", str(dest), branch])
    if result.returncode != 0:
        raise ZipError(f"git archive failed for {root} @ {branch}")
    return dest


def zip_worktree(start: Path, dest_dir: Path, stamp: str) -> Path:
    """Zip of `start`'s current working tree, honoring .gitignore."""
    root = start.resolve()
    if not root.is_dir():
        raise ZipError(f"--zip-worktree: not a directory: {root}")
    result = subprocess.run(
        ["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
        capture_output=True)
    if result.returncode != 0:
        raise ZipError(f"--zip-worktree: {root} is not inside a git repository")
    files = [f for f in result.stdout.decode().split("\0") if f]
    dest = dest_dir / f"{stamp}-{root.name}-worktree.zip"
    with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as zf:
        for rel in files:
            zf.write(root / rel, rel)
    return dest


def _post(url: str, token: str, payload: dict, timeout: float) -> dict:
    request = urllib.request.Request(
        url, data=json.dumps(payload).encode(), method="POST",
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"})
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return json.loads(response.read() or b"{}")


def _sse_events(response):
    for raw in response:
        line = raw.rstrip(b"\n")
        if not line or line.startswith(b":"):
            continue  # blank separator or a keepalive comment
        if line.startswith(b"data: "):
            yield json.loads(line[len(b"data: "):])


def _ask_stream(base: str, token: str, payload: dict, timeout: float, on_event) -> dict:
    """Always taken on the daemon path (`--live` only changes rendering, never
    transport), so failure logs and `--json` fields are identical on both paths."""
    request = urllib.request.Request(
        f"{base}/v1/ask", data=json.dumps({**payload, "stream": True}).encode(),
        method="POST", headers={"Content-Type": "application/json",
                                "Authorization": f"Bearer {token}"})
    response = urllib.request.urlopen(request, timeout=timeout)
    if "text/event-stream" not in response.headers.get("Content-Type", ""):
        # A truncated transport death also arrives here with a non-stream
        # Content-Type; reading first lets that raise its own OSError/
        # IncompleteRead instead of being misreported as an old daemon.
        response.read()
        response.close()
        raise OldDaemonError(
            "solwebd predates streaming /v1/ask (no conversation_id, no event "
            "stream); restart solwebd")
    terminal = None
    try:
        for event in _sse_events(response):
            on_event(event)
            if event.get("type") in ("done", "error"):
                terminal = event
    except (OSError, http.client.HTTPException) as exc:
        if terminal is None:
            on_event({"type": "error", "class": "transport",
                      "detail": f"solwebd died/socket lost mid-turn: {exc}", "partial": {}})
        raise
    finally:
        response.close()
    if terminal is None:
        raise DaemonError("solwebd closed the /v1/ask stream with no terminal event")
    return terminal


def daemon_endpoint() -> str | None:
    """The daemon's base URL when it is up, else None.

    It holds `profile.lock` for its whole lifetime, so a direct session would block
    for 600s and then fail. A listening-but-unauthorised daemon is an ERROR, never a
    fallthrough — falling through is exactly the hang this probe exists to prevent.
    """
    base = f"http://127.0.0.1:{DAEMON_PORT}"
    if not TOKEN_FILE.exists():
        return None
    token = TOKEN_FILE.read_text().strip()
    request = urllib.request.Request(f"{base}/healthz",
                                     headers={"Authorization": f"Bearer {token}"})
    try:
        with urllib.request.urlopen(request, timeout=5) as response:
            return base if json.loads(response.read() or b"{}").get("ok") else None
    except urllib.error.HTTPError as exc:
        raise DaemonError(
            f"solwebd is running on {base} but rejected our token ({exc.code}); "
            f"it holds the browser profile — fix {TOKEN_FILE} or stop the daemon"
        ) from None
    except OSError:
        return None


def _tilde(path: Path) -> str:
    home = str(Path.home())
    text = str(path)
    return "~" + text[len(home):] if text.startswith(home) else text


_LIVE_DIM, _LIVE_RESET = "\x1b[2m", "\x1b[0m"


def render_live(event: dict) -> None:
    """`--live` is a human display stream, not pipe-safe: thinking dimmed/prefixed on
    stdout interleaved with the answer; `replace` redraws by marker plus corrected
    tail; upload/download lines on stderr."""
    etype = event.get("type")
    if etype == "thinking":
        delta = event.get("delta", "")
        if delta == chat.THINKING_UNHARVESTABLE:
            print(f"\n{_LIVE_DIM}[thinking] {delta}{_LIVE_RESET}", file=sys.stderr)
        else:
            sys.stdout.write(f"{_LIVE_DIM}{delta}{_LIVE_RESET}")
            sys.stdout.flush()
    elif etype == "answer":
        sys.stdout.write(event.get("delta", ""))
        sys.stdout.flush()
    elif etype == "replace":
        sys.stdout.write(
            f"\n[corrected]\n{event.get('thinking', '')}\n{event.get('answer', '')}\n")
        sys.stdout.flush()
    elif etype == "response_done":
        sys.stdout.write("\n")
        sys.stdout.flush()
    elif etype == "upload":
        print(f"upload: {event.get('file')} {event.get('state')}", file=sys.stderr)
    elif etype == "download":
        path = event.get("path")
        print(f"download: {event.get('file')} {event.get('state')}"
             f"{f' -> {path}' if path else ''}", file=sys.stderr)
    elif etype == "error":
        print(f"error ({event.get('class')}): {event.get('detail')}", file=sys.stderr)


class DownloadProgress:
    def __init__(self, *, is_tty: bool, clock=time.monotonic, interval: float | None = None,
                 out_dir: Path, width: int | None = None):
        self.is_tty = is_tty
        self.clock = clock
        self.interval = interval if interval is not None else (1.0 if is_tty else 30.0)
        self.out_dir = out_dir
        self.width = width
        self.started_at = clock()
        self._lock = threading.RLock()
        self._stop = threading.Event()
        self._ticker: threading.Thread | None = None
        self._disabled = False
        self._bar_active = False
        self._last_draw = float("-inf")
        self.phase = "starting"
        self.conversation_id = ""
        self.items: list[dict] = []
        self.plan_seen = False
        self.unavailable: list[dict] = []
        self.invalid: list[dict] = []
        self.current: dict | None = None
        self.completed = 0
        self.reused = 0
        self._current_started_at: float | None = None
        self._terminal_duration = 0.0

    def start(self) -> None:
        with self._lock:
            try:
                self._safe_print("download: starting")
                if self._disabled or self._ticker is not None:
                    return
                self._ticker = threading.Thread(
                    target=self._tick, name="ask-gpt-download-progress", daemon=True)
                self._ticker.start()
            except Exception:
                self._disabled = True
                self._stop.set()
                self._ticker = None

    def _tick(self) -> None:
        while not self._stop.wait(self.interval):
            with self._lock:
                if self._disabled:
                    return
                try:
                    self._heartbeat()
                except Exception:
                    self._disabled = True
                    return

    def on_event(self, event: dict) -> None:
        with self._lock:
            if self._disabled:
                return
            try:
                self._on_event(event)
            except Exception:
                self._disabled = True
                self._stop.set()

    def _on_event(self, event: dict) -> None:
        etype = event.get("type")
        if etype == "download_phase":
            self.phase = str(event.get("state") or self.phase)
            self.conversation_id = str(event.get("conversation_id") or self.conversation_id)
            labels = {
                "queued": "download: waiting for browser seat",
                "opening": f"download: opening conversation {self.conversation_id}",
                "scanning": "download: scanning attachments",
            }
            if self.phase in labels:
                self._line(labels[self.phase])
        elif etype == "download_plan":
            self.plan_seen = True
            self.items = list(event.get("items") or [])
            self.unavailable = list(event.get("unavailable") or [])
            self.invalid = list(event.get("invalid") or [])
            self.current = None
            self.completed = 0
            self._print_manifest()
        elif etype == "download":
            state = event.get("state")
            if state == "started":
                self.current = event
                self._current_started_at = self.clock()
                if self.is_tty:
                    self._draw_bar(force=self._last_draw == float("-inf"))
                else:
                    self._safe_print(self._item_status())
            elif state in {"saved", "failed"}:
                if self._current_started_at is not None:
                    self._terminal_duration += max(0.0, self.clock() - self._current_started_at)
                self._current_started_at = None
                self.completed += 1
                if state == "saved" and event.get("reused_existing"):
                    self.reused += 1
                if self.is_tty:
                    self._draw_bar()
                else:
                    suffix = f" -> {event['path']}" if event.get("path") else \
                        f" — {event.get('error', 'failed')}"
                    self._safe_print(
                        f"download: {state} {event.get('index')}/{event.get('total')} "
                        f"{event.get('file')}{suffix}")

    def _print_manifest(self) -> None:
        attempted = len(self.items)
        parts = [f"Will download {attempted} attachment{'s' if attempted != 1 else ''}"]
        if self.unavailable:
            parts.append(f"{len(self.unavailable)} unavailable")
        if self.invalid:
            parts.append(f"{len(self.invalid)} invalid")
        self._line("; ".join(parts) + ":" if self.items or self.unavailable or self.invalid
                   else "Will download 0 attachments.")
        for item in self.items:
            self._safe_print(
                f"  {item.get('index')}/{item.get('total')}  "
                f"[{str(item.get('kind', 'file'))}]   {item.get('file')}")
        for entry in self.unavailable:
            kind = f" [{entry.get('kind')}]" if entry.get("kind") else ""
            self._safe_print(
                f"  unavailable{kind} {entry.get('file')} — {entry.get('reason')}")
        for entry in self.invalid:
            self._safe_print(f"  invalid {entry.get('file')} — {entry.get('reason')}")

    def _elapsed(self) -> float:
        return max(0.0, self.clock() - self.started_at)

    @staticmethod
    def _duration(seconds: float | None) -> str:
        if seconds is None:
            return "--:--"
        value = max(0, int(seconds))
        hours, remainder = divmod(value, 3600)
        minutes, seconds = divmod(remainder, 60)
        return f"{hours:02d}:{minutes:02d}:{seconds:02d}" if hours else f"{minutes:02d}:{seconds:02d}"

    def _eta(self) -> str:
        total = len(self.items)
        if self.completed < 1:
            return "ETA --:--"
        remaining = max(0, total - self.completed)
        estimate = (self._terminal_duration / self.completed) * remaining
        return f"ETA ~{self._duration(estimate)}"

    def _item_status(self) -> str:
        item = self.current or {}
        return (
            f"download: file {item.get('index', 0)}/{item.get('total', 0)} "
            f"· completed {self.completed}/{len(self.items)} · {item.get('file', '')} "
            f"— elapsed {self._duration(self._elapsed())}, {self._eta()}"
        )

    def _bar_text(self) -> str:
        item = self.current or {}
        total = max(1, len(self.items))
        filled = round(16 * self.completed / total)
        bar = "█" * filled + "░" * (16 - filled)
        prefix = (
            f"[{bar}] file {item.get('index', 0)}/{item.get('total', 0)} "
            f"· completed {self.completed}/{len(self.items)} · "
        )
        suffix = f" · elapsed {self._duration(self._elapsed())} · {self._eta()}"
        filename = str(item.get("file", ""))
        try:
            terminal_width = os.get_terminal_size(sys.stderr.fileno()).columns
        except (AttributeError, OSError, ValueError):
            terminal_width = 80
        width = self.width or terminal_width
        available = max(1, width - len(prefix) - len(suffix))
        if len(filename) > available:
            filename = filename[:max(1, available - 1)] + "…"
        return prefix + filename + suffix

    def _draw_bar(self, *, force: bool = False) -> None:
        now = self.clock()
        if not force and now - self._last_draw < 1.0:
            return
        self._last_draw = now
        text = self._bar_text()
        sys.stderr.write("\r" + text + "\x1b[K")
        sys.stderr.flush()
        self._bar_active = True

    def _heartbeat(self) -> None:
        if self.is_tty:
            if self.current is not None:
                self._draw_bar()
            else:
                self._draw_phase()
            return
        if self.current is not None:
            self._safe_print(self._item_status())
        else:
            label = {
                "starting": "download: starting",
                "queued": "download: waiting for browser seat",
                "opening": f"download: opening conversation {self.conversation_id}",
                "scanning": "download: scanning attachments",
            }.get(self.phase, f"download: {self.phase}")
            self._safe_print(f"{label} — elapsed {self._duration(self._elapsed())}")

    def _draw_phase(self) -> None:
        now = self.clock()
        if now - self._last_draw < 1.0:
            return
        self._last_draw = now
        label = {
            "starting": "starting", "queued": "waiting for browser seat",
            "opening": f"opening conversation {self.conversation_id}",
            "scanning": "scanning attachments",
        }.get(self.phase, self.phase)
        sys.stderr.write(f"\rdownload: {label} — elapsed {self._duration(self._elapsed())}\x1b[K")
        sys.stderr.flush()
        self._bar_active = True

    def _line(self, text: str) -> None:
        if self._bar_active:
            sys.stderr.write("\n")
            self._bar_active = False
        self._safe_print(text)

    @staticmethod
    def _safe_print(text: str) -> None:
        print(text, file=sys.stderr)

    def _stop_ticker(self) -> None:
        self._stop.set()
        ticker = self._ticker
        if ticker is not None and ticker is not threading.current_thread():
            ticker.join()
        self._ticker = None

    def stop(self) -> None:
        self._stop_ticker()
        with self._lock:
            if self._bar_active and not self._disabled:
                try:
                    sys.stderr.write("\n")
                    sys.stderr.flush()
                except Exception:
                    self._disabled = True
                self._bar_active = False

    def finish(self, files: list[str], failures: list[dict], unavailable: list[dict],
               invalid: list[dict]) -> None:
        self._stop_ticker()
        with self._lock:
            if self._disabled:
                return
            try:
                if self._bar_active:
                    sys.stderr.write("\n")
                    self._bar_active = False
                successful = len(files)
                planned = len(self.items)
                unique = len(set(files))
                attachment_word = "attachment" if planned == 1 else "attachments"
                file_word = "file" if unique == 1 else "files"
                counts = f"{unique} {file_word}"
                if self.reused:
                    counts += f", {self.reused} reused"
                summary = (
                    f"Recovered {successful}/{planned} {attachment_word} ({counts}) "
                    f"to {self.out_dir} in {self._duration(self._elapsed())}"
                )
                issues = []
                if failures:
                    issues.append(f"{len(failures)} failed")
                if unavailable:
                    issues.append(f"{len(unavailable)} unavailable")
                if invalid:
                    issues.append(f"{len(invalid)} invalid")
                self._safe_print(summary + (f"; {', '.join(issues)}." if issues else "."))
                if failures:
                    self._safe_print("Failed:")
                    for failure in failures:
                        self._safe_print(
                            f"  {failure.get('file')} — {failure.get('error', 'failed')}")
            except Exception:
                self._disabled = True

    def interrupt(self, *, daemon: bool = False) -> None:
        self._stop_ticker()
        with self._lock:
            if self._disabled:
                return
            try:
                if self._bar_active:
                    sys.stderr.write("\n")
                    self._bar_active = False
                if daemon:
                    self._safe_print(
                        f"download: stopped watching after {self.completed}/{len(self.items)}; "
                        f"daemon may continue writing to {self.out_dir}")
                else:
                    self._safe_print(
                        f"download: interrupted after {self.completed}/{len(self.items)}; "
                        f"completed files kept in {self.out_dir}")
            except Exception:
                self._disabled = True


class RunLog:
    """The CLI is the only log writer: it accumulates the turn state from TurnEvents
    (identically on the daemon and direct paths) and writes the canonical + cwd
    duplicate transcript once the turn ends, success or failure alike."""

    def __init__(self, prompt: str, effort: str | None, cwd: Path, stamp: str,
                resume_id: str | None, live: bool):
        self.prompt, self.effort, self.cwd, self.stamp = prompt, effort, cwd, stamp
        self.resume = bool(resume_id)
        self.live = live
        self.conversation_id = resume_id or ""
        self.url = ""
        self.phase = "attaching"
        self.thinking = ""
        self.answer = ""
        self.artifacts: list[str] = []
        self.log: convlog.ConversationLog | None = None
        self.turn_number = 1
        self._lock_cm = None
        self.canonical_path: Path | None = None
        self.duplicate_path: Path | None = None
        self.done_reply: dict | None = None
        self.error_event: dict | None = None
        if self.resume:
            self._acquire(resume_id, f"https://chatgpt.com/c/{resume_id}")

    def _acquire(self, conversation_id: str, url_for_new_log: str) -> None:
        self.conversation_id = conversation_id
        self.log = convlog.ConversationLog(conversation_id)
        self._lock_cm = convlog.held(conversation_id)
        self._lock_cm.__enter__()
        self.log.ensure(url_for_new_log)
        self.turn_number = self.log.next_number()

    def reset_accumulation(self) -> None:
        """A daemon attempt that died mid-stream leaves partial thinking/answer/
        artifacts behind; call this before a direct-browser retry logs the SAME
        turn, or the log ends up concatenating two attempts into one."""
        self.phase = "attaching"
        self.thinking = ""
        self.answer = ""
        self.artifacts = []
        self.done_reply = None
        self.error_event = None

    def hydrate(self, dom_turns: list[dict]) -> None:
        if self.log is not None:
            convlog.hydrate(self.log, dom_turns)
            self.turn_number = self.log.next_number()

    def on_event(self, event: dict) -> None:
        etype = event.get("type")
        self.phase = event.get("phase", self.phase)
        if etype == "meta":
            self.url = event.get("url", self.url)
            if not self.resume and self.log is None:
                conversation_id = str(event["conversation_id"])
                self._acquire(conversation_id, self.url)
                # Held before this prints: the id is never exposed while unlocked.
                print(f"resume: ask-gpt --resume {conversation_id}", file=sys.stderr)
        elif etype == "thinking":
            self.thinking += event.get("delta", "")
        elif etype == "answer":
            self.answer += event.get("delta", "")
        elif etype in ("replace", "response_done"):
            self.thinking = event.get("thinking", self.thinking)
            self.answer = event.get("answer", self.answer)
        elif etype == "download" and event.get("state") == "saved" and event.get("path"):
            self.artifacts.append(str(event["path"]))
        elif etype == "done":
            self.done_reply = event.get("reply") or {}
            self.thinking = self.done_reply.get("thinking", self.thinking)
            self.answer = self.done_reply.get("text", self.answer)
        elif etype == "error":
            self.error_event = event
        if not self.live:
            if etype == "queued":
                print(f"ask-gpt: queued for a browser seat (up to {event.get('wait_s', 0):.0f}s)",
                      file=sys.stderr)
            elif etype == "submitting":
                print("ask-gpt: submitting prompt", file=sys.stderr)
            elif etype == "submitted":
                print("ask-gpt: prompt submitted; waiting for response", file=sys.stderr)
        if self.live:
            render_live(event)

    def close(self, *, ok: bool) -> None:
        if self.log is not None:
            turn = convlog.Turn(
                number=self.turn_number, timestamp=convlog.now_iso(), effort=self.effort,
                prompt=self.prompt, thinking=self.thinking, answer=self.answer,
                artifacts=self.artifacts, interrupted=not ok)
            self.log.append(turn)
            self.canonical_path = self.log.path
            self.duplicate_path = self.log.rewrite_duplicate(self.cwd)
        elif not ok:
            partial = (self.error_event or {}).get("partial") or {
                "phase": self.phase, "thinking": self.thinking, "answer": self.answer,
                "saved_artifacts": self.artifacts}
            self.canonical_path = convlog.write_failure(self.stamp, partial, self.prompt,
                                                         self.effort)
        if self._lock_cm is not None:
            self._lock_cm.__exit__(None, None, None)

    def close_download(self, saved: list[str], failures: list[str]) -> None:
        """§4b: a turn-less `### downloaded` record — no prompt/answer to log."""
        if self.log is not None:
            download = convlog.Download(timestamp=convlog.now_iso(), paths=saved,
                                        failures=failures)
            self.log.append_download(download)
            self.canonical_path = self.log.path
            self.duplicate_path = self.log.rewrite_duplicate(self.cwd)
        if self._lock_cm is not None:
            self._lock_cm.__exit__(None, None, None)

    def print_log_lines(self) -> None:
        if self.canonical_path is None:
            return
        if self.duplicate_path is not None:
            print(f"Log: ./ask-gpt/{self.conversation_id}.log", file=sys.stderr)
        print(f"Log: {_tilde(self.canonical_path)}", file=sys.stderr)


def _write_registry_cwd_copy(items: list[registry.Conversation]) -> Path:
    out_dir = Path("ask-gpt")
    out_dir.mkdir(parents=True, exist_ok=True)
    dest = out_dir / "conversations.jsonl"
    body = "".join(json.dumps(item.record(), separators=(",", ":")) + "\n" for item in items)
    tmp = dest.with_suffix(dest.suffix + f".tmp-{os.getpid()}")
    tmp.write_text(body)
    os.replace(tmp, dest)
    return dest


def _run_listing(args: argparse.Namespace) -> int:
    """Registry-only listing; never opens daemon, browser, or network."""
    try:
        items = registry.conversations()
        if args.list_all:
            _write_registry_cwd_copy(items)
            print("Log: ./ask-gpt/conversations.jsonl")
            return 0
        if args.search is not None:
            pattern = chat.compile_search(args.search)
            items = [item for item in items if pattern.search(item.title)]
        elif args.list is not None:
            items = items[:args.list]
    except (ChatError, registry.RegistryError) as exc:
        print(f"ask-gpt: {exc}", file=sys.stderr)
        return 1
    for item in items:
        print(f"{item.conversation_id}\t{item.title}")
    return 0


def _run_bootstrap(args: argparse.Namespace) -> int:
    try:
        if registry.conversations():
            raise registry.RegistryError("conversation registry already contains records; refusing bootstrap")
        base = daemon_endpoint()
        if base:
            result = _post(f"{base}/v1/ask", TOKEN_FILE.read_text().strip(),
                           {"bootstrap": True}, timeout=180.0)
            account, rows = result.get("account"), result.get("threads") or []
        else:
            account, rows = chat.bootstrap_registry_standalone(mode=args.mode, reseed=args.reseed)
        if not isinstance(account, str) or not account.strip():
            raise ChatError("could not determine authenticated ChatGPT account")
        for row in rows:
            registry.append(account, str(row["id"]), str(row["title"]),
                            f"https://chatgpt.com/c/{row['id']}")
    except (ChatError, MarionetteError, registry.RegistryError, KeyError) as exc:
        print(f"ask-gpt: {exc}", file=sys.stderr)
        return 1
    print(f"Imported: {len(rows)}")
    return 0

def _run_download(args: argparse.Namespace) -> int:
    """Read-only attachment recovery with progress independent of answer rendering."""
    try:
        conversation_id = chat.parse_resume_id(args.download_attachments)
    except ChatError as exc:
        print(f"ask-gpt: {exc}", file=sys.stderr)
        return 1
    try:
        owned = registry.lookup(conversation_id)
        if owned is None:
            raise registry.RegistryError(f"conversation {conversation_id} is not in the local registry")
    except registry.RegistryError as exc:
        print(f"ask-gpt: {exc}", file=sys.stderr)
        return 1

    stamp = dt.datetime.now().strftime("%Y-%m-%d-%H%M%S")
    requested_out = Path(args.out).expanduser()
    display_out = requested_out if not requested_out.is_absolute() else requested_out.resolve()
    out_path = requested_out.resolve()
    rl = RunLog("", None, Path.cwd(), stamp, conversation_id, live=False)
    progress = DownloadProgress(
        is_tty=sys.stderr.isatty(), out_dir=display_out)
    try:
        progress.start()
    except Exception:
        pass
    watching_daemon = False
    daemon_attempted = False
    plan_seen = False

    def on_event(event: dict) -> None:
        nonlocal plan_seen
        if event.get("type") == "download_plan":
            plan_seen = True
        try:
            progress.on_event(event)
        except Exception:
            pass
        rl.on_event(event)

    def stop_progress() -> None:
        try:
            progress.stop()
        except Exception:
            pass

    try:
        base = daemon_endpoint()
        if base:
            daemon_attempted = True
            watching_daemon = True
            try:
                payload = {
                    "conversation": conversation_id, "download": True,
                    "out": str(out_path), "stamp": stamp, "timeout": args.timeout,
                    "attempt": "daemon-1",
                }
                terminal = _ask_stream(
                    base, TOKEN_FILE.read_text().strip(), payload,
                    timeout=args.timeout + 30, on_event=on_event)
                if terminal.get("type") == "error":
                    raise ChatError(terminal.get("detail") or "solwebd: download failed")
                reply = dict(terminal.get("reply") or {})
            except urllib.error.HTTPError:
                raise
            except (OSError, http.client.HTTPException, ValueError) as exc:
                if plan_seen:
                    raise DaemonError(
                        f"connection lost after download plan ({exc}); refusing duplicate "
                        "direct recovery while the daemon may still be writing") from None
                if daemon_endpoint():
                    raise DaemonError(
                        f"the request failed ({exc}) but solwebd is still listening on "
                        f"{base} and holds the browser profile; restart it") from None
                print(
                    f"download: daemon attempt ended before plan ({exc}); "
                    "retrying with direct browser session", file=sys.stderr)
                watching_daemon = False
                base = None
                rl.reset_accumulation()
        if not base:
            watching_daemon = False
            attempt = "direct-2" if daemon_attempted else "direct-1"
            saved, direct_failures = chat.download_attachments(
                conversation_id, expected_account=owned.account, out_dir=out_path,
                mode=args.mode, reseed=args.reseed, on_event=on_event, attempt=attempt)
            reply = dict(rl.done_reply or {})
            reply.setdefault("files", [str(path) for path in saved])
            reply.setdefault("failures", direct_failures)
            reply.setdefault("unavailable", [])
            reply.setdefault("invalid", [])
            reply.setdefault("planned", len(reply["files"]) + len(reply["failures"]))
            reply.setdefault("reused", 0)
    except KeyboardInterrupt:
        try:
            progress.interrupt(daemon=watching_daemon)
        except Exception:
            pass
        rl.close_download(list(rl.artifacts), [])
        rl.print_log_lines()
        return 130
    except OSError as exc:
        stop_progress()
        completed = list(rl.artifacts)
        rl.close_download(completed, [f"filesystem: {exc}"])
        rl.print_log_lines()
        print(f"ask-gpt: attachment output failed: {exc}", file=sys.stderr)
        return 1
    except (ChatError, MarionetteError, DaemonError) as exc:
        stop_progress()
        rl.close_download([], [])
        rl.print_log_lines()
        print(f"ask-gpt: {exc}", file=sys.stderr)
        return 1
    except urllib.error.HTTPError as exc:
        stop_progress()
        rl.close_download([], [])
        rl.print_log_lines()
        detail = json.loads(exc.read() or b"{}").get("error", {}).get("message", exc.reason)
        print(f"ask-gpt: solwebd: {detail}", file=sys.stderr)
        return 1

    files = [str(path) for path in reply.get("files") or []]
    failures = list(reply.get("failures") or [])
    unavailable = list(reply.get("unavailable") or [])
    invalid = list(reply.get("invalid") or [])
    planned = int(reply.get("planned") or 0)
    reused = int(reply.get("reused") or 0)
    progress.reused = reused
    progress.finish(files, failures, unavailable, invalid)
    log_failures = [f"{item.get('file')}: {item.get('error')}" for item in failures]
    log_failures += [f"{item.get('file')}: {item.get('reason')}"
                     for item in unavailable + invalid]
    rl.close_download(files, log_failures)
    rl.print_log_lines()

    result = {
        "conversation_id": conversation_id, "files": files,
        "failures": failures, "unavailable": unavailable, "invalid": invalid,
        "planned": planned, "reused": reused,
    }
    if args.json:
        print(json.dumps(result, indent=2))
    else:
        for path in files:
            print(f"saved: {path}")
    return 0 if files or not (failures or unavailable or invalid) else 1


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        prog="ask-gpt",
        description="Send a prompt (and optional files) to ChatGPT and print the reply.",
    )
    ap.add_argument("prompt", nargs="*", help="the prompt; omit to read stdin")
    ap.add_argument("--effort", choices=EFFORTS, help="reasoning effort for this turn")
    ap.add_argument(
        "-a", "--attach", action="append", default=[], metavar="FILE",
        help="attach a file, image or document (repeatable)",
    )
    ap.add_argument(
        "--zip-repo", action="store_true",
        help="attach a zip of the enclosing repo's default-branch checkout "
             "(no local edits), excluding gitignored files",
    )
    ap.add_argument(
        "--zip-worktree", nargs="?", const=".", default=None, metavar="PATH",
        help="attach a zip of PATH's working tree (default: cwd) as it stands, "
             "excluding gitignored files",
    )
    ap.add_argument("--out", type=Path, default=Path("ask-gpt"),
                    help="directory for generated files and images "
                         "(default ./ask-gpt, relative to the caller's cwd)")
    ap.add_argument("--json", action="store_true", help="emit a JSON result object")
    ap.add_argument("--timeout", type=float, default=900.0,
                    help="seconds to wait for the reply")
    ap.add_argument("--mode", help=argparse.SUPPRESS)
    ap.add_argument(
        "--visible", action="store_true",
        help="human invocation only; agents MUST NOT choose this; show the browser window",
    )
    ap.add_argument("--reseed", action="store_true",
                    help="re-copy the browser profile from the live one")
    ap.add_argument("-r", "--resume", metavar="ID",
                    help="continue an existing conversation (uuid or chatgpt.com/c/<id> url)")
    ap.add_argument("--live", action="store_true",
                    help="stream thinking/answer/upload/download progress as it happens "
                         "(human display only; not pipe-safe — use --json for scripts)")
    ap.add_argument("--download-attachments", metavar="ID",
                    help="recover every image/file from an existing conversation "
                         "(read-only; no prompt sent)")
    ap.add_argument("--list", nargs="?", type=int, const=10, default=None, metavar="N",
                    help="print the N most recent conversation threads (default 10), "
                         "one per line as id<TAB>title")
    ap.add_argument("--search", metavar="PATTERN",
                    help="print conversation threads whose title matches PATTERN "
                         "(regex, falling back to a shell glob)")
    ap.add_argument("--list-all", action="store_true",
                    help="write registered conversations to ./ask-gpt/conversations.jsonl")
    ap.add_argument("--bootstrap-registry", action="store_true",
                    help="import the current account sidebar once into the local registry")
    args = ap.parse_args(argv)
    if args.live and args.json:
        ap.error("--live and --json are mutually exclusive")
    if args.timeout <= 0:
        ap.error("--timeout must be greater than zero")
    args.mode = "show" if args.visible else "virtual"

    listing = args.list is not None or args.search is not None or args.list_all
    bootstrapping = args.bootstrap_registry
    downloading = bool(args.download_attachments)
    if (listing or bootstrapping) and (args.resume or downloading or args.prompt):
        ap.error("listing/bootstrap take no prompt and are mutually exclusive with --resume and --download-attachments")
    if args.list is not None and args.list <= 0:
        ap.error("--list N must be greater than zero")
    if args.list is not None and args.search is not None:
        ap.error("--list and --search are mutually exclusive")
    if args.list_all and (args.list is not None or args.search is not None):
        ap.error("--list-all is mutually exclusive with --list/--search")
    if bootstrapping and listing:
        ap.error("--bootstrap-registry is mutually exclusive with listing options")
    if downloading and args.resume:
        ap.error("--download-attachments and --resume are mutually exclusive")
    if downloading and args.prompt:
        ap.error("--download-attachments takes no prompt")
    if listing:
        return _run_listing(args)
    if bootstrapping:
        return _run_bootstrap(args)
    if downloading:
        return _run_download(args)

    prompt = " ".join(args.prompt).strip() or sys.stdin.read().strip()
    if not prompt:
        ap.error("no prompt given")

    resume_id = None
    expected_account = None
    if args.resume:
        try:
            resume_id = chat.parse_resume_id(args.resume)
            owned = registry.lookup(resume_id)
            if owned is None:
                raise registry.RegistryError(f"conversation {resume_id} is not in the local registry")
            expected_account = owned.account
        except (ChatError, registry.RegistryError) as exc:
            print(f"ask-gpt: {exc}", file=sys.stderr)
            return 1

    stamp = dt.datetime.now().strftime("%Y-%m-%d-%H%M%S")
    attach = [str(Path(p).expanduser().resolve()) for p in args.attach]
    out = str(Path(args.out).expanduser().resolve())
    try:
        if args.zip_repo or args.zip_worktree is not None:
            Path(out).mkdir(parents=True, exist_ok=True)
        if args.zip_repo:
            attach.append(str(zip_main_checkout(Path.cwd(), Path(out), stamp)))
        if args.zip_worktree is not None:
            attach.append(str(zip_worktree(Path(args.zip_worktree), Path(out), stamp)))
    except ZipError as exc:
        print(f"ask-gpt: {exc}", file=sys.stderr)
        return 1

    rl = RunLog(prompt, args.effort, Path.cwd(), stamp, resume_id, args.live)
    try:
        base = daemon_endpoint()
        if base:
            try:
                # The daemon owns the browser; its cwd is not ours, hence absolute paths.
                payload = {"prompt": prompt, "effort": args.effort, "attach": attach,
                          "out": out, "stamp": stamp, "timeout": args.timeout}
                if resume_id:
                    payload["conversation"] = resume_id
                terminal = _ask_stream(base, TOKEN_FILE.read_text().strip(), payload,
                                       timeout=args.timeout + 30, on_event=rl.on_event)
                if terminal.get("type") == "error":
                    raise ChatError(terminal.get("detail") or "solwebd: turn failed")
                reply = terminal.get("reply") or {}
                text, thinking = reply.get("text", ""), reply.get("thinking", "")
                images, files = reply.get("images", []), reply.get("files", [])
                url = reply.get("url", "")
                conversation_id = reply.get("conversation_id", "")
            except urllib.error.HTTPError:
                raise  # a real error response, not a broken connection
            except (OSError, http.client.HTTPException, ValueError) as exc:
                # the reply never arrived: connection reset, a body truncated after the
                # headers, or unparsable JSON. Its serve loop survives a handler
                # exception, so a still-listening daemon is still holding profile.lock
                # and the direct path would block on it for 600s.
                if daemon_endpoint():
                    raise DaemonError(
                        f"the request failed ({exc}) but solwebd is still listening on "
                        f"{base} and holds the browser profile; restart it"
                    ) from None
                print(f"ask-gpt: solwebd died mid-request ({exc}); "
                      f"retrying with a direct browser session", file=sys.stderr)
                base = None
                rl.reset_accumulation()
        if base is None:
            reply = ask(
                prompt,
                effort=args.effort,
                attachments=[Path(p) for p in attach],
                out_dir=Path(out),
                stamp=stamp,
                mode=args.mode,
                reseed=args.reseed,
                timeout=args.timeout,
                resume=resume_id, expected_account=expected_account,
                on_event=rl.on_event,
                on_resumed=(lambda m: rl.hydrate(chat.hydrate_dom(m))) if resume_id else None,
            )
            text, thinking = reply.text, reply.thinking
            images = [str(p) for p in reply.images]
            files = [str(p) for p in reply.files]
            url, conversation_id = reply.url, reply.conversation_id
    except (ChatError, MarionetteError, DaemonError, registry.RegistryError) as exc:
        rl.close(ok=False)
        rl.print_log_lines()
        print(f"ask-gpt: {exc}", file=sys.stderr)
        return 1
    except urllib.error.HTTPError as exc:
        rl.close(ok=False)
        rl.print_log_lines()
        detail = json.loads(exc.read() or b"{}").get("error", {}).get("message", exc.reason)
        print(f"ask-gpt: solwebd: {detail}", file=sys.stderr)
        return 1

    rl.close(ok=True)
    rl.print_log_lines()

    if args.json:
        print(json.dumps({
            "prompt": prompt,
            "text": text,
            "thinking": thinking,
            "images": images,
            "files": files,
            "conversation": url,
            "conversation_id": conversation_id,
            "resume": f"ask-gpt --resume {conversation_id}" if conversation_id else None,
            "log": f"./ask-gpt/{conversation_id}.log" if rl.duplicate_path else None,
            "log_canonical": str(rl.canonical_path) if rl.canonical_path else None,
        }, indent=2))
        return 0

    print(text)
    for path in [*images, *files]:
        print(f"\nsaved: {path}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
