#!/usr/bin/env python3
"""install-trw.py — Self-contained TRW Framework installer.

Generated by: python scripts/build_installer.py
Version: {{VERSION}}

Usage:
    python3 install-trw.py                    # Interactive install
    python3 install-trw.py /path/to           # Install in specified directory
    python3 install-trw.py --upgrade          # Upgrade existing installation
    python3 install-trw.py --script --ai      # Non-interactive with AI extras
    python3 install-trw.py --name myproj      # Set project name
    curl ... | python3 -                      # Headless mode (auto-detected)

Re-run with a newer version of this script to upgrade.
"""

from __future__ import annotations

import argparse
import base64
import random
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from pathlib import Path

# ── Configuration (substituted by build_installer.py) ────────────────
TRW_VERSION = "{{VERSION}}"
WHEEL_FILENAME = "{{WHEEL_FILENAME}}"
MEMORY_WHEEL_FILENAME = "{{MEMORY_WHEEL_FILENAME}}"
MIN_PYTHON_VERSION = (3, 10)
DOCS_BASE = "https://trwframework.com/docs"

# ── ANSI colors ──────────────────────────────────────────────────────
_USE_COLOR = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()

RED = "\033[0;31m" if _USE_COLOR else ""
GREEN = "\033[0;32m" if _USE_COLOR else ""
YELLOW = "\033[0;33m" if _USE_COLOR else ""
BLUE = "\033[0;34m" if _USE_COLOR else ""
CYAN = "\033[0;36m" if _USE_COLOR else ""
BOLD = "\033[1m" if _USE_COLOR else ""
DIM = "\033[2m" if _USE_COLOR else ""
NC = "\033[0m" if _USE_COLOR else ""

_ANSI_RE = re.compile(r"\033\[[0-9;]*m")

# Tips shown randomly after install — a teaching moment while the user is engaged.
_TIPS = [
    "Use trw_recall('topic') to search prior session learnings",
    "Every trw_learn() call compounds across all future sessions",
    "Call trw_session_start() at the beginning of every session",
    "Use Agent Teams for multi-file implementations \u2014 focused context wins",
    "Run /trw-project-health to check your installation's vitals",
    "Use trw_checkpoint() before large operations to save progress",
    "Run trw_deliver() at session end to persist your discoveries",
    "Use /trw-audit PRD-XXX for adversarial spec-vs-code verification",
    "Export learnings anytime: trw-mcp export . learnings --format csv",
    "Your learnings auto-decay \u2014 high-impact ones persist longest",
    "Set CLAUDE_CODE_SUBAGENT_MODEL=sonnet for faster subagents",
    "TRW hooks run automatically \u2014 no setup needed after install",
]


def _visible_len(text: str) -> int:
    """Return the display width of *text* (strip ANSI escapes)."""
    return len(_ANSI_RE.sub("", text))


# ── Box drawing ──────────────────────────────────────────────────────


def draw_box(
    lines: list[str],
    color: str = CYAN,
    width: int = 52,
    padding: int = 2,
) -> None:
    """Draw a Unicode box with proper alignment.

    *width* is the total box width including border characters.
    *padding* is left margin inside the box.
    """
    inner = width - 2
    hbar = "\u2500" * inner
    tl, tr, bl, br, vb = "\u256d", "\u256e", "\u2570", "\u256f", "\u2502"
    print(f"{color}{tl}{hbar}{tr}{NC}")
    for line in lines:
        vis = _visible_len(line)
        gap = " " * max(0, inner - padding - vis)
        margin = " " * padding
        print(f"{color}{vb}{NC}{margin}{line}{gap}{color}{vb}{NC}")
    print(f"{color}{bl}{hbar}{br}{NC}")


def draw_divider(label: str = "", color: str = DIM) -> None:
    """Print a subtle divider with optional centered label."""
    if label:
        print(f"\n  {color}\u2500\u2500 {label} \u2500\u2500{NC}")
    else:
        print()


# ── Output helpers ───────────────────────────────────────────────────


class UI:
    """Output helper that adapts to interactive/quiet/script modes."""

    def __init__(self, interactive: bool = False, quiet: bool = False) -> None:
        self.interactive = interactive
        self.quiet = quiet
        self._spinner: _Spinner | None = None

    def info(self, msg: str) -> None:
        if not self.quiet:
            print(f"{GREEN}[TRW]{NC} {msg}")

    def warn(self, msg: str) -> None:
        print(f"{YELLOW}[TRW]{NC} {msg}")

    def error(self, msg: str) -> None:
        print(f"{RED}[TRW]{NC} {msg}", file=sys.stderr)

    def blank(self) -> None:
        if not self.quiet:
            print()

    def step_header(self, step: int, total: int, label: str) -> None:
        if self.interactive:
            print(f"\n  {BOLD}Step {step}/{total}{NC}  {label}")
        else:
            self.info(label)

    def step_ok(self, msg: str) -> None:
        if self.interactive:
            print(f"            {GREEN}\u2713{NC} {msg}")
        elif not self.quiet:
            self.info(msg)

    def step_warn(self, msg: str) -> None:
        if self.interactive:
            print(f"            {YELLOW}!{NC} {msg}")
        else:
            self.warn(msg)

    def step_fail(self, msg: str) -> None:
        if self.interactive:
            print(f"            {RED}\u2717{NC} {msg}")
        else:
            self.error(msg)

    def hint(self, msg: str) -> None:
        """Show a dim hint line (interactive only)."""
        if self.interactive:
            print(f"    {DIM}{msg}{NC}")

    def doc_link(self, anchor: str) -> None:
        """Show a docs link hint."""
        self.hint(f"Learn more: {DOCS_BASE}/{anchor}")

    # ── Spinner ──────────────────────────────────────────────────────

    def start_spinner(self, msg: str) -> None:
        if not self.interactive:
            return
        self._spinner = _Spinner(msg)
        self._spinner.start()

    def update_spinner(self, msg: str) -> None:
        if self._spinner is not None:
            self._spinner.message = msg

    def stop_spinner(self, success: bool, ok_msg: str, fail_msg: str = "Failed") -> None:
        if self._spinner is not None:
            self._spinner.stop()
            self._spinner = None
            sys.stdout.write("\r\033[K")
            sys.stdout.flush()
        if success:
            self.step_ok(ok_msg)
        else:
            self.step_fail(fail_msg)


class _Spinner:
    """Braille-character spinner in a daemon thread."""

    _FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"

    def __init__(self, message: str) -> None:
        self.message = message
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None

    def start(self) -> None:
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=2.0)

    def _run(self) -> None:
        idx = 0
        while not self._stop.is_set():
            frame = self._FRAMES[idx % len(self._FRAMES)]
            sys.stdout.write(f"\r\033[K            {frame} {self.message}")
            sys.stdout.flush()
            idx += 1
            self._stop.wait(0.1)


# ── Input helpers ────────────────────────────────────────────────────


def _open_tty():  # noqa: ANN202 — returns TextIO | None
    """Open /dev/tty for reading, or None if unavailable."""
    try:
        return open("/dev/tty", "r")  # noqa: SIM115
    except OSError:
        return None


def prompt_yes_no(question: str, default: str = "n") -> bool:
    """Prompt for yes/no. Returns True for yes."""
    hint = "[Y/n]" if default == "y" else "[y/N]"
    tty = _open_tty()
    if tty is None:
        return default == "y"
    try:
        sys.stdout.write(f"    {question} {hint} ")
        sys.stdout.flush()
        answer = tty.readline().strip() or default
    finally:
        tty.close()
    return answer.lower().startswith("y")


def prompt_input(prompt_text: str, default: str = "") -> str:
    """Prompt for text input."""
    display = f"    {prompt_text}"
    if default:
        display += f" [{default}]"
    display += " "
    tty = _open_tty()
    if tty is None:
        return default
    try:
        sys.stdout.write(display)
        sys.stdout.flush()
        answer = tty.readline().strip() or default
    finally:
        tty.close()
    return answer


def prompt_choice(label: str, options: list[str], default: int = 0) -> int:
    """Prompt user to select from numbered options. Returns index."""
    print(f"    {label}")
    for i, opt in enumerate(options):
        marker = f"{BOLD}>{NC}" if i == default else " "
        print(f"    {marker} {i + 1}. {opt}")
    tty = _open_tty()
    if tty is None:
        return default
    try:
        sys.stdout.write(f"    Choice [{default + 1}]: ")
        sys.stdout.flush()
        raw = tty.readline().strip()
    finally:
        tty.close()
    if not raw:
        return default
    try:
        choice = int(raw) - 1
        if 0 <= choice < len(options):
            return choice
    except ValueError:
        pass
    return default


# ── Prior installation detection ─────────────────────────────────────


def _load_prior_config(target_dir: Path) -> dict[str, object]:
    """Load prior installation config from .trw/config.yaml if it exists.

    Returns a dict with keys that were previously configured, or empty dict
    for first-time installs. Parses YAML manually — no pyyaml dependency.
    """
    config_path = target_dir / ".trw" / "config.yaml"
    if not config_path.is_file():
        return {}

    prior: dict[str, object] = {}
    try:
        for line in config_path.read_text(encoding="utf-8").splitlines():
            line = line.strip()
            if line.startswith("#") or not line or ":" not in line:
                continue
            key, _, value = line.partition(":")
            key = key.strip()
            value = value.strip().strip('"').strip("'")
            if key == "installation_id":
                prior["project_name"] = value
            elif key == "platform_api_key":
                prior["api_key"] = value
            elif key == "platform_telemetry_enabled":
                prior["telemetry"] = value.lower() == "true"
            elif key == "embeddings_enabled":
                prior["embeddings"] = value.lower() == "true"
            elif key == "sqlite_vec_enabled":
                prior["sqlite_vec"] = value.lower() == "true"
    except (OSError, UnicodeDecodeError):
        pass
    return prior


def _detect_installed_extras(python: str) -> dict[str, bool]:
    """Detect which optional extras are already installed.

    Uses a short timeout (10s) because import checks should resolve quickly.
    Longer hangs indicate missing packages or environment issues (e.g. SDK
    attempting network calls during import on system Python without a venv).
    """
    extras: dict[str, bool] = {}
    extras["ai"] = _run_quiet([python, "-c", "import anthropic"], timeout=10)
    extras["sqlite_vec"] = _run_quiet([python, "-c", "import sqlite_vec"], timeout=10)
    return extras


# ── Validation / sanitization ────────────────────────────────────────


def sanitize_project_name(name: str) -> str:
    """Lowercase, strip non-alphanumeric, collapse hyphens, truncate to 64."""
    result = name.lower()
    result = re.sub(r"[^a-z0-9]", "-", result)
    result = re.sub(r"-{2,}", "-", result)
    return result.strip("-")[:64].rstrip("-")


def validate_api_key(key: str) -> bool:
    """Check *key* matches ``trw_`` or ``trw_dk_`` prefix, alphanumeric+underscore, max 128."""
    return len(key) <= 128 and bool(re.match(r"^trw_(dk_)?[a-zA-Z0-9_]+$", key))


# ── Python discovery ─────────────────────────────────────────────────


def check_python_version(ui: UI) -> str:
    """Verify Python meets minimum version. Returns executable path."""
    major, minor = sys.version_info[:2]
    if (major, minor) < MIN_PYTHON_VERSION:
        ui.error(f"Python {major}.{minor} found but >= {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]} required")
        sys.exit(1)
    return sys.executable


# ── Wheel extraction ─────────────────────────────────────────────────


def _extract_wheel_data(marker: str) -> bytes:
    """Read base64 data following *marker* from this script file.

    Wheels are embedded as comment lines after marker comments at the
    end of the file (e.g. MEMORY_WHEEL_DATA, WHEEL_DATA). Each marker
    is followed by comment-prefixed base64 lines.
    """
    script_path = Path(sys.argv[0]).resolve() if sys.argv[0] != "-" else Path(__file__).resolve()
    text = script_path.read_text(encoding="utf-8", errors="replace")

    collecting = False
    b64_chunks: list[str] = []

    for line in text.splitlines():
        stripped = line.strip()
        if stripped == f"# {marker}":
            collecting = True
            continue
        if collecting:
            if stripped.startswith("# __") and stripped.endswith("__"):
                break
            if stripped.startswith("# "):
                b64_chunks.append(stripped[2:])
            elif stripped == "#":
                continue
            else:
                break

    if not b64_chunks:
        raise RuntimeError(f"No embedded data found for marker {marker}")

    return base64.b64decode("".join(b64_chunks))


def extract_wheels(tmpdir: Path) -> tuple[Path, Path]:
    """Extract all embedded wheels into *tmpdir*.

    Returns (memory_wheel_path, mcp_wheel_path).
    """
    memory_path = tmpdir / MEMORY_WHEEL_FILENAME
    memory_path.write_bytes(_extract_wheel_data("__MEMORY_WHEEL_DATA__"))

    mcp_path = tmpdir / WHEEL_FILENAME
    mcp_path.write_bytes(_extract_wheel_data("__WHEEL_DATA__"))

    return memory_path, mcp_path


# ── pip install with fallback ────────────────────────────────────────


def _run_quiet(cmd: list[str], timeout: int = 120) -> bool:
    """Run a command silently, return True if exit code == 0.

    *timeout* (seconds, default 120) prevents hangs when pip stalls on
    PEP 668 externally-managed system Pythons without a venv.

    ``KeyboardInterrupt`` is intentionally not caught — it propagates to
    the caller so the user can abort the entire installer with Ctrl-C.
    """
    try:
        return (
            subprocess.run(
                cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=timeout,
            ).returncode
            == 0
        )
    except (FileNotFoundError, subprocess.TimeoutExpired):
        return False


def pip_install(python: str, package: str, label: str, ui: UI) -> bool:
    """Try pip install with escalating fallbacks.

    Tries: normal -> --user -> --break-system-packages.
    """
    base = [python, "-m", "pip", "install", "--upgrade", "--quiet"]

    if _run_quiet(base + [package]):
        return True

    if _run_quiet(base + ["--user", package]):
        ui.step_warn(f"Installed {label} with --user (PEP 668 managed environment)")
        return True

    if _run_quiet(base + ["--break-system-packages", package]):
        ui.step_warn(f"Installed {label} with --break-system-packages")
        return True

    return False


# ── Run command with live progress ───────────────────────────────────


def run_with_progress(ui: UI, fallback_msg: str, cmd: list[str], timeout: int = 180) -> bool:
    """Run *cmd* showing live progress. Returns True on success.

    A watchdog timer kills the subprocess if it hasn't exited after *timeout*
    seconds (default 180).  This prevents the installer from hanging
    indefinitely when the child stalls (e.g. during CLAUDE.md sync).
    """
    try:
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
    except FileNotFoundError:
        return False

    assert proc.stdout is not None

    killed_by_watchdog = False

    def _watchdog_kill() -> None:
        nonlocal killed_by_watchdog
        killed_by_watchdog = True
        proc.kill()

    watchdog = threading.Timer(timeout, _watchdog_kill)
    watchdog.daemon = True
    watchdog.start()

    try:
        if ui.interactive:
            ui.start_spinner(fallback_msg)
            file_count = 0
            for line in proc.stdout:
                line = line.strip()
                # Phase markers update the status message (e.g., "Phase: Syncing CLAUDE.md...")
                if line.startswith("Phase:"):
                    phase_label = line.partition(":")[2].strip()
                    ui.update_spinner(f"{fallback_msg} ({file_count} files) {phase_label}")
                elif re.match(r"  *(Updated|Created|Preserved|Skipped|synced|WARNING|Error):? ", line):
                    file_count += 1
                    short = re.sub(r"^  *(Updated|Created|Created \(new\)|Preserved|Skipped|Error): ", "", line)[:60]
                    ui.update_spinner(f"{fallback_msg} ({file_count} files) {DIM}{short}{NC}")
        else:
            for line in proc.stdout:
                if not ui.quiet:
                    print(f"{GREEN}[TRW]{NC}   {line.rstrip()}")

        proc.wait(timeout=10)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.wait()
    finally:
        watchdog.cancel()

    if killed_by_watchdog:
        ui.step_warn(f"{fallback_msg} timed out after {timeout}s")

    return proc.returncode == 0


# ── Config file update ───────────────────────────────────────────────


def update_config(
    config_path: Path,
    project_name: str,
    api_key: str,
    telemetry_enabled: bool,
    *,
    embeddings_enabled: bool | None = None,
    sqlite_vec_enabled: bool | None = None,
) -> bool:
    """Update .trw/config.yaml with installation settings.

    Optional *embeddings_enabled* and *sqlite_vec_enabled* persist feature
    flags so reinstalls skip the "Optional Features" prompts.
    """
    if not config_path.is_file():
        return False

    lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
    platform_url = "https://api.trwframework.com"

    updated: set[str] = set()
    out: list[str] = []

    for line in lines:
        s = line.lstrip()

        if s.startswith("installation_id:"):
            out.append(f"installation_id: {project_name}\n")
            updated.add("installation_id")
            continue
        if s.startswith("platform_api_key:"):
            out.append(f'platform_api_key: "{api_key}"\n' if api_key else line)
            updated.add("platform_api_key")
            continue
        if s.startswith("platform_telemetry_enabled:"):
            val = "true" if telemetry_enabled else "false"
            out.append(f"platform_telemetry_enabled: {val}\n")
            updated.add("platform_telemetry_enabled")
            continue
        if s.startswith("embeddings_enabled:") and embeddings_enabled is not None:
            out.append(f"embeddings_enabled: {'true' if embeddings_enabled else 'false'}\n")
            updated.add("embeddings_enabled")
            continue
        if s.startswith("sqlite_vec_enabled:") and sqlite_vec_enabled is not None:
            out.append(f"sqlite_vec_enabled: {'true' if sqlite_vec_enabled else 'false'}\n")
            updated.add("sqlite_vec_enabled")
            continue
        if s.startswith("platform_urls:"):
            updated.add("platform_urls")
            continue
        if s.startswith("- ") and "platform_urls" in updated and "platform_urls_done" not in updated:
            continue
        if "platform_urls" in updated and "platform_urls_done" not in updated and not s.startswith("- "):
            updated.add("platform_urls_done")

        # Skip commented-out platform config hints
        skip_prefixes = (
            "# platform_urls:",
            '#   - "https://api.',
            "# platform_api_key:",
            "# platform_telemetry_enabled:",
            "# Platform telemetry",
        )
        if any(s.startswith(p) for p in skip_prefixes):
            continue

        out.append(line)

    # Append missing keys
    if "installation_id" not in updated:
        out.append(f"installation_id: {project_name}\n")
    if api_key and "platform_api_key" not in updated:
        out.append(f'platform_api_key: "{api_key}"\n')
    if telemetry_enabled and "platform_telemetry_enabled" not in updated:
        out.append("platform_telemetry_enabled: true\n")
    if api_key or telemetry_enabled:
        out.append("platform_urls:\n")
        out.append(f'  - "{platform_url}"\n')
    if embeddings_enabled and "embeddings_enabled" not in updated:
        out.append("embeddings_enabled: true\n")
    if sqlite_vec_enabled and "sqlite_vec_enabled" not in updated:
        out.append("sqlite_vec_enabled: true\n")

    config_path.write_text("".join(out), encoding="utf-8")
    return True


# ── Health check ─────────────────────────────────────────────────


def _check_backend_health(url: str, timeout: float = 5.0) -> dict[str, object]:
    """Probe a backend health endpoint. Returns status dict.

    Uses only stdlib — no external dependencies.
    """
    import json
    import urllib.request
    import urllib.error

    health_url = f"{url.rstrip('/')}/v1/health"
    try:
        req = urllib.request.Request(health_url, method="GET")
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return {"url": url, "reachable": True, "status": data.get("status", "ok")}
    except urllib.error.HTTPError as exc:
        return {"url": url, "reachable": True, "status": f"http-{exc.code}"}
    except Exception:
        return {"url": url, "reachable": False, "status": "unreachable"}


def _check_all_backends(
    target_dir: Path,
    prior_config: dict[str, object],
) -> list[dict[str, object]]:
    """Check connectivity to all configured platform backends.

    Reads platform_urls from config.yaml (simple line parsing) and probes
    each one. Also checks localhost:8000 if a docker-compose.yml exists
    in the project (indicating a local backend may be running).
    """
    results: list[dict[str, object]] = []
    urls: list[str] = []

    # Read platform_urls from config.yaml
    config_path = target_dir / ".trw" / "config.yaml"
    if config_path.is_file():
        try:
            in_urls = False
            for line in config_path.read_text(encoding="utf-8").splitlines():
                stripped = line.strip()
                if stripped.startswith("platform_urls:"):
                    in_urls = True
                    continue
                if in_urls:
                    if stripped.startswith("- "):
                        url = stripped[2:].strip().strip('"').strip("'")
                        if url:
                            urls.append(url)
                    elif stripped and not stripped.startswith("#"):
                        in_urls = False
        except OSError:
            pass

    # Check for local Docker backend
    if (target_dir / "docker-compose.yml").is_file() or (target_dir / "docker-compose.yaml").is_file():
        local_url = "http://localhost:8000"
        if local_url not in urls:
            urls.insert(0, local_url)

    # Probe all backends in parallel for faster results
    if not urls:
        return results
    if len(urls) == 1:
        return [_check_backend_health(urls[0])]

    from concurrent.futures import ThreadPoolExecutor, as_completed

    with ThreadPoolExecutor(max_workers=min(len(urls), 4)) as pool:
        futures = {pool.submit(_check_backend_health, url): url for url in urls}
        for future in as_completed(futures, timeout=10):
            try:
                results.append(future.result())
            except Exception:
                results.append({"url": futures[future], "reachable": False, "status": "error"})

    # Preserve original URL ordering
    url_order = {url: i for i, url in enumerate(urls)}
    results.sort(key=lambda r: url_order.get(str(r["url"]), 999))
    return results


# ── MCP server restart ───────────────────────────────────────────


def _is_process_alive(pid: int) -> bool:
    """Check if a process with the given PID is running. Cross-platform.

    - Unix/macOS: ``os.kill(pid, 0)`` (signal 0 = existence check).
    - Windows: ``os.kill(pid, 0)`` raises SystemError on Windows
      (CPython issue #14480). Use ``kernel32.OpenProcess`` instead.
    """
    import os

    if sys.platform == "win32":
        try:
            import ctypes

            SYNCHRONIZE = 0x00100000
            handle = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, 0, pid)  # type: ignore[union-attr]
            if handle == 0:
                return False
            ctypes.windll.kernel32.CloseHandle(handle)  # type: ignore[union-attr]
            return True
        except (OSError, AttributeError):
            return False
    else:
        try:
            os.kill(pid, 0)
            return True
        except (ProcessLookupError, OSError):
            return False


def _terminate_process(pid: int) -> bool:
    """Terminate a process by PID. Cross-platform.

    - Unix/macOS: sends SIGTERM for graceful shutdown.
    - Windows: ``os.kill(pid, SIGTERM)`` calls ``TerminateProcess``
      (forceful but reliable — no graceful path without
      ``CREATE_NEW_PROCESS_GROUP`` at spawn time). Falls back to
      ``taskkill /PID`` if that fails.

    Returns True if the process was signalled successfully.
    """
    import os
    import signal

    try:
        if sys.platform == "win32":
            # On Windows, os.kill(pid, SIGTERM) calls TerminateProcess —
            # always forceful. No graceful alternative without spawn-time
            # CREATE_NEW_PROCESS_GROUP flag (see uvicorn PR #1909).
            try:
                os.kill(pid, signal.SIGTERM)
                return True
            except OSError:
                # Fallback: taskkill handles more edge cases on Windows
                return _run_quiet(["taskkill", "/PID", str(pid), "/F"])
        else:
            os.kill(pid, signal.SIGTERM)
            return True
    except (ProcessLookupError, PermissionError, OSError):
        return False


def _restart_mcp_servers(target_dir: Path, ui: UI) -> None:
    """Restart MCP server(s) after install/upgrade.

    Cross-platform approach:
      - HTTP mode: kill the background process via PID file; it will be
        auto-started on the next tool call via ``ensure_http_server()``.
      - All modes: write a version sentinel so the server detects the
        upgrade on the next tool call and advises ``/mcp``.
    """
    import json

    trw_dir = target_dir / ".trw"
    pid_path = trw_dir / "mcp-server.pid"

    # HTTP mode: kill the background server process
    if pid_path.is_file():
        try:
            pid = int(pid_path.read_text(encoding="utf-8").strip())
            if _is_process_alive(pid):
                if _terminate_process(pid):
                    ui.step_ok("MCP server stopped (will auto-start on next use)")
                pid_path.unlink(missing_ok=True)
            else:
                # Already dead — clean up stale PID
                pid_path.unlink(missing_ok=True)
        except (ValueError, OSError):
            pid_path.unlink(missing_ok=True)

    # Write version sentinel for stdio instances to detect upgrade
    sentinel_path = trw_dir / "installed-version.json"
    try:
        sentinel_path.write_text(
            json.dumps({"version": TRW_VERSION, "timestamp": _iso_now()}),
            encoding="utf-8",
        )
    except OSError:
        pass  # Best-effort


def _iso_now() -> str:
    """Return current UTC timestamp in ISO 8601 format."""
    from datetime import datetime, timezone

    return datetime.now(timezone.utc).isoformat()


# ── Banners ──────────────────────────────────────────────────────────


def show_banner(ui: UI) -> None:
    """Display the installer header banner."""
    if ui.quiet:
        return
    if ui.interactive:
        print()
        draw_box(
            [
                f"{BOLD}TRW Framework Installer{NC}  v{TRW_VERSION}",
                "",
                f"{DIM}The Real Work \u2014 Accumulated Intelligence{NC}",
            ],
            color=CYAN,
        )
    else:
        print(f"{BOLD}TRW Framework Installer v{TRW_VERSION}{NC}")
        print()


def show_success_banner(
    ui: UI,
    platform_status: str,
    features: list[str],
    is_reinstall: bool = False,
    backend_results: list[dict[str, object]] | None = None,
) -> None:
    """Display the post-install success banner with summary.

    Content adapts to fresh vs. reinstall scenarios, shows real backend
    connectivity, and includes a random tip to teach users capabilities.
    """
    if ui.quiet:
        ui.info(f"TRW Framework v{TRW_VERSION} installed.")
        return

    if ui.interactive:
        print()
        draw_box(
            [f"{GREEN}{BOLD}\u2713 TRW Framework v{TRW_VERSION} \u2014 ready{NC}"],
            color=GREEN,
        )
        print()

        # Backend connectivity (real health check results)
        if backend_results:
            for br in backend_results:
                url = str(br["url"])
                # Friendly label for known URLs
                if "localhost" in url or "127.0.0.1" in url:
                    label = f"Local backend ({url})"
                elif "trwframework.com" in url:
                    label = "trwframework.com"
                else:
                    label = url
                if br["reachable"]:
                    print(f"  {GREEN}\u2022{NC} {label}")
                else:
                    print(f"  {DIM}\u2022 {label} (unreachable){NC}")
        elif platform_status == "connected":
            print(f"  {DIM}\u2022 API key configured (backend not verified){NC}")
        elif platform_status == "telemetry":
            print(f"  {BLUE}\u2022{NC} Anonymous telemetry enabled")
        else:
            print(f"  {DIM}\u2022 Offline mode (re-run installer to connect){NC}")

        if features:
            feat_str = ", ".join(features)
            print(f"  {DIM}Extras: {feat_str}{NC}")
        print()

        # Dynamic next-steps based on install type
        if is_reinstall:
            print(f"  {BOLD}After updating:{NC}")
            print(f"    {CYAN}\u2022{NC} Active MCP servers have been signaled to restart")
            print(f"    {CYAN}\u2022{NC} Run {BOLD}/mcp{NC} in Claude Code if tools aren't loading")
            print(f"    {CYAN}\u2022{NC} Your prior learnings carry forward automatically")
        else:
            print(f"  {BOLD}Get started:{NC}")
            print(f"    {CYAN}1.{NC} Open your project in Claude Code:  {BOLD}claude{NC}")
            print(f"    {CYAN}2.{NC} TRW tools load automatically \u2014 just start working")
            print(f"    {CYAN}3.{NC} Call {BOLD}trw_session_start(){NC} for context from prior sessions")

        # Random tip
        print()
        tip = random.choice(_TIPS)  # noqa: S311 — not security-sensitive
        print(f"  {DIM}\u25b8 Tip: {tip}{NC}")

        print()
        print(f"  {DIM}Upgrade anytime: python3 install-trw.py --upgrade{NC}")
        print(f"  {DIM}Full docs: {DOCS_BASE}/integration{NC}")
    else:
        print()
        print(f"{GREEN}{BOLD}TRW Framework v{TRW_VERSION} \u2014 ready{NC}")
        print()
        if backend_results:
            for br in backend_results:
                status = "ok" if br["reachable"] else "unreachable"
                ui.info(f"Backend {br['url']}: {status}")
        if is_reinstall:
            ui.info("Updated. MCP servers signaled to restart.")
        else:
            ui.info("Next: open your project in Claude Code \u2014 TRW tools load automatically.")
        ui.info(f"Docs: {DOCS_BASE}/integration")


# ── IDE detection and selection ──────────────────────────────────────


def _detect_installed_clis() -> list[str]:
    """Detect which AI coding CLI binaries are on PATH."""
    detected = []
    if shutil.which("claude"):
        detected.append("claude-code")
    if shutil.which("opencode"):
        detected.append("opencode")
    if shutil.which("codex"):
        detected.append("codex")
    return detected


def _detect_project_ides(project_dir: str) -> list[str]:
    """Detect which IDE configs exist in the project."""
    import os as _os

    detected = []
    if _os.path.isdir(_os.path.join(project_dir, ".claude")):
        detected.append("claude-code")
    if _os.path.isdir(_os.path.join(project_dir, ".opencode")) or _os.path.isfile(
        _os.path.join(project_dir, "opencode.json")
    ):
        detected.append("opencode")
    if _os.path.isdir(_os.path.join(project_dir, ".codex")) or _os.path.isfile(
        _os.path.join(project_dir, ".codex", "config.toml")
    ):
        detected.append("codex")
    return detected


def _prompt_ide_selection(detected_clis: list[str], detected_ides: list[str]) -> str | None:
    """Prompt user to select which IDE(s) to configure.

    Returns: "claude-code", "opencode", "codex", "all", or None (skip).
    """
    # Show what was detected
    if detected_clis:
        print(f"{GREEN}[TRW]{NC} Detected CLI(s): {', '.join(detected_clis)}")
    if detected_ides:
        print(f"{GREEN}[TRW]{NC} Existing IDE config: {', '.join(detected_ides)}")

    # Build options list: key, label, value
    options: list[tuple[str, str, str | None]] = []
    has_claude = "claude-code" in detected_clis or "claude-code" in detected_ides
    has_opencode = "opencode" in detected_clis or "opencode" in detected_ides
    has_codex = "codex" in detected_clis or "codex" in detected_ides

    if has_claude:
        options.append(("1", "Claude Code only", "claude-code"))
    if has_opencode:
        key = "2" if has_claude else "1"
        options.append((key, "OpenCode only", "opencode"))
    if has_codex:
        key = str(len(options) + 1)
        options.append((key, "Codex only", "codex"))

    # Always offer "all detected/current" and "skip"
    both_key = str(len(options) + 1)
    options.append((both_key, "All supported IDEs", "all"))
    skip_key = "s"
    options.append((skip_key, "Skip (configure later)", None))

    print("\nConfigure TRW for:")
    for key, label, _ in options:
        print(f"  [{key}] {label}")

    default_key = options[0][0]
    tty = _open_tty()
    if tty is None:
        return options[0][2]
    try:
        sys.stdout.write(f"\nSelection [default: {default_key}]: ")
        sys.stdout.flush()
        choice = tty.readline().strip().lower() or default_key
    finally:
        tty.close()

    for key, _, value in options:
        if choice == key:
            return value
    return options[0][2]  # default to first option


# ── Find trw-mcp command ─────────────────────────────────────────────


def find_trw_cmd(python: str) -> list[str]:
    """Return the command list for invoking trw-mcp CLI."""
    if shutil.which("trw-mcp"):
        return ["trw-mcp"]
    return [python, "-m", "trw_mcp.server"]


# ── Installation phases ──────────────────────────────────────────────


def phase_extract_wheels(ui: UI, step: int, total: int, tmpdir: Path) -> tuple[Path, Path]:
    """Phase 2: Extract embedded wheel files."""
    ui.step_header(step, total, "Extracting embedded packages")
    ui.start_spinner("Extracting wheels...")
    try:
        memory_whl, mcp_whl = extract_wheels(tmpdir)
        ok = memory_whl.stat().st_size > 0 and mcp_whl.stat().st_size > 0
    except Exception as exc:
        ui.stop_spinner(False, "", f"Failed to extract wheels: {exc}")
        sys.exit(1)
    ui.stop_spinner(ok, "Extracted trw-memory and trw-mcp wheels")
    if not ok:
        ui.step_fail("Extracted wheels are empty")
        sys.exit(1)
    return memory_whl, mcp_whl


def phase_prompt_features(
    ui: UI,
    install_ai: bool | None,
    install_sqlitevec: bool | None,
    prior_extras: dict[str, bool] | None = None,
) -> tuple[bool, bool]:
    """Prompt for optional features (interactive only). Skips if already answered.

    When both extras are already configured (prior install or CLI flags),
    shows a compact summary line instead of the full prompt section.
    """
    if prior_extras is None:
        prior_extras = {}

    ai_configured = prior_extras.get("ai", False)
    vec_configured = prior_extras.get("sqlite_vec", False)

    # Both already determined via CLI flags — nothing to show
    if install_ai is not None and install_sqlitevec is not None:
        return bool(install_ai), bool(install_sqlitevec)

    # Both from prior install — compact one-liner, no prompts
    if ai_configured and vec_configured and install_ai is None and install_sqlitevec is None:
        names = []
        if ai_configured:
            names.append("AI/LLM")
        if vec_configured:
            names.append("sqlite-vec")
        print(f"  {GREEN}\u2713{NC} Extras: {', '.join(names)} (from prior install)")
        return True, True

    # Need to prompt for at least one — show the feature section
    draw_divider("Optional Features")

    if install_ai is None:
        if ai_configured:
            print(f"  {GREEN}\u2713{NC} AI extras (from prior install)")
            install_ai = True
        else:
            print()
            ui.hint("AI extras enable semantic search over your learnings")
            ui.hint("and LLM-powered analysis of patterns across sessions.")
            ui.doc_link("integration#ai-extras")
            install_ai = prompt_yes_no("Install AI/LLM features?")

    if install_sqlitevec is None:
        if vec_configured:
            print(f"  {GREEN}\u2713{NC} sqlite-vec (from prior install)")
            install_sqlitevec = True
        else:
            print()
            ui.hint("sqlite-vec adds vector similarity search for faster,")
            ui.hint("more relevant recall of past learnings and discoveries.")
            ui.doc_link("integration#vector-search")
            install_sqlitevec = prompt_yes_no("Install sqlite-vec for vector search?")

    return bool(install_ai), bool(install_sqlitevec)


def phase_install_packages(
    ui: UI,
    step: int,
    total: int,
    python: str,
    memory_whl: Path,
    mcp_whl: Path,
) -> None:
    """Phase 3: Install base packages (order: memory -> mcp)."""
    ui.step_header(step, total, "Installing packages")

    ui.start_spinner("Installing trw-memory...")
    if not pip_install(python, str(memory_whl), "trw-memory", ui):
        ui.stop_spinner(False, "", "pip install failed for trw-memory")
        ui.error("Try installing in a virtual environment:")
        ui.error("  python3 -m venv .venv && source .venv/bin/activate")
        ui.error("  python3 install-trw.py")
        sys.exit(1)
    ui.stop_spinner(True, "Installed trw-memory")

    ui.start_spinner(f"Installing trw-mcp v{TRW_VERSION}...")
    if not pip_install(python, str(mcp_whl), "trw-mcp", ui):
        ui.stop_spinner(False, "", "pip install failed for trw-mcp")
        ui.error("Try installing in a virtual environment:")
        ui.error("  python3 -m venv .venv && source .venv/bin/activate")
        ui.error("  python3 install-trw.py")
        sys.exit(1)
    ui.stop_spinner(True, f"Installed trw-mcp v{TRW_VERSION}")

    # Verify imports
    for mod in ("trw_memory", "trw_mcp"):
        rc = subprocess.run(
            [python, "-c", f"import {mod}"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        ).returncode
        if rc != 0:
            ui.step_fail(f"{mod} not importable after install")
            sys.exit(1)


def phase_install_extras(
    ui: UI,
    step: int,
    total: int,
    python: str,
    install_ai: bool,
    install_sqlitevec: bool,
) -> list[str]:
    """Install optional extras as a single step. Returns feature names."""
    ui.step_header(step, total, "Installing extras")
    features: list[str] = []

    if install_ai:
        ui.start_spinner("Installing trw-mcp[ai]...")
        ok = pip_install(python, "trw-mcp[ai]", "trw-mcp[ai]", ui)
        ui.stop_spinner(ok, "AI features enabled", "AI extras failed (non-fatal)")
        if ok:
            features.append("AI/LLM")
        else:
            ui.step_warn("AI extras failed \u2014 base TRW still works fine")

        ui.start_spinner("Installing sentence-transformers (embeddings)...")
        ok = pip_install(python, "sentence-transformers>=2.0.0", "sentence-transformers", ui)
        ui.stop_spinner(ok, "Embeddings enabled", "Embeddings install failed (non-fatal)")
        if ok:
            features.append("embeddings")
        else:
            ui.step_warn("sentence-transformers failed \u2014 recall uses keyword-only search")

    if install_sqlitevec:
        ui.start_spinner("Installing sqlite-vec...")
        ok = pip_install(python, "sqlite-vec", "sqlite-vec", ui)
        ui.stop_spinner(ok, "sqlite-vec enabled", "sqlite-vec failed (non-fatal)")
        if ok:
            features.append("sqlite-vec")
        else:
            ui.step_warn("sqlite-vec failed \u2014 TRW works without it")

    return features


def phase_project_setup(
    ui: UI,
    step: int,
    total: int,
    python: str,
    target_dir: Path,
    upgrade_only: bool,
    interactive: bool = False,
    ide: str | None = None,
) -> None:
    """Set up or update the project scaffolding."""
    ui.step_header(step, total, "Setting up project")

    if upgrade_only:
        ui.step_ok("Upgrade complete — skipping project setup")
        return

    if not (target_dir / ".git").is_dir():
        ui.step_warn("Not a git repository — skipping project setup")
        ui.step_warn("After git init, run: trw-mcp init-project .")
        return

    # IDE detection and selection
    detected_clis = _detect_installed_clis()
    detected_ides = _detect_project_ides(str(target_dir))

    resolved_ide: str | None = ide
    is_update = (target_dir / ".trw").is_dir()
    if resolved_ide is None:
        if is_update and interactive and detected_ides:
            # Re-run: use previously configured IDEs without prompting
            resolved_ide = "all" if len(detected_ides) >= 2 else detected_ides[0]
            ui.step_ok(f"IDE config: {', '.join(detected_ides)} (from prior install)")
        elif interactive:
            resolved_ide = _prompt_ide_selection(detected_clis, detected_ides)
        else:
            # Headless: auto-configure all detected CLIs; default to claude-code if none
            all_detected = list(dict.fromkeys(detected_clis + detected_ides))
            if len(all_detected) >= 2:
                resolved_ide = "all"
            elif len(all_detected) == 1:
                resolved_ide = all_detected[0]
            else:
                resolved_ide = "claude-code"

    trw_cmd = find_trw_cmd(python)
    action = "update-project" if is_update else "init-project"
    label = "Updating" if is_update else "Initializing"

    cmd = trw_cmd + [action, str(target_dir)]
    if resolved_ide is not None:
        cmd += ["--ide", resolved_ide]

    ok = run_with_progress(ui, f"{label} project...", cmd)
    ui.stop_spinner(ok, f"Project {'updated' if is_update else 'initialized'}", f"Project {action} failed")

    # Generate per-client instruction files after project setup
    # (FR01, FR02, FR03: Split AGENTS.md into per-client instruction files)
    if resolved_ide in ("all", "opencode", "codex"):
        _generate_per_client_instructions(ui, target_dir, resolved_ide, is_update)


def phase_configure(
    ui: UI,
    step: int,
    total: int,
    target_dir: Path,
    interactive: bool,
    opt_name: str,
    opt_api_key: str,
    opt_telemetry: bool | None,
    prior_config: dict[str, object] | None = None,
    *,
    install_ai: bool = False,
    install_vec: bool = False,
    skip_auth: bool = False,
) -> str:
    """Configure project identity, API key, and telemetry. Returns platform_status."""
    ui.step_header(step, total, "Configure your project")

    if prior_config is None:
        prior_config = {}

    default_name = sanitize_project_name(target_dir.name)
    project_name = ""
    api_key = ""
    telemetry_enabled = False

    if interactive:
        if prior_config.get("project_name"):
            project_name = str(prior_config["project_name"])
            ui.step_ok(f"Project: {project_name} (from prior install)")
        else:
            draw_divider("Project Identity")
            print()
            ui.hint("This name identifies your installation in the TRW platform.")
            ui.hint("It appears in your analytics dashboard and team views.")
            project_name = _prompt_project_name(ui, default_name)

        if prior_config.get("api_key"):
            ui.step_ok("API key: configured (from prior install)")
            api_key = str(prior_config["api_key"])
            telemetry_enabled = True
        elif skip_auth and opt_api_key and validate_api_key(opt_api_key):
            api_key = opt_api_key
            telemetry_enabled = True
            ui.step_ok("API key: accepted (from bootstrap)")
        elif skip_auth:
            ui.step_ok("Auth skipped (--skip-auth)")
        else:
            draw_divider("Platform Connection")
            print()
            ui.hint("Connect to trwframework.com to sync learnings across")
            ui.hint("machines, view analytics, and collaborate with your team.")
            ui.doc_link("integration#platform-connection")
            api_key = _prompt_api_key(ui)

            if api_key:
                telemetry_enabled = True
            elif "telemetry" in prior_config:
                telemetry_enabled = bool(prior_config["telemetry"])
                ui.step_ok(f"Telemetry: {'enabled' if telemetry_enabled else 'disabled'} (from prior install)")
            else:
                print()
                ui.hint("Anonymous telemetry helps us improve TRW for everyone.")
                ui.hint("Only tool usage counts are shared — no code or learnings.")
                ui.doc_link("integration#telemetry")
                telemetry_enabled = prompt_yes_no("Enable anonymous usage telemetry?")
                ui.step_ok("Telemetry enabled" if telemetry_enabled else "Telemetry disabled")
    else:
        # Script mode: use flags
        project_name = sanitize_project_name(opt_name) if opt_name else default_name
        # PRD-FIX-067-FR02: Check prior_config before opt_api_key
        if prior_config.get("api_key"):
            api_key = str(prior_config["api_key"])
            telemetry_enabled = True
            ui.step_ok("API key: configured (from prior install)")
        elif opt_api_key:
            if validate_api_key(opt_api_key):
                api_key = opt_api_key
                telemetry_enabled = True
            else:
                ui.warn("Invalid API key format — ignoring --api-key")
        if not api_key and opt_telemetry is True:
            telemetry_enabled = True
        if opt_telemetry is False:
            telemetry_enabled = False
        ui.step_ok(f"Project: {project_name}")

    # Write to config (including feature flags for future reinstall detection)
    config_path = target_dir / ".trw" / "config.yaml"
    if update_config(
        config_path,
        project_name,
        api_key,
        telemetry_enabled,
        embeddings_enabled=install_ai or None,
        sqlite_vec_enabled=install_vec or None,
    ):
        ui.step_ok("Configuration saved to .trw/config.yaml")
    else:
        ui.step_warn("Config file not found — run project setup first")

    if api_key:
        return "connected"
    if telemetry_enabled:
        return "telemetry"
    return "offline"


def _prompt_project_name(ui: UI, default: str) -> str:
    """Interactive project name prompt with sanitization."""
    raw = prompt_input("Project name:", default)
    name = sanitize_project_name(raw)
    if not name:
        name = default
    if not re.match(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", name):
        ui.step_warn("Could not sanitize name, using default")
        name = default
    if name != raw and raw:
        ui.step_ok(f"Project name: {name} (sanitized from '{raw}')")
    else:
        ui.step_ok(f"Project name: {name}")
    return name


def _prompt_api_key(ui: UI) -> str:
    """Interactive API key prompt -- tries device auth first, falls back to manual paste.

    Device authorization (RFC 8628) opens a browser for passwordless login.
    If device auth is unavailable or user declines, falls back to manual key entry.
    """
    # Try device auth flow first
    try:
        result = _device_auth_login(DOCS_BASE.replace("/docs", ""), interactive=True)
        if result and isinstance(result.get("api_key"), str) and result["api_key"]:
            key = str(result["api_key"])
            if validate_api_key(key):
                ui.step_ok("Authenticated via device flow")
                return key
    except Exception:  # device auth is best-effort, fall back to manual
        pass

    # Fallback: manual key entry
    print()
    ui.hint("Or paste an API key manually:")
    max_attempts = 3
    for attempt in range(max_attempts):
        raw = prompt_input("Platform API key (Enter to skip):").strip()
        if not raw:
            ui.step_ok("No API key -- offline mode")
            return ""
        if validate_api_key(raw):
            ui.step_ok("API key accepted")
            return raw
        remaining = max_attempts - attempt - 1
        if remaining > 0:
            ui.step_warn("Invalid format (must start with trw_, alphanumeric only). Try again:")
        else:
            ui.step_warn("Invalid format -- skipping API key")
    return ""


def _device_auth_login(api_url: str, interactive: bool = True) -> dict | None:
    """Perform RFC 8628 device authorization -- stdlib only.

    Inline implementation that avoids importing trw_mcp.cli.auth (which may
    not be installed when running the standalone installer script).
    """
    import json as _json
    import time as _time
    import urllib.error
    import urllib.request
    import webbrowser as _wb

    api_url = api_url.rstrip("/")

    # Step 1: Request device code
    try:
        body = _json.dumps({"client_id": "trw-cli"}).encode("utf-8")
        req = urllib.request.Request(
            f"{api_url}/v1/auth/device/code",
            data=body,
            method="POST",
            headers={"Content-Type": "application/json"},
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            code_resp = _json.loads(resp.read().decode("utf-8"))
    except (urllib.error.URLError, OSError):
        return None

    device_code = code_resp.get("device_code", "")
    user_code = code_resp.get("user_code", "")
    verification_uri = code_resp.get("verification_uri", "")
    verification_uri_complete = code_resp.get("verification_uri_complete", verification_uri)
    interval = int(str(code_resp.get("interval", 5)))
    expires_in = int(str(code_resp.get("expires_in", 900)))

    if not device_code or not user_code:
        return None

    # Step 2: Display URL immediately, then try browser in background thread
    if interactive:
        print()
        print(f"  {BOLD}Open this URL to authenticate:{NC}")
        print()
        print(f"    {GREEN}{verification_uri_complete}{NC}")
        print()

        def _open_bg() -> None:
            try:
                _wb.open(verification_uri_complete)
            except Exception:
                pass

        _t = threading.Thread(target=_open_bg, daemon=True)
        _t.start()
        _t.join(timeout=2.0)
        if not _t.is_alive():
            print(f"  {DIM}(Browser opened \u2014 verify the code matches){NC}")
        else:
            print(f"  {DIM}(Opening browser...){NC}")

    # Step 3: Poll for token
    deadline = _time.monotonic() + expires_in
    poll_interval = interval

    while _time.monotonic() < deadline:
        _time.sleep(poll_interval)
        try:
            body = _json.dumps(
                {
                    "client_id": "trw-cli",
                    "device_code": device_code,
                    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                }
            ).encode("utf-8")
            req = urllib.request.Request(
                f"{api_url}/v1/auth/device/token",
                data=body,
                method="POST",
                headers={"Content-Type": "application/json"},
            )
            with urllib.request.urlopen(req, timeout=10) as resp:
                return _json.loads(resp.read().decode("utf-8"))
        except urllib.error.HTTPError as exc:
            err_code = ""
            try:
                err_body = _json.loads(exc.read().decode("utf-8"))
                err_code = err_body.get("error", "")
            except Exception:
                pass
            if err_code == "authorization_pending":
                continue
            elif err_code == "slow_down":
                poll_interval += 5
                continue
            elif err_code in ("expired_token", "access_denied"):
                return None
            else:
                return None
        except (urllib.error.URLError, OSError):
            poll_interval = min(poll_interval * 2, 30)
            continue

    return None


# ── Per-client instruction file generation ────────────────────────────


def _generate_per_client_instructions(ui: UI, target_dir: Path, ide_selection: str, is_update: bool) -> None:
    """Generate per-client instruction files for selected IDEs.

    Calls the TRW bootstrap functions to generate INSTRUCTIONS.md files
    optimized for each selected IDE's model family (qwen, gpt, claude).

    FR01, FR02, FR03: Split AGENTS.md into per-client instruction files
    FR06, FR07: Wire per-client instruction generation into installer
    """
    import subprocess

    trw_cmd = ["trw-mcp", "update-project", str(target_dir)]

    if ide_selection == "all":
        # Generate for all supported IDEs
        trw_cmd.extend(["--ide", "all"])
    else:
        trw_cmd.extend(["--ide", ide_selection])

    try:
        result = subprocess.run(
            trw_cmd,
            capture_output=True,
            text=True,
            timeout=60,
        )
        if result.returncode == 0:
            if is_update:
                ui.step_ok("Per-client instruction files updated")
            else:
                ui.step_ok("Per-client instruction files generated")
        else:
            ui.step_warn("Instruction file generation encountered warnings")
    except subprocess.TimeoutExpired:
        ui.step_warn("Instruction file generation timed out (may still be running)")
    except Exception as exc:
        ui.step_warn(f"Failed to generate per-client instruction files: {exc}")


# ── Main ─────────────────────────────────────────────────────────────


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Install TRW Framework in a project directory.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=(
            "Interactive mode (default in a terminal):\n"
            "  Guided setup with prompts, spinners, and feature selection.\n\n"
            "Script mode (piped input or --script):\n"
            "  No prompts. Use flags to configure.\n\n"
            "Examples:\n"
            "  python3 install-trw.py                       # Interactive\n"
            "  python3 install-trw.py --ai --telemetry      # With extras\n"
            "  python3 install-trw.py --script --no-ai      # Headless\n"
            "  python3 install-trw.py --upgrade             # Upgrade only\n"
        ),
    )
    parser.add_argument("target_dir", nargs="?", default=".", help="Project directory")
    parser.add_argument("--upgrade", action="store_true", help="Upgrade package only")
    parser.add_argument("--ai", dest="install_ai", action="store_true", default=None, help="Install AI/LLM extras")
    parser.add_argument("--no-ai", dest="install_ai", action="store_false", help="Skip AI extras")
    parser.add_argument(
        "--sqlite-vec", dest="install_vec", action="store_true", default=None, help="Install sqlite-vec"
    )
    parser.add_argument("--no-sqlite-vec", dest="install_vec", action="store_false", help="Skip sqlite-vec")
    parser.add_argument("--quiet", "-q", action="store_true", help="Minimal output")
    parser.add_argument("--script", action="store_true", help="Force non-interactive mode")
    parser.add_argument("--name", default="", help="Project name (installation ID)")
    parser.add_argument("--api-key", default="", help="Platform API key")
    parser.add_argument("--telemetry", dest="telemetry", action="store_true", default=None, help="Enable telemetry")
    parser.add_argument("--no-telemetry", dest="telemetry", action="store_false", help="Disable telemetry")
    parser.add_argument("--skip-auth", action="store_true", help="Skip device auth flow during install")
    parser.add_argument(
        "--ide",
        choices=["claude-code", "opencode", "codex", "all"],
        default=None,
        help="Target IDE to configure (prompted if not specified in interactive mode)",
    )

    args = parser.parse_args()

    # Mode detection
    interactive = (not args.script) and sys.stdin.isatty()

    # Script-mode defaults
    install_ai = args.install_ai
    install_vec = args.install_vec
    if not interactive:
        install_ai = install_ai if install_ai is not None else False
        install_vec = install_vec if install_vec is not None else False

    # Resolve target
    target_dir = Path(args.target_dir).resolve()
    if not target_dir.is_dir():
        print(f"{RED}[TRW]{NC} Directory not found: {target_dir}", file=sys.stderr)
        sys.exit(1)

    ui = UI(interactive=interactive, quiet=args.quiet)

    show_banner(ui)

    # ── Preflight ────────────────────────────────────────────────────
    # Gather all information BEFORE showing numbered steps so the step
    # count never changes mid-flow.

    prior_config = _load_prior_config(target_dir)
    is_reinstall = bool(prior_config)

    if is_reinstall and interactive:
        ui.info("Existing TRW installation detected \u2014 reusing prior settings")

    if interactive:
        draw_divider("Preflight")

    # Python check (preflight — not a numbered step)
    python = check_python_version(ui)
    major, minor = sys.version_info[:2]
    if interactive:
        print(f"  {GREEN}\u2713{NC} Python {major}.{minor} ({python})")
    else:
        ui.info(f"Python {major}.{minor} ({python})")

    # Detect already-installed extras via runtime imports
    prior_extras: dict[str, bool] = _detect_installed_extras(python) if is_reinstall else {}
    # Also honour config-level feature flags (user's prior choice persists
    # even if the venv was recreated or packages temporarily missing)
    if prior_config.get("embeddings"):
        prior_extras.setdefault("ai", True)
    if prior_config.get("sqlite_vec"):
        prior_extras.setdefault("sqlite_vec", True)

    # Feature selection (interactive: prompt now; script: already resolved)
    if interactive:
        install_ai, install_vec = phase_prompt_features(
            ui,
            install_ai,
            install_vec,
            prior_extras=prior_extras,
        )

    install_ai = bool(install_ai)
    install_vec = bool(install_vec)
    has_extras = install_ai or install_vec
    has_config = interactive or args.name or args.api_key or bool(prior_config)

    # ── Step count (stable from here on) ─────────────────────────────
    total = 3  # extract + install + project-setup
    if has_extras:
        total += 1
    if has_config:
        total += 1

    # Create temp dir for wheel extraction
    tmpdir = Path(tempfile.mkdtemp(prefix="trw-install-"))
    try:
        step = 0

        # Step 1: Extract wheels
        step += 1
        memory_whl, mcp_whl = phase_extract_wheels(ui, step, total, tmpdir)

        # Step 2: Install base packages
        step += 1
        phase_install_packages(ui, step, total, python, memory_whl, mcp_whl)

        # Step 3 (conditional): Install extras
        features: list[str] = []
        if has_extras:
            step += 1
            features = phase_install_extras(
                ui,
                step,
                total,
                python,
                install_ai,
                install_vec,
            )

        # Step N: Project setup
        step += 1
        phase_project_setup(
            ui,
            step,
            total,
            python,
            target_dir,
            args.upgrade,
            interactive=interactive,
            ide=args.ide,
        )

        # Step N+1 (conditional): Configure
        platform_status = "offline"
        if has_config:
            step += 1
            platform_status = phase_configure(
                ui,
                step,
                total,
                target_dir,
                interactive,
                args.name,
                args.api_key,
                args.telemetry,
                prior_config=prior_config,
                install_ai=install_ai,
                install_vec=install_vec,
                skip_auth=getattr(args, "skip_auth", False),
            )

        # Post-install: restart MCP servers and health-check backends
        _restart_mcp_servers(target_dir, ui)

        backend_results: list[dict[str, object]] = []
        if (
            platform_status in ("connected", "telemetry")
            or (target_dir / "docker-compose.yml").is_file()
            or (target_dir / "docker-compose.yaml").is_file()
        ):
            if interactive:
                ui.start_spinner("Checking backend connectivity...")
            backend_results = _check_all_backends(target_dir, prior_config)
            if interactive:
                ui.stop_spinner(True, "Backend connectivity checked")

        # Done!
        show_success_banner(
            ui,
            platform_status,
            features,
            is_reinstall=is_reinstall,
            backend_results=backend_results,
        )

    finally:
        try:
            shutil.rmtree(tmpdir)
        except OSError:
            pass


if __name__ == "__main__":
    main()

# ── Embedded wheel data (do not edit below this line) ────────────────
# __MEMORY_WHEEL_DATA__
# {{MEMORY_WHEEL_BASE64}}
# __WHEEL_DATA__
# {{WHEEL_BASE64}}
