"""CLI command for running agents."""

import json
import math
import os
import select
import subprocess
import sys
import uuid
from datetime import datetime
from pathlib import Path

import click
from rich.console import Console
from rich.markdown import Markdown
from rich.text import Text

from primordial.config import get_config
from primordial.github import GitHubResolver, GitHubResolverError, is_github_url, parse_github_url
from primordial.manifest import load_manifest
from primordial.security.key_vault import KeyVault
from primordial.security.permissions import format_permissions_for_display
from primordial.sandbox.manager import SandboxManager
from primordial.cli.helix import HelixSpinner

console = Console()


def _input_with_placeholder(prompt: str, placeholder: str) -> str:
    """Show a prompt with dim placeholder text that vanishes on first keystroke."""
    import readline  # noqa: F401 — ensures line editing works for input()
    import termios
    import tty

    # Print prompt + dim placeholder, cursor after prompt
    prompt_ansi = f"\033[1m{prompt}\033[0m"
    placeholder_ansi = f"\033[2m{placeholder}\033[0m"
    sys.stdout.write(f"{prompt_ansi}{placeholder_ansi}")
    # Move cursor back to right after the prompt
    sys.stdout.write(f"\r{prompt_ansi}")
    sys.stdout.flush()

    # Read one character in raw mode to detect first keystroke
    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        first = sys.stdin.read(1)
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)

    if first in ("\x03", "\x04"):  # Ctrl+C / Ctrl+D
        raise KeyboardInterrupt if first == "\x03" else EOFError

    # Clear the line and rewrite prompt + first char, then read the rest
    sys.stdout.write(f"\r\033[2K{prompt_ansi}{first}")
    sys.stdout.flush()

    # Use normal input() for the rest (with readline support)
    rest = input()
    return first + rest


def _detect_git_root() -> Path | None:
    """Return the root of the current git repo, or None."""
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            capture_output=True, text=True, timeout=5,
        )
        if result.returncode == 0:
            return Path(result.stdout.strip())
    except Exception:
        pass
    return None


def _detect_host_tz() -> str | None:
    """Detect the host machine's IANA timezone (e.g. 'America/New_York')."""
    if tz := os.environ.get("TZ"):
        return tz
    try:
        out = subprocess.run(
            ["readlink", "/etc/localtime"],
            capture_output=True, text=True, timeout=3,
        ).stdout.strip()
        if "/zoneinfo/" in out:
            return out.split("/zoneinfo/")[1]
    except Exception:
        pass
    return None


def _stdin_prompt(prompt: str, default: str = "") -> str:
    """Read a line from stdin with a prompt — works without a TTY."""
    if default:
        sys.stdout.write(f"{prompt} [{default}]: ")
    else:
        sys.stdout.write(f"{prompt}: ")
    sys.stdout.flush()
    value = sys.stdin.readline().strip()
    return value or default


def _new_session_name(agent_mode: bool = False) -> str:
    """Prompt for a custom session name or generate one."""
    auto_name = datetime.now().strftime("%Y%m%d-%H%M%S")
    if agent_mode:
        name = _stdin_prompt("Session name", auto_name)
    else:
        name = click.prompt("Session name", default=auto_name).strip()
    return name or auto_name


def _pick_session(config, agent_name: str, agent_mode: bool = False) -> Path:
    """Prompt user to create a new session or resume an existing one."""
    sessions = config.list_sessions(agent_name)

    if not sessions:
        return config.session_state_dir(agent_name, _new_session_name(agent_mode))

    console.print("\n[bold]Sessions[/bold]")
    console.print(f"  [dim]0)[/dim] New session")
    for i, name in enumerate(sessions, 1):
        console.print(f"  [dim]{i})[/dim] {name}")
    console.print()

    if agent_mode:
        raw = _stdin_prompt("Select session", "0")
        try:
            choice = int(raw)
        except ValueError:
            choice = 0
    else:
        choice = click.prompt("Select session", type=int, default=0)

    if choice < 1 or choice > len(sessions):
        return config.session_state_dir(agent_name, _new_session_name(agent_mode))

    return config.session_state_dir(agent_name, sessions[choice - 1])


@click.command()
@click.argument("agent_path")
@click.option("--agent", "agent_mode", is_flag=True, help="Host-agent mode (NDJSON conversation, no key prompts)")
@click.option("--auto-approve", is_flag=True, help="Skip permission approval prompt (for daemon use)")
@click.option("--ref", default=None, help="Git ref (branch, tag, commit) for GitHub agents")
@click.option("--refresh", is_flag=True, help="Force re-fetch of GitHub agent (ignore cache)")
@click.option("--session", "session_name", default=None, help="Session name to resume (skips prompt)")
@click.option("--no-worktree", "no_worktree", is_flag=True,
              help="Skip worktree isolation — bundle the working directory directly")
@click.option("--workspace", "workspace_path", default=None, type=click.Path(exists=True),
              help="Path to the git repo or subdirectory to use as workspace")
def run(
    agent_path: str,
    agent_mode: bool,
    auto_approve: bool,
    ref: str | None,
    refresh: bool,
    session_name: str | None,
    no_worktree: bool = False,
    workspace_path: str | None = None,
):
    """Run an agent in a sandbox.

    AGENT_PATH can be:
      - A local directory (./my-agent)
      - A GitHub URL (https://github.com/user/repo)
    """
    config = get_config()

    # Resolve agent path — GitHub URL, local path, or installed name
    if is_github_url(agent_path):
        try:
            github_ref = parse_github_url(agent_path, ref_override=ref)
            console.print(f"[dim]Resolving: {github_ref}[/dim]")
            resolver = GitHubResolver()
            agent_dir = resolver.resolve(github_ref, force_refresh=refresh)
        except GitHubResolverError as e:
            console.print(f"[red]GitHub resolve failed:[/red] {e}")
            raise SystemExit(1)
    else:
        agent_dir = Path(agent_path)
        if not agent_dir.exists():
            installed = config.agents_dir / agent_path
            if installed.exists():
                agent_dir = installed
            else:
                console.print(f"[red]Agent not found:[/red] {agent_path}")
                raise SystemExit(1)

    # Load manifest
    try:
        manifest = load_manifest(agent_dir)
    except (FileNotFoundError, ValueError) as e:
        console.print(f"[red]Invalid agent:[/red] {e}")
        raise SystemExit(1)

    # Session selection
    if session_name:
        state_dir = config.session_state_dir(manifest.name, session_name)
    elif auto_approve:
        from datetime import datetime as _dt
        state_dir = config.session_state_dir(manifest.name, _dt.now().strftime("%Y%m%d-%H%M%S"))
    else:
        state_dir = _pick_session(config, manifest.name, agent_mode)

    # Validate required API keys from manifest and prompt for missing ones
    vault = KeyVault(config.keys_file)

    # E2B key is always required for sandbox runtime
    if not vault.get_key("e2b"):
        if agent_mode:
            console.print(
                "Missing API key: e2b (E2B_API_KEY)\n"
                "Run: primordial keys add e2b\n"
                "Sign up at https://e2b.dev/dashboard"
            )
            raise SystemExit(1)
        console.print(
            "\n[bold yellow]E2B API key required[/bold yellow]\n"
            "[dim]E2B is the sandbox provider that Primordial uses to run agents "
            "in isolated microVMs. Sign up and grab a key at https://e2b.dev/dashboard[/dim]\n"
        )
        e2b_key = click.prompt("  Paste E2B_API_KEY", hide_input=True)
        if e2b_key.strip():
            vault.add_key("e2b", e2b_key.strip())
            console.print("  [green]Stored e2b.[/green]\n")
        else:
            console.print("[red]E2B key is required to run agents.[/red]")
            raise SystemExit(1)

    # Show permissions and ask for approval
    stored_providers = {e["provider"] for e in vault.list_keys()}
    console.print(f"\n[bold]{manifest.display_name}[/bold] [dim]v{manifest.version}[/dim]")
    console.print(f"[dim]{manifest.description}[/dim]")
    console.print(f"[dim]Session: {state_dir.name}[/dim]\n")
    console.print("[bold]Permissions[/bold]\n")
    for line in format_permissions_for_display(manifest, stored_providers=stored_providers):
        console.print(f"  {line}")
    console.print()
    if auto_approve:
        pass  # Skip approval prompt entirely
    elif agent_mode:
        # Plain stdin prompt — click.confirm requires a TTY
        sys.stdout.write("Approve and run? [y/N]: ")
        sys.stdout.flush()
        answer = sys.stdin.readline().strip().lower()
        if answer not in ("y", "yes"):
            console.print("[dim]Aborted.[/dim]")
            raise SystemExit(0)
    elif not click.confirm("Approve and run?"):
        console.print("[dim]Aborted.[/dim]")
        raise SystemExit(0)

    if manifest.keys:
        missing_required = []
        missing_optional = []
        for key_req in manifest.keys:
            if not vault.get_key(key_req.provider):
                if key_req.required:
                    missing_required.append(key_req)
                else:
                    missing_optional.append(key_req)

        if missing_required:
            if agent_mode:
                console.print("Missing required API keys:")
                for kr in missing_required:
                    console.print(f"  - {kr.provider} ({kr.resolved_env_var()})")
                    console.print(f"    Run: primordial keys add {kr.provider}")
                raise SystemExit(1)

            console.print("[bold]Missing API keys[/bold]")
            for kr in missing_required:
                console.print(f"  [red]✗[/red] {kr.provider} [dim]({kr.resolved_env_var()})[/dim]")
            for kr in missing_optional:
                console.print(f"  [dim]○ {kr.provider} ({kr.resolved_env_var()}) — optional[/dim]")
            console.print()

            for kr in missing_required:
                key = click.prompt(
                    f"  Enter {kr.provider.upper()} API key ({kr.resolved_env_var()})",
                    hide_input=True,
                )
                if key.strip():
                    vault.add_key(kr.provider, key.strip())
                    console.print(f"  [dim]Stored {kr.provider}.[/dim]")
                else:
                    console.print(f"[red]Cannot proceed without {kr.provider} key.[/red]")
                    raise SystemExit(1)
            console.print()

        if missing_optional:
            for kr in missing_optional:
                console.print(f"  [dim]Optional key missing: {kr.provider} ({kr.resolved_env_var()})[/dim]")
    else:
        # Fallback: check the default provider key exists
        provider = manifest.runtime.default_provider

        api_key = vault.get_key(provider)
        if not api_key:
            if agent_mode:
                console.print(f"Missing required API key: {provider}")
                console.print(f"Run: primordial keys add {provider}")
                raise SystemExit(1)
            console.print(
                f"\n[bold yellow]{provider.upper()} API key required[/bold yellow]\n"
                f"[dim]This agent uses {provider} as its model provider.[/dim]\n"
            )
            key = click.prompt(f"  Paste {provider.upper()} API key", hide_input=True)
            if key.strip():
                vault.add_key(provider, key.strip())
                console.print(f"  [green]Stored {provider}.[/green]\n")
            else:
                console.print(f"[red]Cannot proceed without {provider} key.[/red]")
                raise SystemExit(1)

    # Only inject keys the manifest declares — never leak unrelated keys
    if manifest.keys:
        allowed_providers = [kr.provider for kr in manifest.keys]
    else:
        allowed_providers = [manifest.runtime.default_provider]
    # E2B key is always needed for sandbox creation
    allowed_providers.append("e2b")
    env_vars = vault.get_env_vars(providers=allowed_providers)

    # Inject host timezone so agents see the user's local time
    if "TZ" not in env_vars:
        try:
            tz = _detect_host_tz()
            if tz:
                env_vars["TZ"] = tz
        except Exception:
            pass
    # Detect host workspace for agents that request filesystem access
    host_workspace: Path | None = None
    fs_perm = manifest.permissions.filesystem.workspace
    if fs_perm in ("readonly", "readwrite"):
        if workspace_path:
            # Explicit --workspace: resolve to its git root
            wp = Path(workspace_path).resolve()
            try:
                result = subprocess.run(
                    ["git", "rev-parse", "--show-toplevel"],
                    capture_output=True, text=True, timeout=5, cwd=str(wp),
                )
                if result.returncode == 0:
                    host_workspace = Path(result.stdout.strip())
                else:
                    console.print(
                        f"[yellow]Warning:[/yellow] {wp} is not inside a git repo. "
                        "Workspace will be empty."
                    )
            except Exception:
                host_workspace = None
        else:
            host_workspace = _detect_git_root()
        if host_workspace is None and not workspace_path:
            console.print(
                "[yellow]Warning:[/yellow] Agent requests workspace access "
                "but no git repo found. Workspace will be empty."
            )

    # Worktree isolation — automatic when agent requests workspace access
    worktree_mgr = None
    if host_workspace and not no_worktree:
        from primordial.worktree import WorktreeManager, WorktreeError
        try:
            worktree_mgr = WorktreeManager(host_workspace, session_name=state_dir.name)
            wt_path = worktree_mgr.create(manifest.name)
            branch = worktree_mgr._branch_name(manifest.name)
            console.print(
                f"[dim]Repo: {host_workspace}[/dim]\n"
                f"[dim]Worktree: {wt_path} "
                f"(branch: {branch})[/dim]"
            )
            host_workspace = wt_path
        except WorktreeError as e:
            console.print(f"[red]Failed to create worktree:[/red] {e}")
            raise SystemExit(1)

    manager = SandboxManager()

    if agent_mode:
        _run_agent(manager, agent_dir, manifest, host_workspace, env_vars, state_dir,
                   worktree_mgr=worktree_mgr)
    else:
        _run_chat(manager, agent_dir, manifest, host_workspace, env_vars, state_dir,
                  worktree_mgr=worktree_mgr)


_MINI_ROWS = 3
_MINI_HALF = 3
_MINI_CENTER = 4
_MINI_WIDTH = _MINI_CENTER * 2 + 2
_MINI_SPINNER = "/-\\|"


def _mini_strand_char(dx: float, z: float) -> str:
    """Pick strand character based on visual slope and depth."""
    if abs(dx) < 0.12:
        return "|" if z > 0 else ":"
    if dx > 0:
        return "\\" if z > -0.2 else "."
    return "/" if z > -0.2 else "."


def _mini_helix_frame(phase: float) -> list[Text]:
    """Render one frame of a mini double helix (3 rows)."""
    lines = []
    for r in range(_MINI_ROWS):
        t = r * 0.7 + phase
        hx1 = _MINI_CENTER + math.sin(t) * _MINI_HALF
        hx2 = _MINI_CENTER + math.sin(t + math.pi) * _MINI_HALF
        hz1 = math.cos(t)

        x1 = int(round(hx1))
        x2 = int(round(hx2))

        dx1, dx2 = hz1 * 0.55, -hz1 * 0.55
        ch = [" "] * _MINI_WIDTH

        if abs(x1 - x2) <= 1:
            cx = min(x1, x2)
            if 0 <= cx < _MINI_WIDTH:
                front_dx = dx1 if hz1 > 0 else dx2
                ch[cx] = _mini_strand_char(front_dx, 0.5)
            ln = Text("".join(ch))
            if 0 <= cx < _MINI_WIDTH:
                ln.stylize("bright_green", cx, cx + 1)
            lines.append(ln)
            continue

        lx, rx = (x1, x2) if x1 < x2 else (x2, x1)
        lz = hz1 if x1 < x2 else -hz1
        rz = -hz1 if x1 < x2 else hz1
        ldx = dx1 if x1 < x2 else dx2
        rdx = dx2 if x1 < x2 else dx1

        if 0 <= lx < _MINI_WIDTH:
            ch[lx] = _mini_strand_char(ldx, lz)
        if 0 <= rx < _MINI_WIDTH:
            ch[rx] = _mini_strand_char(rdx, rz)

        ln = Text("".join(ch))
        for pos, z, c in [(lx, lz, "green"), (rx, rz, "cyan")]:
            if 0 <= pos < _MINI_WIDTH:
                if z > 0.2:
                    ln.stylize(f"bold bright_{c}", pos, pos + 1)
                elif z > -0.2:
                    ln.stylize(c, pos, pos + 1)
                else:
                    ln.stylize(f"dim {c}", pos, pos + 1)
        lines.append(ln)
    return lines


def _show_sub_spawn(console: Console, session, sid: str, first_status: str) -> None:
    """Show a mini helix animation while sub-agent setup events stream in.

    Supports multiple concurrent spawns — if setup events arrive for different
    session IDs, all are tracked and displayed together.  When a sub-agent
    needs an API key the Live display is paused so the prompt renders cleanly.
    """
    import time
    import threading
    from rich.console import Group
    from rich.live import Live

    # Per-agent spawn state
    class _SpawnState:
        def __init__(self, sid: str, status: str):
            self.sid = sid
            self.status = status
            self.start_time = time.monotonic()
            self.phase_start = time.monotonic()
            self.done = False
            self.elapsed: float = 0.0

    agents: dict[str, _SpawnState] = {sid: _SpawnState(sid, first_status)}

    # Wire up delegation handler callbacks to pause/resume our Live display
    handler = getattr(session, "_delegation_handler", None)
    _prev_input_needed = getattr(handler, "on_input_needed", None) if handler else None
    _prev_input_done = getattr(handler, "on_input_done", None) if handler else None

    # Use handler.input_active flag to detect when a key prompt is showing.
    # The _live_stopped event signals the delegation thread that the Live
    # display has been stopped and it's safe to render the prompt.
    _live_stopped = threading.Event()

    def _on_input_needed():
        """Called from delegation thread when key prompt is about to show."""
        # Wait for the main thread to stop the Live display (it polls input_active)
        _live_stopped.clear()
        _live_stopped.wait(timeout=5.0)

    def _on_input_done():
        """Called from delegation thread when key prompt is finished."""
        _live_stopped.clear()

    if handler:
        handler.on_input_needed = _on_input_needed
        handler.on_input_done = _on_input_done

    def _render(frame: int) -> Group:
        helix_lines = _mini_helix_frame(frame * 0.2)
        parts: list = []

        # Header
        n = sum(1 for a in agents.values() if not a.done)
        label = "Spawning sub-agents" if len(agents) > 1 else "Spawning sub-agent"
        header = Text()
        header.append("      ")
        header.append(label, style="bold yellow")
        if len(agents) > 1:
            header.append(f" ({n} active)", style="dim yellow")
        parts.append(header)

        # Build status lines — one per agent
        sp = _MINI_SPINNER[frame % len(_MINI_SPINNER)]
        status_lines: list[Text] = []
        for st in agents.values():
            line = Text()
            if st.done:
                line.append("  + ", style="green")
                line.append(f"{st.sid}", style="green dim")
                line.append(f": spawned in {st.elapsed:.1f}s", style="dim")
            else:
                elapsed = time.monotonic() - st.phase_start
                line.append(f"  {sp} ", style="cyan")
                line.append(f"{st.sid}", style="cyan")
                line.append(f": {st.status}", style="dim")
                line.append(f" ({elapsed:.1f}s)", style="dim bold")
            status_lines.append(line)

        # Render helix rows alongside status lines
        num_rows = max(_MINI_ROWS, len(status_lines))
        for i in range(num_rows):
            row = Text()
            row.append("      ")
            if i < len(helix_lines):
                row.append_text(helix_lines[i])
            else:
                row.append(" " * _MINI_WIDTH)
            if i < len(status_lines):
                row.append_text(status_lines[i])
            parts.append(row)

        return Group(*parts)

    def _process_msg(msg):
        """Process a setup message, return True if it was handled."""
        msg_type = msg.get("type")
        tool = msg.get("tool", "")
        msg_sid = msg.get("session_id", "")
        if msg_type == "activity" and tool == "sub:setup":
            if msg_sid in agents:
                agents[msg_sid].status = msg.get("description", "")
                agents[msg_sid].phase_start = time.monotonic()
            else:
                agents[msg_sid] = _SpawnState(msg_sid, msg.get("description", ""))
            return True
        elif msg_type == "activity" and tool == "sub:spawned":
            if msg_sid in agents:
                agents[msg_sid].done = True
                agents[msg_sid].elapsed = time.monotonic() - agents[msg_sid].start_time
            return True
        return False

    def _input_is_active():
        return handler and getattr(handler, "input_active", False)

    def _wait_for_input_done():
        """Block while a key prompt is active, processing messages in background."""
        _live_stopped.set()  # Signal delegation thread that Live is stopped
        while _input_is_active():
            msg = session.receive(timeout=0.1)
            if msg is not None:
                if not _process_msg(msg):
                    session._messages.put(msg)

    frame = 0
    while True:
        if all(a.done for a in agents.values()) and agents:
            break

        # If a key prompt is already active, wait for it before starting Live
        if _input_is_active():
            _wait_for_input_done()
            if all(a.done for a in agents.values()) and agents:
                break

        with Live(_render(frame), console=console, refresh_per_second=12, transient=True) as live:
            while True:
                # Pause for key prompt
                if _input_is_active():
                    live.stop()
                    _wait_for_input_done()
                    break  # Restart Live

                if all(a.done for a in agents.values()) and agents:
                    break

                msg = session.receive(timeout=0.08)
                if msg is not None:
                    if not _process_msg(msg):
                        # Non-setup event — put it back and exit
                        session._messages.put(msg)
                        if handler:
                            handler.on_input_needed = _prev_input_needed
                            handler.on_input_done = _prev_input_done
                        for st in agents.values():
                            elapsed = st.elapsed if st.done else time.monotonic() - st.start_time
                            console.print(
                                f"      [yellow]› {st.sid}:[/yellow] [dim]spawned in {elapsed:.1f}s[/dim]"
                            )
                        return

                frame += 1
                live.update(_render(frame))
        if all(a.done for a in agents.values()) and agents:
            break

    # Restore previous callbacks
    if handler:
        handler.on_input_needed = _prev_input_needed
        handler.on_input_done = _prev_input_done

    # Print final summary for each agent
    for st in agents.values():
        if st.done:
            console.print(
                f"      [yellow]› {st.sid}:[/yellow] [dim]spawned in {st.elapsed:.1f}s[/dim]"
            )
        else:
            elapsed = time.monotonic() - st.start_time
            console.print(
                f"      [yellow]› {st.sid}:[/yellow] [dim]spawned in {elapsed:.1f}s[/dim]"
            )


def _present_workspace_patch(
    console: Console, patch: bytes, manifest, worktree_mgr=None,
) -> None:
    """Show the workspace diff and optionally apply it.

    In worktree mode: applies to the worktree and commits to its branch.
    Otherwise: prompts the user to apply to their working directory.
    """
    console.print(f"\n[bold yellow]Workspace changes from {manifest.display_name}:[/bold yellow]")
    # Show diff stat summary
    stat = subprocess.run(
        ["git", "apply", "--stat", "-"],
        input=patch, capture_output=True,
    )
    if stat.stdout:
        console.print(f"[dim]{stat.stdout.decode(errors='replace').strip()}[/dim]")

    if worktree_mgr:
        # Worktree mode — apply to worktree and commit
        from primordial.worktree import WorktreeManager
        wt_path = worktree_mgr._worktree_path(manifest.name)
        branch = worktree_mgr._branch_name(manifest.name)
        apply = subprocess.run(
            ["git", "apply", "-"],
            input=patch, capture_output=True, cwd=str(wt_path),
        )
        if apply.returncode == 0:
            worktree_mgr.commit_worktree(
                manifest.name, f"primordial: {manifest.display_name} changes",
            )
            diff_stat = worktree_mgr.diff_summary(manifest.name)
            console.print(f"[green]Changes committed to branch {branch}[/green]")
            if diff_stat:
                console.print(f"[dim]{diff_stat}[/dim]")
            console.print()
            console.print(f"[dim]Review:  git diff main..{branch}[/dim]")
            console.print(f"[dim]Merge:   git merge {branch}[/dim]")
            console.print(f"[dim]Cleanup: git worktree remove {wt_path}[/dim]")
        else:
            console.print(f"[red]git apply failed:[/red] {apply.stderr.decode(errors='replace')}")
            patch_path = Path(f".agent-patch-{manifest.name}.patch")
            patch_path.write_bytes(patch)
            console.print(f"[dim]Patch saved to {patch_path}[/dim]")
    elif click.confirm("\nApply these changes to your working directory?", default=False):
        apply = subprocess.run(
            ["git", "apply", "-"],
            input=patch, capture_output=True,
        )
        if apply.returncode == 0:
            console.print("[green]Changes applied.[/green]")
        else:
            console.print(f"[red]git apply failed:[/red] {apply.stderr.decode(errors='replace')}")
            patch_path = Path(f".agent-patch-{manifest.name}.patch")
            patch_path.write_bytes(patch)
            console.print(f"[dim]Patch saved to {patch_path}[/dim]")
    else:
        patch_path = Path(f".agent-patch-{manifest.name}.patch")
        patch_path.write_bytes(patch)
        console.print(f"[dim]Patch saved to {patch_path} — apply with: git apply {patch_path}[/dim]")



def _run_chat(
    manager: SandboxManager,
    agent_dir: Path,
    manifest,
    workspace: Path | None,
    env_vars: dict,
    state_dir: Path | None = None,
    worktree_mgr=None,
) -> None:
    """Run an agent with a human-friendly chat loop."""
    agent_subtitle = f"Starting {manifest.display_name} v{manifest.version}"

    with HelixSpinner(console, subtitle=agent_subtitle) as spinner:
        try:
            session = manager.run_agent(
                agent_dir=agent_dir,
                manifest=manifest,
                workspace=workspace,
                env_vars=env_vars,
                state_dir=state_dir,
                on_status=spinner.set_phase,
                worktree_mgr=worktree_mgr,
            )
        except Exception as e:
            console.print(f"\n[red]Failed to start agent:[/red] {e}")
            raise SystemExit(1)

        spinner.set_phase("Waiting for agent to initialize...")
        if not session.wait_ready(timeout=120):
            console.print("\n[red]Agent failed to start (no ready signal)[/red]")
            stderr = session.stderr.strip()
            if stderr:
                console.print(f"[red]Agent stderr:[/red]\n{stderr}")
            session.shutdown()
            raise SystemExit(1)

    # Per-session colors for sub-agent activities
    _DELEG_COLORS = ["cyan", "magenta", "yellow", "green", "blue", "red"]
    _deleg_color_map: dict[str, str] = {}

    def _color_for(sid: str) -> str:
        if sid not in _deleg_color_map:
            _deleg_color_map[sid] = _DELEG_COLORS[len(_deleg_color_map) % len(_DELEG_COLORS)]
        return _deleg_color_map[sid]

    # Wire up delegation handler callbacks to pause/resume the thinking spinner
    _active_spinner = {"ref": None}
    if hasattr(session, "_delegation_handler") and session._delegation_handler:
        def _pause_spinner():
            s = _active_spinner.get("ref")
            if s:
                s.stop()
        def _resume_spinner():
            pass  # spinner is re-created after each activity message
        session._delegation_handler.on_input_needed = _pause_spinner
        session._delegation_handler.on_input_done = _resume_spinner

    try:
        console.print(f"[dim]{manifest.display_name} ready[/dim]\n")
        msg_counter = 0
        first_prompt = True

        while True:
            try:
                if first_prompt:
                    user_input = _input_with_placeholder("> ", "Say something...")
                    first_prompt = False
                else:
                    user_input = console.input("[bold]> [/bold]")
            except (EOFError, KeyboardInterrupt):
                console.print("\n[dim]Ending session...[/dim]")
                break

            # Buffer pasted multi-line input: if more lines arrive
            # within a short window they're part of the same paste.
            paste_lines = [user_input]
            while True:
                ready, _, _ = select.select([sys.stdin], [], [], 0.05)
                if not ready:
                    break
                extra = sys.stdin.readline()
                if not extra:
                    break
                paste_lines.append(extra.rstrip("\n"))
            user_input = "\n".join(paste_lines)

            if user_input.strip().lower() in ("exit", "quit", "/exit", "/quit"):
                console.print("[dim]Ending session...[/dim]")
                break

            if not user_input.strip():
                continue

            if not session.is_alive:
                console.print("[red]Agent process exited unexpectedly.[/red]")
                stderr = session.stderr.strip()
                if stderr:
                    console.print(f"[dim]{stderr}[/dim]")
                break

            msg_counter += 1
            message_id = f"msg_{msg_counter:04d}"
            try:
                session.send_message(user_input, message_id)
            except Exception as e:
                console.print(f"[red]Send failed:[/red] {e}")
                stderr = session.stderr.strip()
                if stderr:
                    console.print(f"[dim]{stderr}[/dim]")
                break

            # Read responses until we get a done=true response
            accumulated_content = ""
            thinking = console.status("[dim]Thinking...[/dim]", spinner="flip")
            thinking.start()
            _active_spinner["ref"] = thinking

            while True:
                msg = session.receive(timeout=300)
                if msg is None:
                    thinking.stop()
                    _active_spinner["ref"] = None
                    console.print("[yellow]Response timed out.[/yellow]")
                    break

                msg_type = msg.get("type")
                if msg_type == "response":
                    thinking.stop()
                    _active_spinner["ref"] = None
                    content = msg.get("content", "")
                    if content:
                        accumulated_content += content
                    if msg.get("done", False):
                        if accumulated_content:
                            console.print()
                            console.print(
                                Markdown(accumulated_content),
                                style="dim",
                            )
                            console.print()
                        break
                elif msg_type == "activity":
                    thinking.stop()
                    _active_spinner["ref"] = None
                    tool = msg.get("tool", "?")
                    desc = msg.get("description", "")
                    if tool == "sub:response":
                        sid = msg.get("session_id", "")
                        label = sid if sid else "response"
                        c = _color_for(sid) if sid else "green"
                        console.print(f"      [{c}]› {label}:[/{c}] [dim]{desc}[/dim]")
                    elif tool == "sub:setup":
                        sid = msg.get("session_id", "")
                        # Show mini spawn animation with live-updating status
                        _show_sub_spawn(console, session, sid, desc)
                        # After spawn completes, resume the thinking spinner
                    elif tool.startswith("sub:"):
                        sub_tool = tool[4:]
                        sid = msg.get("session_id", "")
                        c = _color_for(sid) if sid else "cyan"
                        prefix = f"{sid} › {sub_tool}" if sid else sub_tool
                        console.print(f"      [{c}]› {prefix}:[/{c}] [dim]{desc}[/dim]")
                    else:
                        console.print(f"  [dim]› {tool}: {desc}[/dim]")
                    thinking = console.status("[dim]Thinking...[/dim]", spinner="flip")
                    thinking.start()
                    _active_spinner["ref"] = thinking
                elif msg_type == "error":
                    thinking.stop()
                    _active_spinner["ref"] = None
                    console.print(f"  [red]Error: {msg.get('error', 'Unknown')}[/red]")
                    break

            if not session.is_alive:
                console.print("[red]Agent process exited unexpectedly.[/red]")
                stderr = session.stderr.strip()
                if stderr:
                    console.print(f"[dim]{stderr}[/dim]")
                break

    finally:
        patch = session.shutdown()
        if patch:
            _present_workspace_patch(console, patch, manifest, worktree_mgr)
        console.print("[dim]Session ended.[/dim]")


def _run_agent(
    manager: SandboxManager,
    agent_dir: Path,
    manifest,
    workspace: Path | None,
    env_vars: dict,
    state_dir: Path | None = None,
    worktree_mgr=None,
) -> None:
    """Run an agent with JSON pipe I/O (for agent-to-agent)."""
    session = manager.run_agent(
        agent_dir=agent_dir,
        manifest=manifest,
        workspace=workspace,
        env_vars=env_vars,
        state_dir=state_dir,
        worktree_mgr=worktree_mgr,
    )

    try:
        if not session.wait_ready(timeout=120):
            _json_line({"type": "error", "error": "Agent failed to start"})
            raise SystemExit(1)

        _json_line({"type": "ready"})

        for line in sys.stdin:
            line = line.strip()
            if not line:
                continue
            try:
                incoming = json.loads(line)
            except json.JSONDecodeError:
                continue

            msg_type = incoming.get("type")
            if msg_type == "shutdown":
                break

            if msg_type == "message":
                content = incoming.get("content", "")
                message_id = incoming.get("message_id", f"auto_{uuid.uuid4().hex[:8]}")
                session.send_message(content, message_id)

                while True:
                    msg = session.receive(timeout=600)
                    if msg is None:
                        stderr = session.stderr.strip()
                        _json_line({"type": "error", "error": "timeout",
                                    "message_id": message_id,
                                    "stderr": stderr[-2000:] if stderr else ""})
                        break
                    _json_line(msg)
                    if msg.get("type") == "response" and msg.get("done", False):
                        break
                    if msg.get("type") == "error":
                        break

            if not session.is_alive:
                stderr = session.stderr.strip()
                _json_line({"type": "error", "error": "Agent process exited",
                            "stderr": stderr[-2000:] if stderr else ""})
                break

    finally:
        patch = session.shutdown()
        if patch:
            # In worktree mode, apply patch to the worktree branch
            if worktree_mgr:
                wt_path = worktree_mgr._worktree_path(manifest.name)
                apply = subprocess.run(
                    ["git", "apply", "-"],
                    input=patch, capture_output=True, cwd=str(wt_path),
                )
                if apply.returncode == 0:
                    worktree_mgr.commit_worktree(
                        manifest.name,
                        f"primordial: {manifest.display_name} changes",
                    )
            _json_line({
                "type": "workspace_patch",
                "patch": patch.decode(errors="replace"),
                "agent": manifest.name,
            })


def _json_line(data: dict) -> None:
    """Write a single NDJSON line to stdout."""
    sys.stdout.write(json.dumps(data) + "\n")
    sys.stdout.flush()
