"""linear-canal setup — Interactive setup wizard.

Runs the full Linear Canal Zero-Config pipeline locally — no Docker required.
Installs Claude Code plugin (skills + hooks + MCP auto-start).
"""

from __future__ import annotations

import os
import shutil
import sys
from pathlib import Path
from typing import Optional

import click
from rich.console import Console
from rich.panel import Panel
from rich.table import Table  # used by _pick_agents

from linear_canal.brand import show_header

console = Console()


@click.command("setup")
@click.option(
    "--project",
    "-p",
    type=click.Path(exists=True, file_okay=False),
    default=".",
    help="Path to the project to set up (default: current directory)",
)
@click.option(
    "--profile",
    type=click.Choice(["standard", "minimal"]),
    default=None,
    help="Embedding profile override.",
)
@click.option(
    "--force",
    is_flag=True,
    default=False,
    help="Re-run even if already initialised.",
)
def setup_cmd(
    project: str,
    profile: Optional[str],
    force: bool,
) -> None:
    """Index your codebase and install the Claude Code plugin.

    \b
    Runs the full Linear Canal pipeline locally — no Docker required:
      Scout → Graph Engine → Context Compiler → Indexer (fastembed + LanceDB)

    Installs the Claude Code plugin (skills + hooks + MCP auto-start).
    """
    project_path = Path(project).resolve()
    show_header()
    _run_zero_config(project_path, profile=profile, force=force)


# ---------------------------------------------------------------------------
# Skill installation — multi-agent picker
# ---------------------------------------------------------------------------

# (display_name, relative_skills_dir)
# Each entry describes where that agent loads skills from, relative to project root.
# The .LC/ instruction file written for each agent when selected:
_LC_INSTRUCTION_FILE: dict[str, str] = {
    "Claude Code": "CLAUDE.md",
    "Kilo":        "CLAUDE.md",
    "Roo":         "CLAUDE.md",
    "Gemini CLI":  "GEMINI.md",
    "Cursor":      ".cursorrules",
}
_LC_INSTRUCTION_DEFAULT = "AGENTS.md"

_SKILL_AGENTS: list[tuple[str, str]] = [
    ("Antigravity",    ".agent/skills"),
    ("Augment",        ".augment/skills"),
    ("Claude Code",    ".claude/skills"),
    ("Cline",          ".cline/skills"),
    ("CodeBuddy",      ".codebuddy/skills"),
    ("Command Code",   ".commandcode/skills"),
    ("Continue",       ".continue/skills"),
    ("Cortex Code",    ".cortex/skills"),
    ("Cursor",         ".cursor/rules"),
    ("Gemini CLI",     ".gemini/skills"),
    ("GitHub Copilot", ""),          # no skills dir — uses rule injection + .vscode/mcp.json
    ("Kilo",           ".claude/skills"),
    ("OpenCode",       "skills"),
    ("Roo",            ".roo/rules"),
    ("Windsurf",       ".windsurf/rules"),
    ("Zed AI",         ".zed/skills"),
]


def _pick_agents() -> list[tuple[str, str]]:
    """
    Show an interactive numbered multi-select list of supported agents.
    Returns the chosen (name, skills_dir) pairs, deduplicated by directory.
    """
    from rich.table import Table as _Table

    tbl = _Table(
        title="— Supported agents —",
        title_style="white",
        border_style="grey35",
        show_header=False,
        box=None,
        padding=(0, 2),
    )
    tbl.add_column("#",     style="grey58", width=4,  justify="right")
    tbl.add_column("Agent", style="bold white", min_width=18)
    tbl.add_column("Path",  style="grey58")

    for i, (name, path) in enumerate(_SKILL_AGENTS, 1):
        tbl.add_row(f"[{i}]", name, f"({path})")

    console.print()
    console.print(tbl)
    console.print()
    console.print(
        "[grey58]↑↓ scroll  ·  enter numbers separated by commas  ·  "
        "[bold white]all[/bold white] = every agent  ·  "
        "[bold white]none[/bold white] = skip[/grey58]"
    )
    console.print()

    raw = console.input(
        "[bold white]Install lc-context skill for[/bold white] "
        "[grey58](default: 3 = Claude Code)[/grey58]: "
    ).strip().lower()

    if not raw or raw == "3":
        # Default: Claude Code only
        return [_SKILL_AGENTS[2]]  # index 2 = Claude Code

    if raw in {"none", "0", "n", "skip"}:
        return []

    if raw in {"all", "a", "*"}:
        return list(_SKILL_AGENTS)

    # Parse comma/space-separated numbers
    chosen: list[tuple[str, str]] = []
    seen_dirs: set[str] = set()
    for token in raw.replace(",", " ").split():
        try:
            idx = int(token) - 1
            if 0 <= idx < len(_SKILL_AGENTS):
                name, path = _SKILL_AGENTS[idx]
                if path not in seen_dirs:
                    seen_dirs.add(path)
                    chosen.append((name, path))
        except ValueError:
            pass  # ignore non-numeric tokens

    return chosen if chosen else [_SKILL_AGENTS[2]]  # default fallback


def _lc_instruction_content(agent_name: str) -> str:
    """Return the brief `.LC/<file>` instruction markdown for an agent."""
    return f"""# Linear Canal — {agent_name}

> Auto-generated by `linear-canal setup`. Do not edit manually.
> Full documentation: `.claude/skills/lc-context/SKILL.md` (or your agent's skills dir).

## Quick start

Linear Canal has indexed this codebase. Before writing code, call:

```
lc_context("<describe your task>")   # get relevant context bundle
lc_impact("<file_path>")             # check impact before editing a file
lc_search("<concept or symbol>")     # semantic search across the codebase
```

## MCP server

```
Command : linear-canal serve
Endpoint: http://localhost:3141/mcp/v1
```

## Context files

Raw context documents live in `.linear_canal_data/<project>/ctx/`.
Start with `ARCHITECTURE.ctx`, then drill into the relevant module `.ctx` file.
"""


_LC_MARKER_START = "<!-- LINEAR CANAL START -->"
_LC_MARKER_END   = "<!-- LINEAR CANAL END -->"


def _inject_into_agent_file(target: Path, content: str) -> None:
    """Append (or update) the LC section inside an existing agent file.

    Uses HTML comment markers so the section can be found and replaced on
    subsequent runs without creating duplicates.
    """
    section = f"\n{_LC_MARKER_START}\n{content.strip()}\n{_LC_MARKER_END}\n"
    existing = target.read_text(encoding="utf-8")

    if _LC_MARKER_START in existing:
        # Replace the existing section
        start_idx = existing.index(_LC_MARKER_START)
        end_idx   = existing.index(_LC_MARKER_END) + len(_LC_MARKER_END)
        updated   = existing[:start_idx].rstrip() + section + existing[end_idx:].lstrip()
    else:
        # Append a new section
        updated = existing.rstrip() + section

    target.write_text(updated, encoding="utf-8")


def _install_lc_plugin(project_path: Path) -> None:
    """Install Linear Canal hooks and skills into a project's .claude/ directory.

    Claude Code reads skills from .claude/skills/ and hooks from .claude/settings.json.
    This function writes to those locations directly — no plugin manifest needed.

        <project>/
        └── .claude/
            ├── settings.json        ← hook config merged in (existing keys preserved)
            ├── skills/
            │   ├── lc-context/
            │   │   └── SKILL.md
            │   └── using-linear-canal/
            │       └── SKILL.md
            └── hooks/
                ├── session-start    ← PreToolUse hook script (Glob|Grep interceptor)
                └── prompt-reminder  ← UserPromptSubmit hook script
    """
    import json

    # ── Locate source files ────────────────────────────────────────────────
    cli_root   = Path(__file__).parent.parent          # cli/src/linear_canal/
    src_skills = cli_root / "skills"
    # Hooks: bundled with CLI package (works for pip install and editable install)
    src_hooks = cli_root / "hooks"
    if not src_hooks.exists():
        # Fallback: repo root hooks/ (dev install from repo)
        src_hooks = Path(__file__).parents[4] / "hooks"
    if not src_hooks.exists():
        # Last resort: old .claude-plugin/hooks/ location
        src_hooks = Path(__file__).parents[4] / ".claude-plugin" / "hooks"

    claude_dir = project_path / ".claude"
    claude_dir.mkdir(exist_ok=True)

    # ── 1. Skills → .claude/skills/ ───────────────────────────────────────
    for skill_name in ("lc-context", "using-linear-canal"):
        src = src_skills / skill_name / "SKILL.md"
        dst = claude_dir / "skills" / skill_name / "SKILL.md"
        if src.exists():
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dst)
            console.print(f"  [green]✓[/green] Skill            [dim]→ .claude/skills/{skill_name}/SKILL.md[/dim]")
        else:
            console.print(f"  [yellow]Warning: source skill not found: {src}[/yellow]")

    # ── 2. Hook scripts → .claude/hooks/ ──────────────────────────────────
    hooks_dir = claude_dir / "hooks"
    hooks_dir.mkdir(exist_ok=True)

    # ── Platform-aware hook script selection ──────────────────────────────
    is_windows = sys.platform == "win32"

    if is_windows:
        # Windows: use PowerShell hooks
        hook_scripts = (
            "session-start.ps1",
            "prompt-reminder.ps1",
            "read-interceptor.ps1",
            "semantic-inject.py",
        )
        _cmd_session  = "pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .claude/hooks/session-start.ps1"
        _cmd_reminder = "pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .claude/hooks/prompt-reminder.ps1"
        _cmd_read     = "pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File .claude/hooks/read-interceptor.ps1"
    else:
        # macOS / Linux: use Python hooks (python3 guaranteed by linear-canal deps)
        hook_scripts = (
            "session-start.py",
            "prompt-reminder.py",
            "read-interceptor.py",
            "semantic-inject.py",
        )
        _cmd_session  = "python3 .claude/hooks/session-start.py"
        _cmd_reminder = "python3 .claude/hooks/prompt-reminder.py"
        _cmd_read     = "python3 .claude/hooks/read-interceptor.py"

    for script_name in hook_scripts:
        src = src_hooks / script_name
        dst = hooks_dir / script_name
        if src.exists():
            shutil.copy2(src, dst)
            # Make executable on Unix
            if not is_windows:
                dst.chmod(dst.stat().st_mode | 0o111)
            console.print(f"  [green]✓[/green] Hook script      [dim]→ .claude/hooks/{script_name}[/dim]")
        else:
            console.print(f"  [yellow]Warning: source hook script not found: {src}[/yellow]")

    # ── 3. Merge hook config into .claude/settings.json ───────────────────
    settings_path = claude_dir / "settings.json"
    if settings_path.exists():
        try:
            settings = json.loads(settings_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            settings = {}
    else:
        settings = {}

    lc_hooks = {
        "UserPromptSubmit": [
            {
                "hooks": [
                    {
                        "type": "command",
                        "command": _cmd_reminder,
                        "timeout": 20,
                    }
                ]
            }
        ],
        "PreToolUse": [
            {
                "matcher": "Glob",
                "hooks": [
                    {
                        "type": "command",
                        "command": _cmd_session,
                        "timeout": 10,
                    }
                ],
            },
            {
                "matcher": "Grep",
                "hooks": [
                    {
                        "type": "command",
                        "command": _cmd_session,
                        "timeout": 10,
                    }
                ],
            },
            {
                "matcher": "Read",
                "hooks": [
                    {
                        "type": "command",
                        "command": _cmd_read,
                        "timeout": 10,
                    }
                ],
            },
        ],
    }

    existing_hooks = settings.get("hooks", {})
    # Only write LC hook entries — don't overwrite unrelated hooks
    for event, entries in lc_hooks.items():
        existing_hooks[event] = entries
    settings["hooks"] = existing_hooks

    settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
    console.print("  [green]✓[/green] Hook config      [dim]→ .claude/settings.json[/dim]")

    # ── 4. Write .mcp.json so Claude Code auto-starts/stops the MCP server ──
    # Claude Code spawns stdio MCP servers when a session opens and kills them
    # when the session closes — users never have to run `linear-canal serve` manually.
    mcp_path = project_path / ".mcp.json"
    if mcp_path.exists():
        try:
            mcp_cfg = json.loads(mcp_path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            mcp_cfg = {}
    else:
        mcp_cfg = {}

    servers = mcp_cfg.setdefault("mcpServers", {})
    servers["linear-canal"] = {
        "type": "stdio",
        "command": "linear-canal",
        "args": ["serve", "--project", "."],
    }

    mcp_path.write_text(json.dumps(mcp_cfg, indent=2) + "\n", encoding="utf-8")
    console.print("  [green]✓[/green] MCP server       [dim]→ .mcp.json (auto-start on session open)[/dim]")

    console.print()


# ---------------------------------------------------------------------------
# Agent-specific extras: Cursor hooks, Antigravity rules, OpenCode MCP, Copilot
# ---------------------------------------------------------------------------

def _install_cursor_hooks(project_path: Path) -> None:
    """Write .cursor/hooks.json, .cursor/mcp.json, and copy Cursor hook scripts.

    Cursor 1.7+ lifecycle hooks (project-level — scripts relative to project root):
      sessionStart        → .cursor/hooks/cursor-inject.py
          Fires when a new composer conversation starts.
          Returns {"additional_context": "..."} to seed the session system context.
      beforeShellExecution → .cursor/hooks/cursor-block.py
          Fires before every shell command.
          Returns {"permission": "deny", "agent_message": "..."} to block raw
          grep/find/rg commands and redirect the agent to LC MCP tools.
    """
    import json as _json

    # ── Copy hook scripts → .cursor/hooks/ (project-level convention) ────
    # For project hooks (.cursor/hooks.json), script paths in hooks.json are
    # relative to the project root, so scripts must live under .cursor/hooks/.
    src_hooks      = Path(__file__).parent.parent / "hooks"
    cursor_dir     = project_path / ".cursor"
    cursor_hooks_dir = cursor_dir / "hooks"
    cursor_hooks_dir.mkdir(parents=True, exist_ok=True)

    for script in ("cursor-inject.py", "cursor-block.py"):
        src = src_hooks / script
        dst = cursor_hooks_dir / script
        if src.exists():
            shutil.copy2(src, dst)
            console.print(f"  [green]✓[/green] Cursor hook      [dim]→ .cursor/hooks/{script}[/dim]")
        else:
            console.print(f"  [yellow]⚠ Cursor hook source not found: {src}[/yellow]")

    # ── .cursor/hooks.json ────────────────────────────────────────────────
    hooks_path = cursor_dir / "hooks.json"
    if hooks_path.exists():
        try:
            cfg = _json.loads(hooks_path.read_text(encoding="utf-8"))
        except _json.JSONDecodeError:
            cfg = {"version": 1, "hooks": {}}
    else:
        cfg = {"version": 1, "hooks": {}}
    cfg["version"] = 1
    cfg.setdefault("hooks", {})
    # sessionStart: inject LC context at conversation start via additional_context
    cfg["hooks"]["sessionStart"] = [{"command": "python .cursor/hooks/cursor-inject.py"}]
    # beforeShellExecution: block raw grep/find/rg — paths relative to project root
    cfg["hooks"]["beforeShellExecution"] = [{"command": "python .cursor/hooks/cursor-block.py"}]
    hooks_path.write_text(_json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
    console.print("  [green]✓[/green] Cursor hooks     [dim]→ .cursor/hooks.json[/dim]")

    # ── .cursor/mcp.json — MCP server config for Cursor ──────────────────
    mcp_path = cursor_dir / "mcp.json"
    if mcp_path.exists():
        try:
            mcp_cfg = _json.loads(mcp_path.read_text(encoding="utf-8"))
        except _json.JSONDecodeError:
            mcp_cfg = {}
    else:
        mcp_cfg = {}
    mcp_cfg.setdefault("mcpServers", {})["linear-canal"] = {
        "command": "linear-canal",
        "args": ["serve", "--project", "."],
    }
    mcp_path.write_text(_json.dumps(mcp_cfg, indent=2) + "\n", encoding="utf-8")
    console.print("  [green]✓[/green] Cursor MCP       [dim]→ .cursor/mcp.json[/dim]")
    console.print()


def _install_antigravity_rule(project_path: Path) -> None:
    """Write .agent/rules/linear-canal.md — an always-on Antigravity rule.

    Unlike a Skill (loaded on-demand), a Rule is *always* injected into the
    system prompt.  This guarantees the agent calls lc_context on every task.
    """
    rules_dir = project_path / ".agent" / "rules"
    rules_dir.mkdir(parents=True, exist_ok=True)
    rule_path = rules_dir / "linear-canal.md"
    content = """# Linear Canal — Always-On Context Rule

> Auto-generated by `linear-canal setup`. This is an Antigravity **Rule**, not a Skill.
> Rules are always injected into the system prompt — this fires on every single task.

## MANDATORY: Call lc_context before any coding task

Before responding to ANY coding question, task, or file editing request you MUST call
the `lc_context` MCP tool, passing the user's full request as `task_description`.

```
lc_context("<user's full request here>")
```

Do NOT use raw file exploration (grep, find, glob, ripgrep) before calling this tool.
The pre-indexed context costs a fraction of the tokens and is more accurate.

## Why this matters

This codebase is indexed by Linear Canal.  Calling `lc_context` retrieves:
- The right `.ctx` documents for your task (ranked CRITICAL → HIGH → MEDIUM)
- Relevant code chunks with file paths and line numbers
- Architecture, interfaces, risks, and dependency summaries

Skipping this call = hallucinating architecture + burning 80-95% more tokens.

## Other useful tools

```
lc_search("<concept or symbol>")   # semantic search
lc_impact("<file_path>")           # change-impact before editing
lc_context("<task>")               # CALL FIRST — context bundle
```

## MCP endpoint

`linear-canal serve` → `http://localhost:3141/mcp/v1`
"""
    rule_path.write_text(content, encoding="utf-8")
    console.print(
        "  [green]✓[/green] Antigravity rule [dim]→ .agent/rules/linear-canal.md "
        "(always-on — injected every prompt)[/dim]"
    )
    console.print()


def _install_opencode_mcp(project_path: Path) -> None:
    """Write / update .opencode.json with the Linear Canal MCP server entry."""
    import json as _json

    cfg_path = project_path / ".opencode.json"
    if cfg_path.exists():
        try:
            cfg = _json.loads(cfg_path.read_text(encoding="utf-8"))
        except _json.JSONDecodeError:
            cfg = {}
    else:
        cfg = {}

    cfg.setdefault("mcp", {}).setdefault("servers", {})["linear-canal"] = {
        "command": "linear-canal",
        "args": ["serve", "--project", "."],
    }
    cfg_path.write_text(_json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
    console.print("  [green]✓[/green] OpenCode MCP     [dim]→ .opencode.json[/dim]")
    console.print()


def _install_copilot_instructions(project_path: Path) -> None:
    """Write .github/copilot-instructions.md and .vscode/mcp.json for GitHub Copilot."""
    import json as _json

    # ── .github/copilot-instructions.md ──────────────────────────────────
    gh_dir = project_path / ".github"
    gh_dir.mkdir(exist_ok=True)
    instr_path = gh_dir / "copilot-instructions.md"
    content = """# Linear Canal — GitHub Copilot Instructions

> Auto-generated by `linear-canal setup`.

## Project context

This codebase is indexed by Linear Canal. Pre-compiled context files are available
at `.linear_canal_data/<project>/ctx/`.  Before writing or editing code, call:

```
lc_context("<describe your task>")   # REQUIRED FIRST CALL — fetches relevant context
lc_impact("<file_path>")             # check impact before modifying a file
lc_search("<concept or symbol>")     # semantic search
```

Do NOT use file search or glob tools to explore the codebase — use the tools above.

## MCP server

The Linear Canal MCP server runs at `http://localhost:3141/mcp/v1`.
Start it with `linear-canal serve`.
"""
    _inject_into_agent_file(instr_path, content) if instr_path.exists() else \
        instr_path.write_text(content, encoding="utf-8")
    console.print("  [green]✓[/green] Copilot instruct [dim]→ .github/copilot-instructions.md[/dim]")

    # ── .vscode/mcp.json — MCP server config for VS Code / Copilot ───────
    vscode_dir = project_path / ".vscode"
    vscode_dir.mkdir(exist_ok=True)
    mcp_path = vscode_dir / "mcp.json"
    if mcp_path.exists():
        try:
            mcp_cfg = _json.loads(mcp_path.read_text(encoding="utf-8"))
        except _json.JSONDecodeError:
            mcp_cfg = {}
    else:
        mcp_cfg = {}
    mcp_cfg.setdefault("servers", {})["linear-canal"] = {
        "type": "stdio",
        "command": "linear-canal",
        "args": ["serve", "--project", "."],
    }
    mcp_path.write_text(_json.dumps(mcp_cfg, indent=2) + "\n", encoding="utf-8")
    console.print("  [green]✓[/green] Copilot MCP      [dim]→ .vscode/mcp.json[/dim]")
    console.print()


def _install_agent_extras(project_path: Path, agent_name: str) -> None:
    """Dispatch agent-specific installs beyond the skill file."""
    if agent_name == "Cursor":
        _install_cursor_hooks(project_path)
    elif agent_name == "Antigravity":
        _install_antigravity_rule(project_path)
    elif agent_name == "OpenCode":
        _install_opencode_mcp(project_path)
    elif agent_name == "GitHub Copilot":
        _install_copilot_instructions(project_path)


def _install_lc_skill(project_path: Path) -> None:
    """
    Ask the user which agents they use, then:
      1. Drop the lc-context SKILL.md into each agent's skills directory.
      2. Write the LC instruction block — injected into the existing root-level
         agent file (CLAUDE.md, GEMINI.md, .cursorrules, AGENTS.md) if one is
         already present, otherwise created inside the .LC/ folder.
      3. Run agent-specific extras (hooks, rules, MCP configs).

    Nothing is written until the user explicitly selects their agents —
    the .LC/ directory is created here and only here (when needed).
    """
    skill_src     = Path(__file__).parent.parent / "skills" / "lc-context" / "SKILL.md"
    skill_src_ulc = Path(__file__).parent.parent / "skills" / "using-linear-canal" / "SKILL.md"

    agents = _pick_agents()
    if not agents:
        console.print("[dim]Skipping skill installation.[/dim]\n")
        return

    lc_dir = project_path / ".LC"

    console.print()
    installed_lc_files: set[str] = set()

    for name, rel_dir in agents:
        # ── 1. SKILL.md → agent's skills directory ────────────────────────
        if rel_dir and skill_src.exists():
            skill_dst = project_path / rel_dir / "lc-context" / "SKILL.md"
            if not skill_dst.exists():
                skill_dst.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(skill_src, skill_dst)
                console.print(f"  [green]✓[/green] [bold]{name}[/bold] SKILL  [dim]→ {rel_dir}/lc-context/SKILL.md[/dim]")
            else:
                console.print(f"  [dim]· {name}: SKILL.md already present, skipped[/dim]")

        if rel_dir and skill_src_ulc.exists():
            skill_dst_ulc = project_path / rel_dir / "using-linear-canal" / "SKILL.md"
            if not skill_dst_ulc.exists():
                skill_dst_ulc.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(skill_src_ulc, skill_dst_ulc)
                console.print(f"  [green]✓[/green] [bold]{name}[/bold] SKILL  [dim]→ {rel_dir}/using-linear-canal/SKILL.md[/dim]")
            else:
                console.print(f"  [dim]· {name}: using-linear-canal SKILL.md already present, skipped[/dim]")

        # ── 2. LC instruction block ────────────────────────────────────────
        lc_filename = _LC_INSTRUCTION_FILE.get(name, _LC_INSTRUCTION_DEFAULT)
        if lc_filename not in installed_lc_files:
            installed_lc_files.add(lc_filename)
            content = _lc_instruction_content(name)
            root_file = project_path / lc_filename

            if root_file.exists():
                # Inject into the existing root-level agent file — no duplicate
                _inject_into_agent_file(root_file, content)
                console.print(f"  [green]✓[/green] [bold]{name}[/bold] .LC    [dim]→ {lc_filename} (injected into existing)[/dim]")
            else:
                # No root file — create inside .LC/
                lc_dir.mkdir(exist_ok=True)
                lc_dst = lc_dir / lc_filename
                lc_dst.write_text(content, encoding="utf-8")
                console.print(f"  [green]✓[/green] [bold]{name}[/bold] .LC    [dim]→ .LC/{lc_filename}[/dim]")

        # ── 3. Agent-specific extras (hooks, rules, MCP configs) ──────────
        _install_agent_extras(project_path, name)

    console.print()


# ---------------------------------------------------------------------------
# Zero-Config path
# ---------------------------------------------------------------------------

# ─── One-time embedding model selection ──────────────────────────────────────

_PROFILE_FILE = Path.home() / ".linear-canal" / "profile.json"

_MODEL_MENU = [
    ("standard", "nomic-ai/nomic-embed-text-v1.5", "768-dim", "~90 MB",  "Best accuracy — daily dev use"),
    ("minimal",  "BAAI/bge-small-en-v1.5",         "384-dim", "~130 MB", "Low memory — CI / 8 GB RAM laptops"),
]


def _pick_model_if_needed(explicit_profile: Optional[str]) -> tuple[Optional[str], bool]:
    """Show a one-time model picker on first run; return (profile_name, showed_picker).

    Skipped (returns ``explicit_profile`` unchanged) when:
    - The user passed ``--profile`` explicitly, OR
    - ``~/.linear-canal/profile.json`` already exists (user already chose).

    The chosen profile is persisted to profile.json so subsequent ``setup``
    and ``add`` runs never ask again.  Use ``linear-canal profile set`` to
    change the choice later.

    Returns ``showed_picker=True`` when the interactive menu was displayed —
    callers can use this to skip the redundant download-confirm prompt.
    """
    import json as _json

    # Already persisted or overridden by flag — skip picker
    if explicit_profile is not None:
        return explicit_profile, False
    if _PROFILE_FILE.exists():
        try:
            data = _json.loads(_PROFILE_FILE.read_text(encoding="utf-8"))
            saved = data.get("profile", "")
            if saved in ("minimal", "standard"):
                return saved, False
        except Exception:
            pass

    # First run — show picker
    tbl = Table(
        title="— Choose your embedding model —",
        title_style="bold white",
        border_style="grey35",
        show_header=True,
        header_style="bold grey58",
        box=None,
        padding=(0, 2),
    )
    tbl.add_column("#",           style="grey58",   width=4,  justify="right")
    tbl.add_column("Profile",     style="bold white", no_wrap=True)
    tbl.add_column("Model",       style="dim",       no_wrap=True)
    tbl.add_column("Dims",        style="dim",       justify="right")
    tbl.add_column("Download",    style="orange1",   justify="right")
    tbl.add_column("Description", style="dim")

    for i, (profile_name, model, dims, size, desc) in enumerate(_MODEL_MENU, 1):
        tbl.add_row(f"[{i}]", profile_name, model, dims, size, desc)

    console.print()
    console.print(tbl)
    console.print(
        "\n[dim]Your choice is saved permanently — you won't be asked again.\n"
        "Change later with: [bold white]linear-canal profile set minimal|standard[/bold white][/dim]\n"
    )

    raw = console.input(
        "[bold white]Select model[/bold white] [grey58](default: 1 = standard)[/grey58]: "
    ).strip()

    if raw in {"", "1"}:
        chosen = "standard"
    elif raw == "2":
        chosen = "minimal"
    elif raw.lower() in ("standard", "minimal"):
        chosen = raw.lower()
    else:
        console.print("[yellow]Invalid choice — defaulting to standard.[/yellow]")
        chosen = "standard"

    # Persist
    _PROFILE_FILE.parent.mkdir(parents=True, exist_ok=True)
    _PROFILE_FILE.write_text(_json.dumps({"profile": chosen}, indent=2) + "\n", encoding="utf-8")
    console.print(f"[green]✓[/green] Saved profile [bold]{chosen}[/bold] → {_PROFILE_FILE}\n")

    return chosen, True


# ─── Embedding model pre-flight cache check ──────────────────────────────────

#: Approximate download sizes for each supported fastembed model (ONNX).
# nomic uses INT8 quantization (quantize=True) so only ~90 MB vs 270 MB float32.
_MODEL_SIZES: dict[str, str] = {
    "nomic-ai/nomic-embed-text-v1.5": "~90 MB (INT8 quantized)",
    "BAAI/bge-small-en-v1.5":         "~130 MB",
}


def _check_model_cache(model_name: str, profile_name: str) -> None:
    """Detect a cold fastembed cache and warn the user before the pipeline hangs.

    fastembed stores ONNX models in ~/.cache/fastembed/<vendor>/<name>/.
    The model_name (e.g. "nomic-ai/nomic-embed-text-v1.5") contains a ``/``
    that maps directly to a nested directory structure inside the cache root.

    If the directory is missing or empty the user is shown a one-time-download
    notice so the pipeline doesn't appear to silently hang.
    """
    cache_base  = Path.home() / ".cache" / "fastembed"
    model_cache = cache_base / model_name          # '/' creates nested dirs

    warm = model_cache.exists() and any(model_cache.iterdir())
    if warm:
        console.print(
            f"  [dim]Embedding cache  [green]✓ warm[/green]  "
            f"({model_name})[/dim]"
        )
        return

    size_hint = _MODEL_SIZES.get(model_name, "~200–400 MB")

    console.print(
        Panel(
            f"[bold white]First-run model download required[/bold white]\n\n"
            f"  Model    [white]{model_name}[/white]  [dim]({profile_name} profile)[/dim]\n"
            f"  Size     [orange1]{size_hint}[/orange1]  [dim]— one-time download[/dim]\n"
            f"  Cache    [dim]{cache_base}[/dim]\n\n"
            "[dim]fastembed will download the ONNX model automatically on\n"
            "first use. All subsequent runs use the local cache and are\n"
            "fast. No manual installation needed.[/dim]",
            title="[bold white]⬇  Embedding Model[/bold white]",
            border_style="grey35",
        )
    )

    raw = console.input(
        "[bold]Proceed with download?[/bold] [dim](Ctrl-C cancels)[/dim] \\[Y/n]: "
    ).strip().lower()
    if raw in {"n", "no"}:
        console.print("[dim]Setup cancelled.[/dim]\n")
        sys.exit(0)

    console.print()


def _run_zero_config(
    project_path: Path,
    profile: Optional[str],
    force: bool,
) -> None:
    """Run the zero-config pipeline."""
    from linear_canal.embed_config import resolve_embedding_config
    from linear_canal.pipeline import count_project_files, estimate_seconds, run_pipeline

    # One-time model selection (skipped if already saved or --profile passed)
    profile, _just_picked = _pick_model_if_needed(profile)
    resolved   = resolve_embedding_config(profile)
    file_count = count_project_files(project_path)
    est_secs   = estimate_seconds(file_count)
    est_str    = f"{est_secs}s" if est_secs < 60 else f"{est_secs // 60}m {est_secs % 60}s"

    console.print(
        Panel(
            f"[bold white]Zero-Config Pipeline[/bold white]\n\n"
            f"[grey58]Profile :[/grey58]  [white]{resolved.profile}[/white]  "
            f"[dim]({resolved.model_name})[/dim]\n"
            f"[grey58]Files   :[/grey58]  [orange1]{file_count:,}[/orange1]  "
            f"[dim]CRITICAL + HIGH + MEDIUM tiers[/dim]\n"
            f"[grey58]Est.    :[/grey58]  [orange1]~{est_str}[/orange1]\n\n"
            "[dim]Running pipeline locally — no Docker required.[/dim]",
            border_style="grey35",
        )
    )

    # ── Pre-flight: warn if the ONNX model hasn't been downloaded yet ─────
    # Skip when the user just picked a model — they already implicitly confirmed.
    if not _just_picked:
        _check_model_cache(resolved.model_name, resolved.profile)

    try:
        run_pipeline(
            target_dir=project_path,
            project_id=project_path.name,
            profile=profile,
            verbose=True,
        )
        console.print("\n[bold white]✓  Indexing complete[/bold white]  [grey58]— context is ready for your agents[/grey58]\n")
        _install_lc_plugin(project_path)
        _install_lc_skill(project_path)
    except Exception as exc:
        console.print(f"\n[red][ERROR] Pipeline failed: {exc}[/red]")
        sys.exit(1)

    # ── Offer to start watch mode ──────────────────────────────────────────
    raw = console.input(
        "[bold]Start watch mode now?[/bold] [dim](keeps context up-to-date as files change)[/dim] \\[Y/n]: "
    ).strip().lower()
    if raw in {"", "y", "yes"}:
        console.print()
        from linear_canal.commands.watch import _run_watcher
        try:
            _run_watcher(
                project_path=project_path,
                data_dir=project_path / ".linear_canal_data",
                project_id=project_path.name,
                profile=profile,
                debounce_seconds=3,
                verbose=False,
                stats={
                    "files_watched": 0,
                    "events_caught": 0,
                    "pipeline_runs": 0,
                    "last_event": "—",
                    "last_run": "—",
                    "status": "[green]Watching[/green]",
                },
            )
        except KeyboardInterrupt:
            console.print("\n[yellow]Watch mode stopped.[/yellow]\n")
    else:
        console.print("[dim]Tip: Run `linear-canal watch` anytime to start watching.[/dim]\n")

    # ── Offer to install the always-on MCP daemon ──────────────────────────
    console.print()
    raw2 = console.input(
        "[bold]Install always-on MCP daemon?[/bold] "
        "[dim](background server so agents connect instantly, no manual `serve` needed)[/dim] \\[Y/n]: "
    ).strip().lower()
    if raw2 in {"", "y", "yes"}:
        from linear_canal.commands.daemon import _do_install_autostart, _DEFAULT_PORT
        console.print()
        _do_install_autostart(_DEFAULT_PORT)
        console.print(
            f"\n[dim]MCP endpoint: [cyan]http://127.0.0.1:{_DEFAULT_PORT}/mcp[/cyan]\n"
            "Manage with: [bold]linear-canal daemon status/stop/logs[/bold][/dim]\n"
        )
    else:
        console.print(
            "[dim]Tip: Run [bold]linear-canal daemon install[/bold] anytime "
            "to set up the background MCP server.[/dim]\n"
        )


