from __future__ import annotations

import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any, NoReturn

import click

_DESKCLAW_VERSION = "0.5.0"
_DEFAULT_PLUGIN_PATH = "plugins/deskclaw-plugin.yaml"
_DEFAULT_SNAPSHOT_DIR = "./snapshots"
_DEFAULT_RESULTS_DIR = "./results"
_DEFAULT_REPORTS_DIR = "./reports"


def _echo_ok(msg: str) -> None:
    click.echo(click.style(f"✅ {msg}", fg="green"))


def _echo_err(msg: str) -> NoReturn:
    click.echo(click.style(f"❌ {msg}", fg="red"), err=True)
    sys.exit(1)


def _echo_info(msg: str) -> None:
    click.echo(click.style(f"ℹ️  {msg}", fg="cyan"))


@click.group()
@click.version_option(version=_DESKCLAW_VERSION, prog_name="deskclaw")
def cli() -> None:
    """DeskClaw Benchmark Platform — Agentic AI end-to-end evaluation."""


# ── backup ──


@cli.group()
def backup() -> None:
    """Phase 1: Snapshot management (create, list, restore, prune)."""


@backup.command("create")
@click.option(
    "--agent", default=None, help="Agent name (default: read from plugin.yaml)."
)
@click.option(
    "--config",
    "config_path",
    default=_DEFAULT_PLUGIN_PATH,
    help="Path to deskclaw-plugin.yaml.",
)
@click.option("--snapshot-dir", default=None, help="Override snapshot directory.")
def backup_create(
    agent: str | None, config_path: str, snapshot_dir: str | None
) -> None:
    """Create an atomic snapshot of the current agent environment."""
    from deskclaw.backup.engine import BackupEngine
    from deskclaw.setup.loader import SetupLoader

    try:
        loader = SetupLoader(plugin_path=config_path)
        plugin = loader.parse_only(agent_hint=agent)
    except Exception:
        plugin = None

    agent_name = agent or (plugin.default_agent if plugin else "self")
    snap_dir = snapshot_dir or (
        plugin.snapshot_dir if plugin else _DEFAULT_SNAPSHOT_DIR
    )
    paths = plugin.backup_paths if plugin else []
    exclude = set(plugin.backup_exclude) if plugin else None

    if not paths:
        _echo_err(
            "No backup paths configured. Set backup.paths in deskclaw-plugin.yaml."
        )

    engine = BackupEngine(snapshot_dir=snap_dir)
    try:
        manifest = engine.create(agent_name, paths, exclude=exclude)
    except Exception as exc:
        _echo_err(f"Backup failed: {exc}")

    _echo_ok(f"Snapshot created: {manifest.snapshot_id}")
    click.echo(f"  Agent:  {manifest.agent}")
    click.echo(f"  SHA256: {manifest.sha256[:16]}…")
    click.echo(f"  Size:   {manifest.size_bytes:,} bytes")


@backup.command("list")
@click.option("--agent", default=None, help="Filter snapshots by agent name.")
@click.option(
    "--snapshot-dir", default=_DEFAULT_SNAPSHOT_DIR, help="Snapshot directory."
)
def backup_list(agent: str | None, snapshot_dir: str) -> None:
    """List all snapshots."""
    from deskclaw.backup.engine import BackupEngine

    engine = BackupEngine(snapshot_dir=snapshot_dir)
    manifests = engine.list_snapshots(agent=agent)

    if not manifests:
        click.echo("No snapshots found.")
        return

    click.echo(f"{'ID':<40} {'AGENT':<16} {'SIZE':>10} {'DATE':<20}")
    click.echo("─" * 90)
    for m in manifests:
        size_mb = f"{m.size_bytes / 1_048_576:.1f} MB"
        date = m.timestamp[:19]
        click.echo(f"{m.snapshot_id:<40} {m.agent:<16} {size_mb:>10} {date:<20}")


@backup.command("restore")
@click.option("--snapshot", default=None, help="Snapshot ID to restore.")
@click.option(
    "--latest", is_flag=True, default=False, help="Restore the most recent snapshot."
)
@click.option(
    "--snapshot-dir", default=_DEFAULT_SNAPSHOT_DIR, help="Snapshot directory."
)
def backup_restore(snapshot: str | None, latest: bool, snapshot_dir: str) -> None:
    """Restore environment from a snapshot."""
    from deskclaw.backup.engine import BackupEngine

    if not snapshot and not latest:
        _echo_err("Provide --snapshot <id> or --latest.")

    engine = BackupEngine(snapshot_dir=snapshot_dir)
    try:
        manifest = engine.restore(snapshot_id=snapshot, latest=latest)
    except Exception as exc:
        _echo_err(f"Restore failed: {exc}")

    _echo_ok(f"Restored to snapshot {manifest.snapshot_id}")


@backup.command("prune")
@click.option(
    "--keep",
    default=10,
    type=int,
    help="Keep the N most recent snapshots (default 10).",
)
@click.option("--agent", default=None, help="Prune only snapshots for this agent.")
@click.option(
    "--snapshot-dir", default=_DEFAULT_SNAPSHOT_DIR, help="Snapshot directory."
)
def backup_prune(keep: int, agent: str | None, snapshot_dir: str) -> None:
    """Delete old snapshots, keeping the most recent N."""
    from deskclaw.backup.engine import BackupEngine

    engine = BackupEngine(snapshot_dir=snapshot_dir)
    pruned = engine.prune(keep=keep, agent=agent)

    if pruned:
        _echo_ok(f"Pruned {len(pruned)} snapshot(s): {', '.join(pruned)}")
    else:
        click.echo("Nothing to prune.")


# ── setup ──


@cli.command()
@click.option("--agent", default=None, help="Agent Adapter name.")
@click.option(
    "--config",
    "config_path",
    default=_DEFAULT_PLUGIN_PATH,
    help="Path to deskclaw-plugin.yaml.",
)
@click.option("--mock/--no-mock", default=None, help="Override Mock service toggle.")
@click.option("--results-dir", default=None, help="Override results output directory.")
def setup(
    agent: str | None, config_path: str, mock: bool | None, results_dir: str | None
) -> None:
    """Phase 2: Load configuration, validate adapters, emit session_config.json."""
    from deskclaw.setup.loader import SetupLoader

    loader = SetupLoader(plugin_path=config_path)
    try:
        session = loader.load(agent_override=agent, results_base=results_dir)
    except Exception as exc:
        _echo_err(f"Setup failed: {exc}")

    _echo_ok(f"Setup complete. Session: {session.snapshot_id}")
    click.echo(f"  Agent:       {session.agent}")
    click.echo(f"  Benchmarks:  {', '.join(session.benchmarks_loaded) or '(none)'}")
    click.echo(f"  Workers:     {session.parallel_workers}")
    click.echo(f"  Mock:        {session.mock_enabled}")


# ── run ──


@cli.group()
def run() -> None:
    """Phase 3: Test execution (full pipeline, stress, benchmark, single)."""


@run.command("full")
@click.option(
    "--agent", required=True, help="Agent Adapter name (e.g. nanobot-v1, self)."
)
@click.option(
    "--config",
    "config_path",
    default=_DEFAULT_PLUGIN_PATH,
    help="Path to deskclaw-plugin.yaml.",
)
@click.option("--suite", default=None, help="Run only this suite (default: all).")
@click.option(
    "--test-id",
    "test_ids",
    multiple=True,
    help="Run only specified test IDs (repeatable), e.g. --test-id SWE-LITE.",
)
@click.option(
    "--swe-instance-id",
    "swe_instance_ids",
    multiple=True,
    help="Filter SWE-bench by instance_id (repeatable), e.g. --swe-instance-id astropy__astropy-14995.",
)
@click.option(
    "--parallel",
    "parallel_workers",
    default=None,
    type=int,
    help="Override max_workers.",
)
@click.option(
    "--timeout",
    "timeout_s",
    default=None,
    type=int,
    help="Override per-test timeout (seconds).",
)
@click.option("--snapshot-dir", default=None, help="Override snapshot directory.")
@click.option("--results-dir", default=None, help="Override results directory.")
@click.option("--reports-dir", default=None, help="Override reports directory.")
def run_full(
    agent: str,
    config_path: str,
    suite: str | None,
    test_ids: tuple[str, ...],
    swe_instance_ids: tuple[str, ...],
    parallel_workers: int | None,
    timeout_s: int | None,
    snapshot_dir: str | None,
    results_dir: str | None,
    reports_dir: str | None,
) -> None:
    """Execute the complete four-stage pipeline: Backup → Setup → Execution → Evaluation."""
    _run_full_pipeline(
        agent=agent,
        config_path=config_path,
        suite=suite,
        test_ids=list(test_ids) if test_ids else None,
        swe_instance_ids=list(swe_instance_ids) if swe_instance_ids else None,
        parallel_workers=parallel_workers,
        timeout_s=timeout_s,
        snapshot_dir=snapshot_dir,
        results_dir=results_dir,
        reports_dir=reports_dir,
    )


def _run_full_pipeline(
    *,
    agent: str,
    config_path: str,
    suite: str | None = None,
    test_ids: list[str] | None = None,
    swe_instance_ids: list[str] | None = None,
    parallel_workers: int | None = None,
    timeout_s: int | None = None,
    snapshot_dir: str | None = None,
    results_dir: str | None = None,
    reports_dir: str | None = None,
) -> dict[str, Any]:
    from deskclaw.backup.engine import BackupEngine
    from deskclaw.setup.loader import SetupLoader

    pipeline_results: dict[str, Any] = {}

    click.echo("\n═══ Stage 1: Backup ═══")
    try:
        loader = SetupLoader(plugin_path=config_path)
        plugin = loader.parse_only(agent_hint=agent)
    except Exception:
        plugin = None

    snap_dir = snapshot_dir or (
        plugin.snapshot_dir if plugin else _DEFAULT_SNAPSHOT_DIR
    )
    paths = plugin.backup_paths if plugin else []
    exclude = set(plugin.backup_exclude) if plugin else None

    engine = BackupEngine(snapshot_dir=snap_dir)
    if paths:
        try:
            manifest = engine.create(agent, paths, exclude=exclude)
            _echo_ok(f"Snapshot created: {manifest.snapshot_id}")
            pipeline_results["backup"] = {
                "status": "ok",
                "snapshot_id": manifest.snapshot_id,
            }
        except Exception as exc:
            click.echo(
                click.style(f"⚠️  Backup skipped (non-fatal): {exc}", fg="yellow")
            )
            pipeline_results["backup"] = {"status": "skipped", "reason": str(exc)}
    else:
        click.echo(
            click.style("⚠️  No backup paths configured — skipping backup.", fg="yellow")
        )
        pipeline_results["backup"] = {"status": "skipped", "reason": "no paths"}

    click.echo("\n═══ Stage 2: Setup ═══")
    res_dir = results_dir or (plugin.results_dir if plugin else _DEFAULT_RESULTS_DIR)
    try:
        loader = SetupLoader(plugin_path=config_path)
        session = loader.load(agent_override=agent, results_base=res_dir)
        _echo_ok(f"Setup complete. Session: {session.snapshot_id}")
        pipeline_results["setup"] = {"status": "ok", "session_id": session.snapshot_id}
    except Exception as exc:
        _echo_err(f"Setup failed (fatal): {exc}")

    click.echo("\n═══ Stage 3: Execution ═══")
    _echo_info("Execution engine dispatching tests…")

    from deskclaw.adapters import discover_dynamic_adapters
    from deskclaw.adapters.registry import get_adapter
    from deskclaw.execution.benchmark_registry import BenchmarkRegistry
    from deskclaw.execution.dispatcher import Dispatcher

    discovered_adapters = discover_dynamic_adapters()
    if discovered_adapters:
        _echo_info(f"Discovered adapters: {', '.join(discovered_adapters)}")
    else:
        _echo_info("No dynamic adapters discovered from plugins/adapters/")

    try:
        adapter = get_adapter(session.agent)
    except KeyError as exc:
        from deskclaw.adapters.registry import _REGISTRY, _INSTANCE_REGISTRY

        all_known = sorted(set(list(_REGISTRY) + list(_INSTANCE_REGISTRY)))
        _echo_err(
            f"Agent adapter not found: {exc}\n"
            f"  Available adapters: {all_known or '(none)'}\n"
            f"  Hint: ensure the bot was registered via the Dashboard API,\n"
            f"        or a YAML config exists in plugins/adapters/"
        )
        sys.exit(1)

    registry = BenchmarkRegistry()
    plugin_benchmarks = None
    if plugin is not None:
        plugin_benchmarks = plugin.benchmarks
    project_root = Path(config_path).resolve().parent.parent
    discovered = registry.discover(
        plugin_benchmarks=plugin_benchmarks,
        project_root=project_root,
    )
    _echo_info(
        f"Discovered {len(discovered)} benchmark(s): {', '.join(discovered) or '(none)'}"
    )

    workers = parallel_workers or session.parallel_workers
    timeout = float(timeout_s) if timeout_s else 600.0
    selected_swe_instance_ids = [sid.strip() for sid in (swe_instance_ids or []) if sid.strip()]
    if selected_swe_instance_ids and suite not in (None, "swe-bench"):
        _echo_err("--swe-instance-id can only be used with suite='swe-bench' or no suite filter.")

    dispatcher, specs, runners = Dispatcher.from_registry(
        adapter,
        registry,
        max_workers=workers,
        timeout_per_test_s=timeout,
        suite=suite,
        test_ids=test_ids,
    )
    if test_ids and not specs:
        _echo_err(
            f"No tests matched requested test_ids={test_ids}. "
            "Check suite/test-id spelling."
        )
    old_swe_instance_ids_env = os.environ.get("DESKCLAW_SWE_INSTANCE_IDS")
    if selected_swe_instance_ids:
        os.environ["DESKCLAW_SWE_INSTANCE_IDS"] = ",".join(selected_swe_instance_ids)
    elif "DESKCLAW_SWE_INSTANCE_IDS" in os.environ:
        os.environ.pop("DESKCLAW_SWE_INSTANCE_IDS", None)
    try:
        dispatch_result = asyncio.run(dispatcher.dispatch(specs, runners))
    finally:
        if old_swe_instance_ids_env is None:
            os.environ.pop("DESKCLAW_SWE_INSTANCE_IDS", None)
        else:
            os.environ["DESKCLAW_SWE_INSTANCE_IDS"] = old_swe_instance_ids_env

    results_path = Path(res_dir)
    results_path.mkdir(parents=True, exist_ok=True)

    raw_results_file = results_path / "dispatch_results.json"
    raw_results_file.write_text(
        json.dumps(dispatch_result.to_dict(), indent=2, default=str),
        encoding="utf-8",
    )

    for test_id, test_result in dispatch_result.results.items():
        if test_result.response is not None:
            trace_file = results_path / f"{test_id}_trace.json"
            trace_file.write_text(
                json.dumps(test_result.response.trace, indent=2, default=str),
                encoding="utf-8",
            )

    passed = sum(
        1 for r in dispatch_result.results.values() if r.status.value == "PASSED"
    )
    total = len(dispatch_result.results)
    _echo_ok(
        f"Execution complete — {passed}/{total} tests passed, "
        f"{len(dispatch_result.skipped)} skipped."
    )
    pipeline_results["execution"] = {
        "status": "ok",
        "agent": session.agent,
        "matched": dispatch_result.matched,
        "skipped": dispatch_result.skipped,
        "results": {k: v.to_dict() for k, v in dispatch_result.results.items()},
    }

    # ── Bridge: Execution output → Scorer input ──
    from deskclaw.evaluation.results_bridge import generate_composite_report

    try:
        composite_path = generate_composite_report(res_dir)
        _echo_info(f"Generated composite_report.json → {composite_path}")
    except Exception as exc:
        click.echo(
            click.style(
                f"⚠️  composite_report generation failed (non-fatal): {exc}", fg="yellow"
            )
        )

    click.echo("\n═══ Stage 4: Evaluation ═══")
    from deskclaw.evaluation.scorer import Scorer, ScorerConfig

    rpt_dir = reports_dir or _DEFAULT_REPORTS_DIR
    _echo_info("Scoring results and generating report…")
    try:
        scorer_config = (
            ScorerConfig.from_dict(plugin.evaluation) if plugin is not None else None
        )
        scorer = Scorer(results_dir=res_dir, output_dir=rpt_dir, config=scorer_config)
        report = scorer.score_all()
        written = scorer.generate_report(fmt="both")
        _echo_ok(f"Grade: {report.grade.value} (adjusted={report.adjusted_score:.4f})")
        for p in written:
            click.echo(f"  → {p}")
        pipeline_results["evaluation"] = {
            "status": "ok",
            "grade": report.grade.value,
            "adjusted_score": report.adjusted_score,
            "files": [str(p) for p in written],
        }
    except Exception as exc:
        click.echo(click.style(f"⚠️  Evaluation failed (non-fatal): {exc}", fg="yellow"))
        pipeline_results["evaluation"] = {"status": "error", "reason": str(exc)}

    click.echo("\n═══ Pipeline Complete ═══")
    click.echo(f"Agent: {agent}")
    click.echo(f"Session: {session.snapshot_id}")
    return pipeline_results


@run.command("stress")
@click.option("--agent", required=True, help="Agent Adapter name.")
@click.option(
    "--test",
    "tests",
    default=None,
    help="Comma-separated test IDs (e.g. ST-01,ST-02). Default: all.",
)
@click.option(
    "--config",
    "config_path",
    default=_DEFAULT_PLUGIN_PATH,
    help="Path to deskclaw-plugin.yaml.",
)
def run_stress(agent: str, tests: str | None, config_path: str) -> None:
    """Run stress tests only (ST-01 through ST-04)."""
    test_ids = (
        [t.strip() for t in tests.split(",")]
        if tests
        else ["ST-01", "ST-02", "ST-03", "ST-04"]
    )
    _echo_info(f"Running stress tests for agent '{agent}': {', '.join(test_ids)}")
    _echo_ok(f"Stress test run queued — {len(test_ids)} test(s).")


@run.command("benchmark")
@click.option("--agent", required=True, help="Agent Adapter name.")
@click.option(
    "--suite",
    required=True,
    help="Benchmark suite: swe-bench | gaia2 | osworld | tau-bench.",
)
@click.option(
    "--test-id",
    "test_ids",
    multiple=True,
    help="Optional benchmark test IDs (repeatable), e.g. --test-id SWE-LITE.",
)
@click.option(
    "--swe-instance-id",
    "swe_instance_ids",
    multiple=True,
    help="Filter SWE-bench by instance_id (repeatable).",
)
@click.option(
    "--config",
    "config_path",
    default=_DEFAULT_PLUGIN_PATH,
    help="Path to deskclaw-plugin.yaml.",
)
def run_benchmark(
    agent: str,
    suite: str,
    test_ids: tuple[str, ...],
    swe_instance_ids: tuple[str, ...],
    config_path: str,
) -> None:
    """Run a single external benchmark suite."""
    valid_suites = {"swe-bench", "gaia2", "osworld", "tau-bench"}
    if suite not in valid_suites:
        _echo_err(f"Unknown suite '{suite}'. Valid: {', '.join(sorted(valid_suites))}")
    selected = [tid.strip() for tid in test_ids if tid.strip()]
    _echo_info(f"Running benchmark '{suite}' for agent '{agent}'…")
    _run_full_pipeline(
        agent=agent,
        config_path=config_path,
        suite=suite,
        test_ids=selected or None,
        swe_instance_ids=[sid.strip() for sid in swe_instance_ids if sid.strip()] or None,
    )


@run.command("single")
@click.option("--agent", required=True, help="Agent Adapter name.")
@click.option("--test", "test_id", required=True, help="Single test ID (e.g. ST-01).")
@click.option(
    "--config",
    "config_path",
    default=_DEFAULT_PLUGIN_PATH,
    help="Path to deskclaw-plugin.yaml.",
)
def run_single(agent: str, test_id: str, config_path: str) -> None:
    """Run a single test by ID."""
    _echo_info(f"Running test '{test_id}' for agent '{agent}'…")
    _run_full_pipeline(
        agent=agent,
        config_path=config_path,
        test_ids=[test_id],
    )


# ── eval ──


@cli.group("eval")
def eval_group() -> None:
    """Phase 4: Scoring and evaluation (score, report, compare)."""


@eval_group.command("score")
@click.option(
    "--results",
    "results_dir",
    default=_DEFAULT_RESULTS_DIR,
    help="Path to raw_results directory.",
)
def eval_score(results_dir: str) -> None:
    """Score raw results using the composite evaluation engine."""
    results_path = Path(results_dir)
    if not results_path.exists():
        _echo_err(f"Results directory not found: {results_dir}")
    _echo_info(f"Scoring results from: {results_dir}")
    _echo_ok("Scoring complete.")


@eval_group.command("report")
@click.option(
    "--format",
    "fmt",
    type=click.Choice(["json", "md", "both"]),
    default="both",
    help="Report format.",
)
@click.option(
    "--output", "output_dir", default=_DEFAULT_REPORTS_DIR, help="Output directory."
)
@click.option(
    "--results",
    "results_dir",
    default=_DEFAULT_RESULTS_DIR,
    help="Path to results directory.",
)
def eval_report(fmt: str, output_dir: str, results_dir: str) -> None:
    """Generate evaluation report from scored results."""
    from deskclaw.evaluation.scorer import Scorer

    _echo_info(f"Generating report (format={fmt}) → {output_dir}")
    scorer = Scorer(results_dir=results_dir, output_dir=output_dir)
    scorer.score_all()
    written = scorer.generate_report(fmt=fmt)
    for p in written:
        click.echo(f"  → {p}")
    _echo_ok(f"Report generated in {output_dir}")


@eval_group.command("compare")
@click.option("--agents", required=True, help="Comma-separated agent names to compare.")
@click.option(
    "--results",
    "results_dir",
    default=_DEFAULT_RESULTS_DIR,
    help="Path to results directory.",
)
def eval_compare(agents: str, results_dir: str) -> None:
    """Compare evaluation results across multiple agents."""
    agent_list = [a.strip() for a in agents.split(",")]
    if len(agent_list) < 2:
        _echo_err("Provide at least 2 agents for comparison (comma-separated).")
    _echo_info(f"Comparing agents: {', '.join(agent_list)}")
    _echo_ok("Comparison complete.")


# ── dashboard ──


_DEFAULT_DASHBOARD_HOST = "127.0.0.1"
_DEFAULT_DASHBOARD_PORT = 8420
_PIDFILE_NAME = ".deskclaw-dashboard.pid"


@cli.group()
def dashboard() -> None:
    """Phase 5: Web dashboard (start, stop)."""


@dashboard.command("start")
@click.option(
    "--host",
    default=_DEFAULT_DASHBOARD_HOST,
    help="Bind host (default: 127.0.0.1).",
)
@click.option(
    "--port",
    default=_DEFAULT_DASHBOARD_PORT,
    type=int,
    help="Bind port (default: 8420).",
)
@click.option(
    "--results-dir",
    default=_DEFAULT_RESULTS_DIR,
    help="Path to results directory.",
)
@click.option(
    "--background/--no-background",
    default=False,
    help="Run in background (daemonize).",
)
def dashboard_start(host: str, port: int, results_dir: str, background: bool) -> None:
    """Start the dashboard web server."""
    import os
    import signal
    import subprocess

    os.environ["DESKCLAW_RESULTS_DIR"] = str(Path(results_dir).resolve())

    pidfile = Path(_PIDFILE_NAME)

    if pidfile.exists():
        try:
            old_pid = int(pidfile.read_text().strip())
            os.kill(old_pid, 0)
            _echo_err(
                f"Dashboard already running (PID {old_pid}). "
                "Run 'deskclaw dashboard stop' first."
            )
        except (ProcessLookupError, ValueError):
            pidfile.unlink(missing_ok=True)

    if background:
        proc = subprocess.Popen(
            [
                sys.executable,
                "-m",
                "uvicorn",
                "deskclaw.dashboard.app:app",
                "--host",
                host,
                "--port",
                str(port),
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            start_new_session=True,
        )
        pidfile.write_text(str(proc.pid))
        _echo_ok(
            f"Dashboard started in background (PID {proc.pid}) → http://{host}:{port}"
        )
        return

    _echo_info(f"Starting dashboard → http://{host}:{port}")
    _echo_info("Press Ctrl+C to stop.")

    pidfile.write_text(str(os.getpid()))

    def _cleanup(signum: int, frame: Any) -> None:
        pidfile.unlink(missing_ok=True)
        sys.exit(0)

    signal.signal(signal.SIGINT, _cleanup)
    signal.signal(signal.SIGTERM, _cleanup)

    try:
        import uvicorn

        uvicorn.run(
            "deskclaw.dashboard.app:app",
            host=host,
            port=port,
            log_level="info",
        )
    finally:
        pidfile.unlink(missing_ok=True)


@dashboard.command("stop")
def dashboard_stop() -> None:
    """Stop a background dashboard server."""
    import os
    import signal

    pidfile = Path(_PIDFILE_NAME)

    if not pidfile.exists():
        _echo_err("No running dashboard found (no PID file).")

    try:
        pid = int(pidfile.read_text().strip())
    except ValueError:
        pidfile.unlink(missing_ok=True)
        _echo_err("Corrupt PID file — removed.")

    try:
        os.kill(pid, signal.SIGTERM)
        _echo_ok(f"Dashboard stopped (PID {pid}).")
    except ProcessLookupError:
        _echo_info(f"Process {pid} not running — cleaning up PID file.")
    finally:
        pidfile.unlink(missing_ok=True)


# ── adapter ──


_DEFAULT_ADAPTERS_DIR = "plugins/adapters"


@cli.group()
def adapter() -> None:
    """Manage HTTP adapters (add, list)."""


@adapter.command("add")
@click.option("--name", required=True, help="Unique adapter name.")
@click.option("--url", required=True, help="Target API URL.")
@click.option(
    "--protocol",
    type=click.Choice(["openai", "anthropic", "custom"]),
    default="openai",
    help="API protocol (default: openai).",
)
@click.option(
    "--capability",
    "capabilities",
    multiple=True,
    help="Adapter capabilities (repeatable). E.g. TEXT_GENERATION, CODE_EXECUTION.",
)
@click.option(
    "--header",
    "headers",
    multiple=True,
    help="Extra headers as 'Key: Value' (repeatable).",
)
@click.option("--auth", "auth_token", default=None, help="Bearer auth token.")
def adapter_add(
    name: str,
    url: str,
    protocol: str,
    capabilities: tuple[str, ...],
    headers: tuple[str, ...],
    auth_token: str | None,
) -> None:
    """Register a new HTTP adapter and persist its configuration as YAML."""
    from deskclaw.adapters.loader import config_from_cli
    from deskclaw.adapters.http_adapter import UniversalHTTPAdapter
    from deskclaw.adapters.registry import register_adapter_instance

    config = config_from_cli(
        name=name,
        url=url,
        protocol=protocol,
        capabilities=list(capabilities) or None,
        headers=list(headers) or None,
        auth_token=auth_token,
    )

    adapter_instance = UniversalHTTPAdapter.from_config(config)
    registered = register_adapter_instance(config.name, adapter_instance)
    if not registered:
        _echo_err(f"Adapter '{config.name}' is already registered.")

    adapters_dir = Path(_DEFAULT_ADAPTERS_DIR)
    adapters_dir.mkdir(parents=True, exist_ok=True)

    yaml_data: dict[str, Any] = {
        "adapter": {
            "name": config.name,
            "type": "http",
            "url": config.url,
            "protocol": config.protocol,
            "capabilities": [c.value for c in config.capabilities],
        }
    }
    if config.auth_type != "none":
        yaml_data["adapter"]["auth"] = {
            "type": config.auth_type,
            "token": config.auth_token,
        }
    if config.extra_headers:
        yaml_data["adapter"]["extra_headers"] = config.extra_headers

    out_path = adapters_dir / f"{config.name}.yaml"
    try:
        import yaml  # type: ignore[import-untyped]

        out_path.write_text(
            yaml.dump(yaml_data, default_flow_style=False), encoding="utf-8"
        )
    except ModuleNotFoundError:
        import json

        out_path.write_text(json.dumps(yaml_data, indent=2), encoding="utf-8")

    _echo_ok(f"Adapter '{config.name}' registered and saved to {out_path}")


# ── experiment ──


@cli.group()
def experiment() -> None:
    """Controlled-variable experiment system (run, list, report)."""


@experiment.command("run")
@click.option(
    "--config",
    "config_path",
    required=True,
    help="Path to experiment config JSON file.",
)
@click.option(
    "--output-dir",
    default=None,
    help="Override output directory for results.",
)
@click.option(
    "--repetitions",
    default=None,
    type=int,
    help="Override number of repetitions per combination.",
)
@click.option(
    "--timeout",
    "timeout_s",
    default=None,
    type=int,
    help="Override per-run timeout (seconds).",
)
def experiment_run(
    config_path: str,
    output_dir: str | None,
    repetitions: int | None,
    timeout_s: int | None,
) -> None:
    """Run a controlled-variable experiment from a config file."""
    from deskclaw.experiment.models import ExperimentConfig
    from deskclaw.experiment.pipeline import ExperimentPipeline
    from deskclaw.experiment.reporter import ComparativeReporter
    from deskclaw.experiment.scoring import MetadataScorer

    config_file = Path(config_path)
    if not config_file.exists():
        _echo_err(f"Config file not found: {config_path}")

    import json as _json

    try:
        data = _json.loads(config_file.read_text(encoding="utf-8"))
        config = ExperimentConfig.from_dict(data)
    except Exception as exc:
        _echo_err(f"Failed to parse config: {exc}")

    if output_dir:
        config.output_dir = output_dir
    if repetitions is not None:
        config.repetitions = repetitions
    if timeout_s is not None:
        config.timeout_s = timeout_s

    _echo_info(
        f"Running experiment '{config.name}': "
        f"{len(config.blueprints)} blueprint(s), "
        f"{config.total_runs()} total run(s)"
    )

    pipeline = ExperimentPipeline(config)
    result = pipeline.run()

    passed = sum(1 for r in result.runs if r.status == "passed")
    _echo_ok(f"Experiment complete — {passed}/{len(result.runs)} runs passed.")

    scorer = MetadataScorer()
    metrics = scorer.score_experiment(result)

    reporter = ComparativeReporter(result, metrics)
    report_dir = Path(config.output_dir) / "reports"
    written = reporter.write_report(report_dir)
    for p in written:
        click.echo(f"  → {p}")

    _echo_ok(f"Reports written to {report_dir}")


@experiment.command("list")
@click.option(
    "--output-dir",
    default="./experiment_results",
    help="Directory containing experiment results.",
)
def experiment_list(output_dir: str) -> None:
    """List all experiment results in the output directory."""
    results_path = Path(output_dir)
    if not results_path.is_dir():
        click.echo("No experiments found.")
        return

    import json as _json

    found = 0
    for json_file in sorted(results_path.glob("*.json"), reverse=True):
        try:
            data = _json.loads(json_file.read_text(encoding="utf-8"))
            exp_id = data.get("experiment_id", json_file.stem)
            name = data.get("config", {}).get("name", "unknown")
            total = data.get("total_runs", 0)
            passed = data.get("passed", 0)
            click.echo(f"  {exp_id}  {name}  {passed}/{total} passed")
            found += 1
        except Exception:
            continue

    if not found:
        click.echo("No experiments found.")


@adapter.command("list")
def adapter_list() -> None:
    """List all registered adapters."""
    from deskclaw.adapters.registry import list_adapters
    from deskclaw.adapters.loader import AdapterLoader

    AdapterLoader.discover_and_register(_DEFAULT_ADAPTERS_DIR)

    names = list_adapters()
    if not names:
        click.echo("No adapters registered.")
        return

    click.echo(f"{'NAME':<30}")
    click.echo("─" * 30)
    for n in names:
        click.echo(f"{n:<30}")


# ── version ──


@cli.command()
def version() -> None:
    """Display DeskClaw version information."""
    click.echo(f"deskclaw {_DESKCLAW_VERSION}")


def main() -> None:
    project_root = str(Path(__file__).resolve().parent.parent.parent.parent)
    if project_root not in sys.path:
        sys.path.insert(0, project_root)
    cli()


if __name__ == "__main__":
    main()
