"""Typer CLI for claw-forge."""

from __future__ import annotations

import asyncio
import os
import shutil
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

import httpx
import typer
import yaml
from rich.console import Console
from rich.table import Table

from claw_forge import __version__
from claw_forge.pool.model_resolver import resolve_model
from claw_forge.pool.providers.registry import load_configs_from_yaml

app = typer.Typer(name="claw-forge", help="Multi-provider autonomous coding agent harness")
console = Console()

# ── Helpers ──────────────────────────────────────────────────────────────────


def _expand_env_vars(obj: object) -> object:
    """Recursively expand ${VAR} placeholders using os.environ."""
    import re
    if isinstance(obj, str):
        def _replace(m: re.Match) -> str:  # type: ignore[type-arg]
            key = m.group(1)
            val = os.environ.get(key, "")
            if not val:
                console.print(f"[yellow]⚠ Env var ${{{key}}} is not set[/yellow]")
            return val
        return re.sub(r"\$\{([^}]+)\}", _replace, obj)
    if isinstance(obj, dict):
        return {k: _expand_env_vars(v) for k, v in obj.items()}
    if isinstance(obj, list):
        return [_expand_env_vars(i) for i in obj]
    return obj


_DEFAULT_CONFIG_YAML = """\
# claw-forge configuration — generated by `claw-forge init`
# Edit providers below, then copy .env.example → .env and fill in your keys.

model_aliases:
  opus:   claude-opus-4-5
  sonnet: claude-sonnet-4-20250514
  haiku:  claude-haiku-4-5
  fast:   claude-haiku-4-5

pool:
  strategy: priority
  max_retries: 3
  failure_threshold: 5
  recovery_timeout: 60

providers:
  # Claude CLI OAuth — run `claude login` once, works out of the box
  claude-oauth:
    type: anthropic_oauth
    priority: 1

  # Direct Anthropic API key
  anthropic-direct:
    type: anthropic
    api_key: ${ANTHROPIC_API_KEY}
    priority: 2

  # Anthropic-compatible proxy (optional)
  # anthropic-proxy:
  #   type: anthropic_compat
  #   api_key: ${PROXY_API_KEY}
  #   base_url: ${PROXY_BASE_URL}
  #   model: ${PROXY_MODEL}
  #   priority: 3

  # Local Ollama (optional, free)
  # local-ollama:
  #   type: ollama
  #   base_url: http://localhost:11434
  #   model: qwen2.5:32b
  #   priority: 10

agent:
  default_model: claude-sonnet-4-20250514
  max_tokens: 8192
  max_concurrent_agents: 5

skills:
  - pyright
  - typescript-lsp
  - rust-analyzer
  - systematic-debug
  - git-workflow

state:
  database_url: "sqlite+aiosqlite:///claw_forge.db"
  host: "0.0.0.0"
  port: 8420
"""

_DEFAULT_ENV_EXAMPLE = """\
# claw-forge environment variables
# Copy this file to .env and fill in your values.
# Never commit .env to version control.

# ── Anthropic (direct) ────────────────────────────────────────────────────────
# API key from https://console.anthropic.com
ANTHROPIC_API_KEY=sk-ant-...
# Model to use (default used when no --model flag is passed)
ANTHROPIC_MODEL=claude-sonnet-4-20250514

# ── Anthropic OAuth (Claude.ai / claude CLI) ──────────────────────────────────
# Run `claude setup-token` to get this token, then paste it here.
# ANTHROPIC_OAUTH_TOKEN=...
# ANTHROPIC_OAUTH_MODEL=claude-sonnet-4-6

# ── Anthropic-compatible proxy ────────────────────────────────────────────────
# Any provider that speaks the Anthropic Messages API (e.g. internal gateways)
# PROXY_API_KEY=...
# PROXY_BASE_URL=https://your-proxy.example.com/api/anthropic
# PROXY_MODEL=claude-sonnet-4-20250514

# ── OpenAI ────────────────────────────────────────────────────────────────────
# OPENAI_API_KEY=sk-...
# OPENAI_MODEL=gpt-4o

# ── OpenAI-compatible proxy (e.g. Azure, Together, Fireworks) ─────────────────
# OPENAI_COMPAT_API_KEY=...
# OPENAI_COMPAT_BASE_URL=https://...
# OPENAI_COMPAT_MODEL=...

# ── Groq ──────────────────────────────────────────────────────────────────────
# GROQ_API_KEY=gsk_...
# GROQ_MODEL=llama-3.3-70b-versatile

# ── Cerebras ──────────────────────────────────────────────────────────────────
# CEREBRAS_API_KEY=...
# CEREBRAS_MODEL=llama3.3-70b

# ── Ollama (local) ────────────────────────────────────────────────────────────
# OLLAMA_BASE_URL=http://localhost:11434
# OLLAMA_MODEL=qwen2.5:32b
"""


def _scaffold_config(config_path: str) -> bool:
    """Create claw-forge.yaml + .env.example if they don't exist.

    Returns True if config was newly created, False if it already existed.
    """
    path = Path(config_path)
    created = False
    if not path.exists():
        path.write_text(_DEFAULT_CONFIG_YAML)
        console.print(f"[green]✓ Created {path}[/green]  (edit providers as needed)")
        created = True
    env_example = path.parent / ".env.example"
    if not env_example.exists():
        env_example.write_text(_DEFAULT_ENV_EXAMPLE)
        console.print(f"[green]✓ Created {env_example}[/green]  (copy to .env and fill keys)")
    env_file = path.parent / ".env"
    if not env_file.exists():
        console.print(
            "[yellow]⚠  No .env found — copy .env.example → .env and add your API keys[/yellow]"
        )
    return created


def _load_config(config_path: str, *, auto_scaffold: bool = False) -> dict[str, Any]:
    """Load YAML config and expand ${ENV_VAR} placeholders from environment.

    When auto_scaffold=True (used by ``claw-forge init``), if the config file
    does not exist it is auto-created along with .env.example. For all other
    commands, a missing explicit --config path is still treated as an error.
    """
    path = Path(config_path)
    if not path.exists():
        if auto_scaffold:
            _scaffold_config(config_path)
        else:
            console.print(f"[red]Config not found: {path}[/red]")
            console.print(
                "[dim]Run [bold]claw-forge init[/bold] to create a default claw-forge.yaml[/dim]"
            )
            raise typer.Exit(1) from None
    # Auto-load .env file if present alongside the config
    env_file = path.parent / ".env"
    if env_file.exists():
        for line in env_file.read_text().splitlines():
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                key, _, val = line.partition("=")
                os.environ.setdefault(key.strip(), val.strip())
    raw = yaml.safe_load(path.read_text())
    return _expand_env_vars(raw)  # type: ignore[return-value]


def _state_url(port: int = 8420) -> str:
    return f"http://localhost:{port}"


def _http_get(url: str) -> dict[str, Any] | list[Any]:
    """Simple synchronous GET helper."""
    try:
        resp = httpx.get(url, timeout=5)
        resp.raise_for_status()
        return resp.json()  # type: ignore[no-any-return]
    except httpx.ConnectError:
        console.print(
            f"[red]State service not reachable at {url}[/red]\n"
            "[yellow]Tip: start it with `claw-forge state`[/yellow]"
        )
        raise typer.Exit(1) from None
    except httpx.HTTPStatusError as exc:
        console.print(f"[red]HTTP {exc.response.status_code}: {exc.response.text}[/red]")
        raise typer.Exit(1) from None


def _http_post(url: str, json: dict[str, Any] | None = None) -> dict[str, Any]:
    """Simple synchronous POST helper."""
    try:
        resp = httpx.post(url, json=json or {}, timeout=5)
        resp.raise_for_status()
        return resp.json()  # type: ignore[no-any-return]
    except httpx.ConnectError:
        console.print(
            f"[red]State service not reachable at {url}[/red]\n"
            "[yellow]Tip: start it with `claw-forge state`[/yellow]"
        )
        raise typer.Exit(1) from None
    except httpx.HTTPStatusError as exc:
        console.print(f"[red]HTTP {exc.response.status_code}: {exc.response.text}[/red]")
        raise typer.Exit(1) from None


# ── Commands ──────────────────────────────────────────────────────────────────


@app.command()
def status(
    config: str = typer.Option(
        "claw-forge.yaml", "--config", "-c",
        help="Path to claw-forge.yaml config file.",
    ),
    project: str = typer.Option(".", "--project", "-p", help="Project directory."),
) -> None:
    """Show project progress, agent status, and recommended next action.

    Zero-friction re-entry command — run this after returning to a project
    to see exactly where you left off and what to do next.

    Examples:

        # Show status for current directory
        claw-forge status

        # Show status for a specific project
        claw-forge status --project ~/projects/my-app
    """
    from claw_forge.commands.help_cmd import run_help

    project_path = Path(project).resolve()
    if config == "claw-forge.yaml":
        config = str(project_path / "claw-forge.yaml")
    run_help(config_path=config, project_path=str(project_path))


@app.command()
def version() -> None:
    """Print version."""
    console.print(f"claw-forge {__version__}")


@app.command()
def run(
    config: str = typer.Option(
        "claw-forge.yaml", "--config", "-c",
        help="Path to claw-forge.yaml config file.",
    ),
    project: str = typer.Option(".", "--project", "-p", help="Project directory."),
    task: str = typer.Option(
        "coding", "--task", "-t",
        help="Agent task type: coding | testing | review.",
    ),
    model: str = typer.Option(
        "claude-sonnet-4-20250514", "--model", "-m",
        help=(
            "Model to use. Supported formats:\n"
            "  claude-sonnet-4-20250514             bare model (pool picks provider)\n"
            "  anthropic-proxy-1/claude-opus-4-5    pin to specific provider\n"
            "  sonnet                               alias from model_aliases in config\n"
            "Defaults to the value in claw-forge.yaml."
        ),
    ),
    concurrency: int = typer.Option(
        5, "--concurrency", "-n",
        help="Max parallel coding agents (1–10). Higher = faster but more API spend.",
    ),
    yolo: bool = typer.Option(
        False, "--yolo/--no-yolo",
        help="YOLO mode: skip human-input gates, max concurrency, aggressive retry on errors.",
    ),
    dry_run: bool = typer.Option(
        False, "--dry-run",
        help="Print tasks in dependency order without executing.",
    ),
) -> None:
    """Run agents on a project until all features pass.

    Examples:

        # Run with defaults (reads claw-forge.yaml)
        claw-forge run

        # Run with 3 parallel agents
        claw-forge run --concurrency 3

        # Run with a specific model
        claw-forge run --model claude-opus-4-5 --concurrency 2

        # YOLO mode — no human-input gates, max speed
        claw-forge run --yolo

        # Dry-run — see what would execute
        claw-forge run --dry-run
    """
    import asyncio

    from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

    from claw_forge.orchestrator.dispatcher import Dispatcher
    from claw_forge.state.models import Base, Task
    from claw_forge.state.models import Session as DbSession
    from claw_forge.state.scheduler import TaskNode

    cfg = _load_config(config)
    resolved = resolve_model(model, cfg)
    if resolved.alias_resolved:
        console.print(f"[dim]Model alias '{model}' → {resolved.model_id}[/dim]")
    if resolved.provider_hint:
        console.print(f"[dim]Provider pinned: {resolved.provider_hint}[/dim]")
    model = resolved.model_id
    console.print(f"[bold]claw-forge[/bold] v{__version__}")
    console.print(f"Project: {project}")
    console.print(f"Task: {task}")
    console.print(f"Model: {model}")
    console.print(f"Providers: {len(cfg.get('providers', {}))}")
    if shutil.which("claude"):
        console.print("Executor:  claude CLI (autonomous agent — writes files) ✅")
    else:
        console.print(
            "[yellow]Executor:  API-only mode (no claude CLI in PATH — "
            "LLM responses only, files NOT written)[/yellow]"
        )
        console.print(
            "[dim]  → Install claude CLI for full autonomous coding: "
            "https://claude.ai/download[/dim]"
        )

    if yolo:
        cpu_count = max(1, os.cpu_count() or 4)
        console.print(
            f"[bold yellow]⚠️  YOLO MODE: Human approval skipped, max concurrency ({cpu_count}), aggressive retry[/bold yellow]"  # noqa: E501
        )

    # Resolve project path and create .claw-forge directory
    project_path = Path(project).resolve()
    claw_forge_dir = project_path / ".claw-forge"
    claw_forge_dir.mkdir(parents=True, exist_ok=True)
    db_path = claw_forge_dir / "state.db"
    db_url = f"sqlite+aiosqlite:///{db_path}"

    # Set up async engine
    engine = create_async_engine(db_url, echo=False)
    async_session_maker = async_sessionmaker(
        engine, class_=AsyncSession, expire_on_commit=False
    )

    async def main() -> None:
        async with engine.begin() as conn:
            await conn.run_sync(Base.metadata.create_all)

        async with async_session_maker() as session:
            # Find or create a session for this project
            from sqlalchemy import select

            stmt = (
                select(DbSession)
                .where(DbSession.project_path == str(project_path))
                .where(
                    (DbSession.status == "running") | (DbSession.status == "pending")
                )
                .order_by(DbSession.created_at.desc())
                .limit(1)
            )
            result = await session.execute(stmt)
            db_session = result.scalar_one_or_none()

            if db_session is None:
                # No existing session — create one
                import uuid

                db_session = DbSession(
                    id=str(uuid.uuid4()),
                    project_path=str(project_path),
                    status="pending",
                )
                session.add(db_session)
                await session.commit()
                await session.refresh(db_session)

            # Fetch pending or failed tasks (retry)
            stmt_tasks = (
                select(Task)
                .where(Task.session_id == db_session.id)
                .where((Task.status == "pending") | (Task.status == "failed"))
            )
            result_tasks = await session.execute(stmt_tasks)
            tasks = result_tasks.scalars().all()

            if not tasks:
                console.print(
                    "[yellow]No pending tasks found — run 'claw-forge plan <spec>' first[/yellow]"
                )
                return

            # Build TaskNode objects from DB tasks
            task_nodes: list[TaskNode] = []
            for t in tasks:
                depends_on: list[str] = t.depends_on or []
                node = TaskNode(
                    id=t.id,
                    plugin_name=t.plugin_name,
                    priority=t.priority,
                    depends_on=depends_on,
                    status=t.status,
                )
                task_nodes.append(node)

            # Dry-run: print tasks and exit
            if dry_run:
                from claw_forge.state.scheduler import Scheduler

                scheduler = Scheduler()
                for node in task_nodes:
                    scheduler.add_task(node)
                waves = scheduler.get_execution_order()

                console.print(f"\n[bold]Execution plan ({len(waves)} waves):[/bold]\n")
                for wave_idx, wave in enumerate(waves, 1):
                    console.print(f"  Wave {wave_idx}:")
                    for task_id in wave:
                        task = next((t for t in tasks if t.id == task_id), None)
                        if task:
                            desc = task.description or task.plugin_name
                            console.print(f"    • {task_id[:8]}…  {desc}")
                return

            # Build provider pool for direct API fallback
            from claw_forge.pool.manager import ProviderPoolManager

            configs = load_configs_from_yaml(cfg)
            pool = ProviderPoolManager(configs) if configs else None

            # Build task handler — each invocation gets its own session to
            # avoid concurrent commit() collisions on a shared session.
            async def task_handler(task_node: TaskNode) -> dict[str, Any]:
                async with async_session_maker() as task_session:
                    # Mark task as running
                    stmt_update = select(Task).where(Task.id == task_node.id)
                    result = await task_session.execute(stmt_update)
                    db_task = result.scalar_one()
                    db_task.status = "running"
                    db_task.started_at = datetime.now(UTC)
                    await task_session.commit()

                    output = ""
                    success = False

                    try:
                        prompt = db_task.description or f"Execute task: {db_task.plugin_name}"

                        # ── Option 1: claude_agent_sdk + claude CLI (full autonomous agent) ──
                        # claude_agent_sdk is a declared dependency so it's always installed.
                        # It requires the `claude` CLI in PATH to actually run agents.
                        sdk_available = (
                            shutil.which("claude") is not None
                        )

                        if sdk_available:
                            from claude_agent_sdk import ClaudeAgentOptions

                            from claw_forge.agent.session import AgentSession

                            options = ClaudeAgentOptions(model=model)
                            async with AgentSession(options) as agent_session:
                                full_output: list[str] = []
                                async for msg in agent_session.run(prompt):
                                    if hasattr(msg, "text"):
                                        full_output.append(msg.text)
                                output = "\n".join(full_output)
                            success = True

                        # ── Option 2: direct API call via provider pool ───────────────
                        # Used when claude CLI is not installed (API-only mode).
                        # Note: this sends prompts to the LLM but does NOT write files.
                        # Install the claude CLI for full autonomous file-writing agents.
                        elif pool is not None:
                            system_prompt = (
                                "You are an expert software engineer. "
                                "Implement the requested feature precisely and completely. "
                                f"Project directory: {project_path}"
                            )
                            response = await pool.execute(
                                model=model,
                                messages=[{"role": "user", "content": prompt}],
                                system=system_prompt,
                                max_tokens=8192,
                            )
                            output = response.content or ""
                            success = True

                        # ── No executor available ─────────────────────────────────────
                        else:
                            output = (
                                "No agent executor available.\n"
                                "  • For full autonomous coding: install the claude CLI "
                                "(https://claude.ai/download) and ensure it is in PATH\n"
                                "  • For API-only mode: add a provider with a valid API key "
                                "to claw-forge.yaml"
                            )
                            success = False

                    except Exception as exc:
                        output = str(exc)
                        success = False

                    finally:
                        db_task.status = "completed" if success else "failed"
                        db_task.completed_at = datetime.now(UTC)
                        db_task.error_message = None if success else output
                        db_task.result_json = {"output": output} if success else None
                        await task_session.commit()

                return {"success": success, "output": output}

            # Run dispatcher
            dispatcher = Dispatcher(
                handler=task_handler,
                max_concurrency=concurrency,
                yolo=yolo,
            )

            for node in task_nodes:
                dispatcher.add_task(node)

            dispatch_result = await dispatcher.run()

            # Print summary
            completed = len(dispatch_result.completed)
            failed = len(dispatch_result.failed)
            console.print("\n[bold]Run complete:[/bold]")
            console.print(f"  ✅ Completed: {completed}")
            if failed > 0:
                console.print(f"  ❌ Failed: {failed}")
                # Show a concise per-task failure reason (first line only)
                for task_id, err in list(dispatch_result.failed.items())[:5]:
                    short = err.splitlines()[0][:120] if err else "unknown error"
                    console.print(f"     [dim]{task_id[:8]}…[/dim] {short}")
                if len(dispatch_result.failed) > 5:
                    console.print(f"     [dim]… and {len(dispatch_result.failed) - 5} more[/dim]")
                # Give an actionable hint when the error is provider exhaustion
                sample_err = next(iter(dispatch_result.failed.values()), "")
                if "All providers exhausted" in sample_err or "No agent executor" in sample_err:
                    console.print(
                        "\n[yellow]💡 No working providers found.[/yellow]\n"
                        "  • Run [bold]claw-forge pool-status[/bold] to see which providers are active\n"
                        "  • Add a valid API key to claw-forge.yaml (e.g. ANTHROPIC_API_KEY_1)\n"
                        "  • Or install the claude CLI for OAuth-based execution: "
                        "https://claude.ai/download"
                    )
            else:
                console.print("  🎉 All tasks succeeded!")

    try:
        running_loop = asyncio.get_running_loop()
    except RuntimeError:
        running_loop = None

    if running_loop is not None:
        import concurrent.futures

        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
            pool.submit(asyncio.run, main()).result()
    else:
        asyncio.run(main())


@app.command()
def pool_status(
    config: str = typer.Option("claw-forge.yaml", "--config", "-c"),
) -> None:
    """Show provider pool status."""
    cfg = _load_config(config)
    configs = load_configs_from_yaml(cfg)

    table = Table(title="Provider Pool")
    table.add_column("Name")
    table.add_column("Type")
    table.add_column("Priority")
    table.add_column("Enabled")
    table.add_column("Cost (in/out $/Mtok)")

    for c in configs:
        table.add_row(
            c.name,
            c.provider_type.value,
            str(c.priority),
            "✅" if c.enabled else "❌",
            f"${c.cost_per_mtok_input}/{c.cost_per_mtok_output}",
        )
    console.print(table)


@app.command()
def state(
    port: int = typer.Option(8420, "--port", help="Port to bind the state service on."),
    host: str = typer.Option(
        "0.0.0.0", "--host",
        help="Host to bind (use 127.0.0.1 for local-only).",
    ),
    config: str = typer.Option(
        "claw-forge.yaml", "--config", "-c",
        help="Path to claw-forge.yaml (used for provider config and project metadata).",
    ),
) -> None:
    """Start the AgentStateService REST + WebSocket API.

    The state service powers the Kanban UI and exposes REST endpoints for
    session, task, pool, regression, and command management.

    Examples:

        # Start on default port 8420
        claw-forge state

        # Start on a custom port, local-only
        claw-forge state --port 9000 --host 127.0.0.1

        # Start the UI separately (connects to state service automatically)
        claw-forge ui --state-port 8420
    """
    import uvicorn

    from claw_forge.state.service import AgentStateService

    svc = AgentStateService()
    uvicorn.run(svc.create_app(), host=host, port=port)


@app.command()
def init(
    project: str = typer.Option(".", "--project", "-p", help="Project directory to bootstrap"),
    config: str = typer.Option(
        "claw-forge.yaml", "--config", "-c",
        help="Path to claw-forge.yaml (created if absent).",
    ),
) -> None:
    """Bootstrap a project — scaffold .claude/, CLAUDE.md, and claw-forge.yaml.

    Run this once in a new project directory before writing your spec.
    It installs the /create-spec and other Claude slash commands, then guides
    you to the next step.

    Examples:

        # Bootstrap current directory
        claw-forge init

        # Bootstrap a specific directory
        claw-forge init --project ~/projects/my-app
    """
    from claw_forge.scaffold import scaffold_project

    project_path = Path(project).resolve()
    if config == "claw-forge.yaml":
        config = str(project_path / "claw-forge.yaml")

    _load_config(config, auto_scaffold=True)

    scaffold = scaffold_project(project_path)
    console.print(
        f"✓ Stack detected: {scaffold['stack'].get('language', 'unknown')} / "
        f"{scaffold['stack'].get('framework', 'unknown')}"
    )
    if scaffold["claude_md_written"]:
        console.print("✓ Generated CLAUDE.md (tailored to your stack)")
    if scaffold["dot_claude_created"]:
        console.print("✓ Created .claude/ with settings.json")
    else:
        console.print("[dim]✓ .claude/ already exists — skipped[/dim]")
    if scaffold["commands_copied"]:
        console.print(
            f"✓ Scaffolded {len(scaffold['commands_copied'])} slash commands → .claude/commands/"
        )
        for cmd in scaffold["commands_copied"]:
            console.print(f"  • /{Path(cmd).stem}")

    if scaffold["spec_example_written"]:
        console.print("✓ Created app_spec.example.xml  (reference format for your spec)")

    # Guide user to next step
    spec_file = project_path / "app_spec.txt"
    xml_spec_file = project_path / "additions_spec.xml"
    if not spec_file.exists() and not xml_spec_file.exists():
        console.print(
            "\n[bold cyan]Next step:[/bold cyan] create your project spec.\n"
            "  Option A — use the Claude slash command:\n"
            "    Open Claude Code here and run [bold]/create-spec[/bold]\n\n"
            "  Option B — paste your PRD into Claude with this prompt:\n"
            '    "Convert this PRD to claw-forge XML spec format.\n'
            "     Use app_spec.example.xml in this directory as the template.\n"
            '     Write the result to app_spec.txt."\n\n'
            "  Then run: [bold]claw-forge plan app_spec.txt[/bold]"
        )
    else:
        found = spec_file if spec_file.exists() else xml_spec_file
        console.print(
            f"\n[bold cyan]Spec found:[/bold cyan] {found.name}\n"
            f"  Run: [bold]claw-forge plan {found.name}[/bold]"
        )


async def _write_plan_to_db(
    project_path: Path, project_name: str, features: list[dict[str, Any]]
) -> None:
    """Create .claw-forge/state.db and persist parsed features as pending tasks."""
    import uuid

    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

    from claw_forge.state.models import Base, Task
    from claw_forge.state.models import Session as DbSession

    claw_forge_dir = project_path / ".claw-forge"
    claw_forge_dir.mkdir(parents=True, exist_ok=True)
    db_path = claw_forge_dir / "state.db"
    db_url = f"sqlite+aiosqlite:///{db_path}"

    engine = create_async_engine(db_url, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    async_session = async_sessionmaker(engine, expire_on_commit=False)
    async with async_session() as db:
        # Create a session row
        session_id = str(uuid.uuid4())
        db_sess = DbSession(
            id=session_id,
            project_path=str(project_path),
            status="pending",
        )
        db.add(db_sess)
        await db.flush()

        # Map feature index → task UUID for dependency wiring
        index_to_uuid: dict[int, str] = {}
        task_objs: list[Task] = []
        for feat in features:
            tid = str(uuid.uuid4())
            index_to_uuid[feat["index"]] = tid
            task_objs.append(
                Task(
                    id=tid,
                    session_id=session_id,
                    plugin_name="coding",
                    description=(
                        f"[{feat['category']}] {feat['name']}: {feat['description']}"
                    ),
                    status="pending",
                    priority=feat.get("index", 0),
                    depends_on=[],  # filled below
                )
            )

        # Wire depends_on using resolved UUIDs
        for feat, task in zip(features, task_objs, strict=True):
            dep_uuids = [
                index_to_uuid[i]
                for i in feat.get("depends_on_indices", [])
                if i in index_to_uuid
            ]
            task.depends_on = dep_uuids
            db.add(task)

        await db.commit()

    await engine.dispose()


@app.command()
def plan(
    spec: str = typer.Argument(..., help="Path to app_spec.txt or additions_spec.xml."),
    project: str = typer.Option(".", "--project", "-p", help="Project directory."),
    model: str = typer.Option(
        "claude-opus-4-5", "--model", "-m",
        help=(
            "Model to use. Supported formats:\n"
            "  claude-opus-4-5                      bare model (pool picks provider)\n"
            "  anthropic-proxy-1/claude-opus-4-5    pin to specific provider\n"
            "  opus                                 alias from model_aliases in config\n"
            "Defaults to Opus — planning is the most critical step."
        ),
    ),
    concurrency: int = typer.Option(
        5, "--concurrency", "-n",
        help="Max parallel agents when running (saved as hint in output).",
    ),
    config: str = typer.Option(
        "claw-forge.yaml", "--config", "-c",
        help="Path to claw-forge.yaml config file.",
    ),
) -> None:
    """Parse a spec file and generate the feature DAG in the state database.

    Reads app_spec.txt (or additions_spec.xml for brownfield), decomposes it into
    atomic features, assigns dependencies, and writes the task graph to the DB.
    Uses Opus by default — errors in planning cascade through every agent run.

    Examples:

        # Parse spec and build feature DAG
        claw-forge plan app_spec.txt

        # Use Sonnet if cost matters more than planning quality
        claw-forge plan app_spec.txt --model claude-sonnet-4-5

        # Brownfield additions
        claw-forge plan additions_spec.xml --concurrency 3
    """
    from claw_forge.plugins.base import PluginContext
    from claw_forge.plugins.initializer import InitializerPlugin

    project_path = Path(project).resolve()
    if config == "claw-forge.yaml":
        config = str(project_path / "claw-forge.yaml")

    cfg = _load_config(config)
    resolved = resolve_model(model, cfg)
    if resolved.alias_resolved:
        console.print(f"[dim]Model alias '{model}' → {resolved.model_id}[/dim]")
    if resolved.provider_hint:
        console.print(f"[dim]Provider pinned: {resolved.provider_hint}[/dim]")
    model = resolved.model_id  # noqa: F841

    spec_path = Path(spec)
    if not spec_path.is_absolute():
        spec_path = project_path / spec
    if not spec_path.exists():
        console.print(f"[red]Spec file not found: {spec_path}[/red]")
        raise typer.Exit(1) from None

    plugin = InitializerPlugin()
    ctx = PluginContext(project_path=str(project_path), session_id="plan", task_id="plan")
    ctx.metadata = {"spec_file": str(spec_path)}
    result = asyncio.run(plugin.execute(ctx))

    if result.success:
        meta = result.metadata

        # ── Write tasks to DB so `claw-forge run` can find them ──────────────
        features = meta.get("features", [])
        if features:
            asyncio.run(_write_plan_to_db(project_path, meta["project_name"], features))

        if "feature_count" in meta:
            console.print(f"\n[bold green]✅ Spec parsed: {meta['project_name']}[/bold green]")
            console.print(f"   {result.output}\n")

            category_counts = meta.get("category_counts", {})
            if category_counts:
                cat_table = Table(title="Features by Category")
                cat_table.add_column("Category", style="cyan")
                cat_table.add_column("Count", justify="right", style="bold")
                for cat, count in sorted(category_counts.items(), key=lambda x: -x[1]):
                    cat_table.add_row(cat, str(count))
                cat_table.add_row("[bold]Total[/bold]", f"[bold]{meta['feature_count']}[/bold]")
                console.print(cat_table)

            wave_count = meta.get("wave_count", 0)
            console.print(f"\n   [bold]Dependency waves:[/bold] {wave_count}")

            feature_count = meta.get("feature_count", 0)
            if feature_count > 0 and concurrency > 0:
                est_minutes = (feature_count * 2) / concurrency
                est_str = (
                    f"{est_minutes:.0f} minutes"
                    if est_minutes < 60
                    else f"{est_minutes / 60:.1f} hours"
                )
                console.print(
                    f"   [bold]Estimated run time:[/bold] ~{est_str} "
                    f"(at concurrency={concurrency})"
                )

            phases = meta.get("phases", [])
            if phases:
                console.print(f"\n   [bold]Implementation phases ({len(phases)}):[/bold]")
                for i, phase in enumerate(phases, 1):
                    console.print(f"     {i}. {phase}")

            console.print(
                f"\n   Next: [bold]claw-forge run --concurrency {concurrency}[/bold]"
            )
        else:
            console.print("[green]Plan complete[/green]")
            for k, v in meta.items():
                if k != "features":
                    console.print(f"  {k}: {v}")
    else:
        console.print(f"[red]Planning failed: {result.output}[/red]")
        raise typer.Exit(1) from None


@app.command()
def add(
    feature: str = typer.Argument(
        ...,
        help="Feature description (plain text) or @path/to/additions_spec.xml.",
    ),
    spec: str | None = typer.Option(
        None, "--spec", "-s",
        help="Path to brownfield XML spec (additions_spec.xml). "
             "Use /create-spec in Claude Code to generate one.",
    ),
    project: str = typer.Option(".", "--project", "-p", help="Project directory."),
    model: str = typer.Option(
        "claude-sonnet-4-20250514", "--model", "-m",
        help=(
            "Model to use. Supported formats:\n"
            "  claude-sonnet-4-20250514             bare model (pool picks provider)\n"
            "  anthropic-proxy-1/claude-opus-4-5    pin to specific provider\n"
            "  sonnet                               alias from model_aliases in config"
        ),
    ),
    concurrency: int = typer.Option(
        3, "--concurrency", "-n",
        help="Max parallel agents for this addition (default 3, lower than greenfield).",
    ),
    branch: bool = typer.Option(
        True, "--branch/--no-branch",
        help="Create a feat/<slug> git branch for this addition.",
    ),
    config: str = typer.Option(
        "claw-forge.yaml", "--config", "-c",
        help="Path to claw-forge.yaml config file.",
    ),
) -> None:
    """Add a feature or brownfield spec to an existing project.

    Runs a brownfield agent that respects existing conventions, keeps all
    current tests green, and works on a dedicated git branch.

    Examples:

        # Add a single feature by description
        claw-forge add 'add Stripe payment webhook handler'

        # Add features from a brownfield spec
        claw-forge add --spec additions_spec.xml

        # Add without branching
        claw-forge add 'add rate limiting' --no-branch

        # Add with a specific model and concurrency
        claw-forge add --spec additions_spec.xml --model claude-opus-4-5 --concurrency 2
    """
    import json

    from claw_forge.plugins.base import PluginContext
    from claw_forge.plugins.initializer import InitializerPlugin

    cfg_data = _load_config(config)
    resolved = resolve_model(model, cfg_data)
    if resolved.alias_resolved:
        console.print(f"[dim]Model alias '{model}' → {resolved.model_id}[/dim]")
    if resolved.provider_hint:
        console.print(f"[dim]Provider pinned: {resolved.provider_hint}[/dim]")
    model = resolved.model_id

    if spec:
        # Brownfield spec path provided — parse and inject context
        from claw_forge.spec import ProjectSpec

        spec_path = Path(spec)
        try:
            parsed = ProjectSpec.from_file(spec_path)
        except Exception as exc:
            console.print(f"[red]Failed to parse spec: {exc}[/red]")
            raise typer.Exit(1) from exc

        # Load manifest if brownfield
        manifest: dict[str, Any] | None = None
        if parsed.is_brownfield:
            manifest_path = Path(project) / "brownfield_manifest.json"
            if manifest_path.exists():
                try:
                    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
                    for key in ("stack", "test_baseline", "conventions"):
                        if key in manifest:
                            parsed.existing_context[key] = str(manifest[key])
                except Exception as exc:
                    console.print(f"[yellow]Warning: could not load manifest: {exc}[/yellow]")

        agent_ctx = parsed.to_agent_context(manifest)
        num_features = len(parsed.features)

        console.print(f"\n[bold green]✅ Brownfield spec: {parsed.project_name}[/bold green]")
        console.print(f"   Mode: {parsed.mode}")
        console.print(f"   Features to add: {num_features}")
        if parsed.constraints:
            console.print(f"   Constraints: {len(parsed.constraints)}")
        if parsed.integration_points:
            console.print(f"   Integration points: {len(parsed.integration_points)}")

        console.print("\n[bold]Agent context:[/bold]")
        console.print(agent_ctx)

        # Delegate to initializer for feature creation
        plugin = InitializerPlugin()
        ctx = PluginContext(project_path=project, session_id="add", task_id="add")
        ctx.metadata = {"spec_file": spec}
        result = asyncio.run(plugin.execute(ctx))
        if not result.success:
            console.print(f"[red]Failed: {result.output}[/red]")
            raise typer.Exit(1) from None

        if branch:
            branch_name = parsed.project_name.lower().replace(" ", "-").replace("—", "")
            branch_name = "".join(c for c in branch_name if c.isalnum() or c == "-")
            console.print(f"\n[dim]Suggested git branch: feature/{branch_name}[/dim]")

        console.print(
            f"\n   Next: [bold]claw-forge run --spec {spec} --project {project}[/bold]"
        )
    else:
        # Single feature description
        console.print(f"[bold]Adding feature:[/bold] {feature}")
        console.print(f"[bold]Project:[/bold] {project}")
        if branch:
            slug = feature.lower().replace(" ", "-")[:40]
            console.print(f"[dim]Suggested git branch: feature/{slug}[/dim]")
        console.print("[yellow]Feature addition not yet integrated — scaffold only[/yellow]")


@app.command()
def pause(
    project: str = typer.Argument(..., help="Session ID or project name to pause"),
    port: int = typer.Option(8420, "--port", help="State service port"),
) -> None:
    """Pause a running project (drain mode: finish in-flight agents, start no new ones)."""
    url = f"{_state_url(port)}/project/pause?session_id={project}"
    result = _http_post(url)
    paused = result.get("paused", False)
    if paused:
        console.print(f"[yellow]⏸  Project {project!r} paused.[/yellow]")
        console.print("In-flight agents will complete. No new agents will start.")
        console.print(f"Resume with: [bold]claw-forge resume {project}[/bold]")
    else:
        console.print(f"[red]Unexpected response: {result}[/red]")
        raise typer.Exit(1) from None


@app.command()
def resume(
    project: str = typer.Argument(..., help="Session ID or project name to resume"),
    port: int = typer.Option(8420, "--port", help="State service port"),
) -> None:
    """Resume a paused project — dispatcher starts accepting new tasks again."""
    url = f"{_state_url(port)}/project/resume?session_id={project}"
    result = _http_post(url)
    paused = result.get("paused", True)
    if not paused:
        console.print(f"[green]▶  Project {project!r} resumed.[/green]")
        console.print("Dispatcher is now accepting new tasks.")
    else:
        console.print(f"[red]Unexpected response: {result}[/red]")
        raise typer.Exit(1) from None


@app.command(name="input")
def human_input(
    project: str = typer.Argument(..., help="Session ID or project name"),
    port: int = typer.Option(8420, "--port", help="State service port"),
) -> None:
    """List pending human-input questions and answer them interactively.

    Agents that are stuck POST a question to /features/{id}/human-input.
    This command shows those questions and submits your answers, moving
    the feature back to 'pending' for the dispatcher to retry.
    """
    url = f"{_state_url(port)}/features/needs-human?session_id={project}"
    pending: list[dict[str, Any]] = _http_get(url)  # type: ignore[assignment]

    if not pending:
        console.print(f"[green]✅ No pending human-input questions for {project!r}[/green]")
        return

    console.print(f"[bold yellow]🙋 {len(pending)} pending question(s) for {project!r}:[/bold yellow]\n")  # noqa: E501

    for item in pending:
        task_id = item["task_id"]
        question = item.get("question", "(no question text)")
        description = item.get("description", "")

        console.print(f"[bold]Task:[/bold] {description or task_id}")
        console.print(f"[bold yellow]Q:[/bold yellow] {question}")
        answer = typer.prompt("Your answer")

        answer_url = f"{_state_url(port)}/features/{task_id}/human-answer"
        _http_post(answer_url, json={"answer": answer})
        console.print(f"[green]✅ Answer submitted — task {task_id} moved to pending[/green]\n")

    console.print(f"[green]All questions answered. Project {project!r} can now continue.[/green]")


@app.command()
def ui(
    port: int = typer.Option(
        int(os.environ.get("CLAW_FORGE_UI_PORT", "5173")),
        "--port",
        "-p",
        help="Port for the Kanban UI dev server",
    ),
    state_port: int = typer.Option(  # noqa: E501
        8888, "--state-port", help="Port the state service is running on"
    ),
    open_browser: bool = typer.Option(True, "--open/--no-open", help="Open browser automatically"),
    session: str = typer.Option("", "--session", "-s", help="Session UUID to open on the board"),
) -> None:
    """Launch the Kanban UI (React dev server).

    Requires Node.js and the UI dependencies installed:

        cd ui && npm install

    Starts the Vite dev server and optionally opens the board in your browser.
    The UI connects to the state service at localhost:<state-port>.
    """
    import shutil
    import subprocess

    ui_dir = Path(__file__).parent.parent / "ui"
    if not ui_dir.exists():
        console.print("[red]ui/ directory not found. Is claw-forge installed from source?[/red]")
        raise typer.Exit(1) from None

    if not shutil.which("node"):
        console.print("[red]Node.js not found. Install it from https://nodejs.org/[/red]")
        raise typer.Exit(1) from None

    node_modules = ui_dir / "node_modules"
    if not node_modules.exists():
        console.print("[yellow]Installing UI dependencies (npm install)…[/yellow]")
        subprocess.run(["npm", "install"], cwd=ui_dir, check=True)  # noqa: S603, S607

    url = f"http://localhost:{port}"
    if session:
        url += f"/?session={session}"

    console.print("[bold green]🔥 Starting claw-forge Kanban UI[/bold green]")
    console.print(f"   UI:           [cyan]{url}[/cyan]")
    console.print(f"   State API:    [cyan]http://localhost:{state_port}[/cyan]")
    console.print("   Press [bold]Ctrl+C[/bold] to stop\n")

    env = os.environ.copy()
    env["VITE_API_PORT"] = str(state_port)
    env["VITE_WS_PORT"] = str(state_port)

    if open_browser:
        import threading
        import time
        import webbrowser

        def _open_after_delay() -> None:
            time.sleep(2)  # wait for Vite to start
            webbrowser.open(url)

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

    subprocess.run(  # noqa: S603, S607
        ["npm", "run", "dev", "--", "--port", str(port), "--host"],
        cwd=ui_dir,
        env=env,
        check=False,
    )


@app.command()
def fix(
    description: str | None = typer.Argument(
        None, help="Bug description (use --report for structured reports)"
    ),
    report: str | None = typer.Option(
        None, "--report", "-r",
        help="Path to bug_report.md (generated by /create-bug-report in Claude Code).",
    ),
    project: str = typer.Option(".", "--project", "-p", help="Project directory."),
    model: str = typer.Option(
        "claude-sonnet-4-20250514", "--model", "-m",
        help=(
            "Model to use. Supported formats:\n"
            "  claude-sonnet-4-20250514             bare model (pool picks provider)\n"
            "  anthropic-proxy-1/claude-opus-4-5    pin to specific provider\n"
            "  sonnet                               alias from model_aliases in config"
        ),
    ),
    branch: bool = typer.Option(
        True, "--branch/--no-branch",
        help="Create a fix/<slug> git branch before applying the fix.",
    ),
    config: str = typer.Option(
        "claw-forge.yaml", "--config", "-c",
        help="Path to claw-forge.yaml config file.",
    ),
) -> None:
    """Fix a bug using reproduce-first (RED→GREEN) protocol.

    Writes a regression test that reproduces the bug first, then fixes the root
    cause and verifies the full test suite still passes.

    Examples:

        # Fix from a description
        claw-forge fix 'users get 500 on login with uppercase email'

        # Fix from a structured bug report
        claw-forge fix --report bug_report.md

        # Fix without creating a branch
        claw-forge fix --report bug_report.md --no-branch

        # Fix with a specific model
        claw-forge fix 'search returns empty results' --model claude-opus-4-5
    """
    import re
    import subprocess

    from claw_forge.bugfix.report import BugReport
    from claw_forge.plugins.base import PluginContext
    from claw_forge.plugins.bugfix import BugFixPlugin

    cfg_fix = _load_config(config)
    resolved = resolve_model(model, cfg_fix)
    if resolved.alias_resolved:
        console.print(f"[dim]Model alias '{model}' → {resolved.model_id}[/dim]")
    if resolved.provider_hint:
        console.print(f"[dim]Provider pinned: {resolved.provider_hint}[/dim]")
    model = resolved.model_id

    if report:
        report_path = Path(report)
        if not report_path.exists():
            console.print(f"[red]Report file not found: {report_path}[/red]")
            raise typer.Exit(1) from None
        bug = BugReport.from_file(report_path)
    elif description:
        bug = BugReport.from_description(description)
    else:
        console.print("[red]Error: provide a description or --report[/red]")
        console.print("  claw-forge fix 'users get 500 on login'")
        console.print("  claw-forge fix --report bug_report.md")
        raise typer.Exit(1) from None

    console.print(f"[bold]🐛 Bug:[/bold] {bug.title}")

    if branch:
        slug = re.sub(r"[^a-z0-9]+", "-", bug.title.lower()).strip("-")[:50]
        branch_name = f"fix/{slug}"
        try:
            subprocess.run(  # noqa: S603, S607
                ["git", "checkout", "-b", branch_name],
                cwd=project,
                check=True,
                capture_output=True,
            )
            console.print(f"[dim]Created branch: {branch_name}[/dim]")
        except subprocess.CalledProcessError:
            console.print(
                f"[yellow]⚠ Could not create branch {branch_name!r} (continuing)[/yellow]"
            )

    ctx = PluginContext(
        project_path=project,
        session_id="bugfix",
        task_id=f"fix-{bug.title[:40]}",
        metadata={"bug_report": bug},
    )

    console.print("[cyan]Running bug-fix agent…[/cyan]")
    result = asyncio.run(BugFixPlugin().execute(ctx))

    if result.success:
        console.print("[bold green]✅ Bug fix complete[/bold green]")
        if result.files_modified:
            console.print(f"   Files modified: {', '.join(result.files_modified)}")
        if result.output:
            console.print(result.output[:2000])
    else:
        console.print(f"[red]Fix failed: {result.output}[/red]")
        raise typer.Exit(1) from None


if __name__ == "__main__":
    app()
