"""clawbench pipeline API — JSON backend for the envdash Pipeline UI."""

from __future__ import annotations

import difflib
import json
import os
import subprocess
import sys
import tomllib
from pathlib import Path

import httpx
import yaml
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware

from clawbench._paths import _PROJECT_ROOT
from clawbench.task_utils import discover_tasks

# Module-level cache: task name → needle manifest path (set by seed, used by solve/evaluate)
_manifest_paths: dict[str, str] = {}

# Module-level cache for loaded run data
_run_data: dict | None = None
_output_dir: Path | None = None
_exp_cache: dict[str, dict] = {}  # {experiment_name: run_data}

# Track running optimization processes (keyed by experiment name)
_experiments: dict[str, dict] = {}  # {name: {"process": Popen, "log": []}}


def _normalize_train(iter_results: dict) -> dict:
    """Normalize the train split in iteration results.

    Convention:
      - GEPA engine: does not evaluate a train split — writes train=null.
      - Custom-loop engine: evaluates both train and val — writes real data.
      - Stale GEPA data (pre-2026-03-20) wrote train as a zeroed-out dict
        instead of null.

    This function collapses an all-zero train block to null so the UI can
    use a single rule: show train iff train is not null.  Scoring follows
    the same rule: use train when present, otherwise val.
    """
    train = iter_results.get("train")
    if train is not None:
        results = train.get("results") or []
        if all(r.get("avg_reward", 0) == 0 for r in results):
            iter_results["train"] = None
    return iter_results


def load_run(output_dir: Path) -> dict:
    """Load all optimization output files into a single dict."""
    results_path = output_dir / "results.json"
    if not results_path.exists():
        return {"error": f"No results.json in {output_dir}"}

    results = json.loads(results_path.read_text())

    # Load best skill
    best_skill_path = output_dir / "best_SKILL.md"
    results["best_skill_content"] = (
        best_skill_path.read_text() if best_skill_path.exists() else ""
    )

    # Load initial (baseline) skill for diff views
    initial_skill_path = output_dir / "skill_iter_0" / "SKILL.md"
    results["initial_skill"] = (
        initial_skill_path.read_text() if initial_skill_path.exists() else ""
    )

    # Load run config (launch command, params)
    run_config_path = output_dir / "run_config.json"
    if run_config_path.exists():
        try:
            results["run_config"] = json.loads(run_config_path.read_text())
        except (json.JSONDecodeError, OSError):
            pass

    # Load per-iteration data
    skipped_iters = {
        h["iteration"]
        for h in results.get("history", [])
        if h.get("skipped")
    }
    iterations = []
    # Start from 0 to include the seed baseline if iter_0_results.json or
    # skill_iter_0 exists on disk (works for both old and new experiments).
    has_seed = (output_dir / "iter_0_results.json").exists() or (output_dir / "skill_iter_0").exists()
    start = 0 if has_seed else 1
    for i in range(start, results.get("iterations_run", 0) + 1):
        iter_data = {"iteration": i}
        if i in skipped_iters:
            iter_data["skipped"] = True

        iter_results_path = output_dir / f"iter_{i}_results.json"
        if iter_results_path.exists():
            iter_data["results"] = _normalize_train(
                json.loads(iter_results_path.read_text())
            )

        skill_path = output_dir / f"skill_iter_{i}" / "SKILL.md"
        if skill_path.exists():
            iter_data["skill_content"] = skill_path.read_text()
        else:
            iter_data["skill_content"] = ""

        log_path = output_dir / "logs" / f"improve_iter_{i}.json"
        if log_path.exists():
            iter_data["optimizer_log"] = json.loads(log_path.read_text())

        # Trajectories loaded on-demand via /api/iteration/{n}/trajectories

        iterations.append(iter_data)

    results["iterations"] = iterations
    return results


def create_app(output_dir: Path) -> FastAPI:
    """Create the pipeline API app."""
    global _run_data, _output_dir
    _output_dir = output_dir
    _run_data = load_run(output_dir)

    app = FastAPI(title="clawbench Pipeline API")

    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    # --- Run data ---

    @app.get("/api/run")
    def api_run(experiment: str | None = None):
        """Full run data as JSON (served from cache — use POST /api/reload to refresh)."""
        data = _resolve_data(experiment)
        if not data or "error" in data:
            raise HTTPException(404, (data or {}).get("error", "No data loaded"))
        return data

    @app.post("/api/reload")
    def api_reload(experiment: str | None = None):
        """Reload data from disk into cache and return it."""
        global _run_data
        if experiment and experiment != "default":
            exp_dir = _output_dir / experiment
            if exp_dir.is_dir():
                _exp_cache[experiment] = load_run(exp_dir)
                data = _exp_cache[experiment]
            else:
                data = None
        else:
            _run_data = load_run(_output_dir)
            data = _run_data
        if not data or "error" in data:
            raise HTTPException(404, (data or {}).get("error", "No data loaded"))
        return data

    @app.get("/api/experiments")
    def api_list_experiments():
        """List experiments (root + subdirectories) with results and per-iteration scores."""

        def _build_entry(d: Path, name: str) -> dict | None:
            results_path = d / "results.json"
            if not results_path.exists():
                return None
            try:
                results = json.loads(results_path.read_text())
                # Build a set of skipped iteration numbers from history
                skipped_iters = {
                    h["iteration"]
                    for h in results.get("history", [])
                    if h.get("skipped")
                }
                scores = []
                has_seed = (d / "iter_0_results.json").exists() or (d / "skill_iter_0").exists()
                start = 0 if has_seed else 1
                for i in range(start, results.get("iterations_run", 0) + 1):
                    if i in skipped_iters:
                        scores.append(None)
                        continue
                    iter_path = d / f"iter_{i}_results.json"
                    if iter_path.exists():
                        try:
                            ir = _normalize_train(
                                json.loads(iter_path.read_text())
                            )
                            train = ir.get("train")
                            val = ir.get("val") or {}
                            # Prefer train when present (custom-loop); fall
                            # back to val (GEPA, or when train is null).
                            reward = (
                                train.get("avg_reward", 0)
                                if train is not None
                                else val.get("avg_reward", 0)
                            ) or 0
                            scores.append(reward)
                        except (json.JSONDecodeError, OSError):
                            scores.append(0)
                    else:
                        scores.append(0)
                # Read run config if available (launch command, params)
                run_config_path = d / "run_config.json"
                run_config = None
                if run_config_path.exists():
                    try:
                        run_config = json.loads(run_config_path.read_text())
                    except (json.JSONDecodeError, OSError):
                        pass

                entry: dict = {
                    "name": name,
                    "iterations": results.get("iterations_run", 0),
                    "best_reward": results.get("best_avg_reward"),
                    "best_iteration": results.get("best_iteration"),
                    "skill": results.get("task_name", name),
                    "scores": scores,
                }
                if run_config:
                    entry["run_config"] = run_config
                return entry
            except (json.JSONDecodeError, OSError):
                return {"name": name, "iterations": 0, "scores": []}

        experiments = []
        if _output_dir and _output_dir.exists():
            # Include root output dir as "default" experiment
            root_entry = _build_entry(_output_dir, "default")
            if root_entry:
                experiments.append(root_entry)
            # Include subdirectories
            for d in sorted(_output_dir.iterdir()):
                if d.is_dir():
                    entry = _build_entry(d, d.name)
                    if entry:
                        experiments.append(entry)
        return {"experiments": experiments}

    def _resolve_data(experiment: str | None) -> dict:
        """Resolve run data for a given experiment (or root).

        Re-reads from disk when the experiment is still running so the
        dashboard iteration table stays current.  Completed experiments
        are served from cache.
        """
        global _run_data
        if experiment and experiment != "default":
            cached = _exp_cache.get(experiment)
            if cached is None or cached.get("status") == "running":
                exp_dir = _output_dir / experiment
                if exp_dir.is_dir():
                    _exp_cache[experiment] = load_run(exp_dir)
            return _exp_cache.get(experiment, {})
        if _run_data is None or _run_data.get("status") == "running":
            _run_data = load_run(_output_dir)
        return _run_data or {}

    @app.get("/api/iteration/{n}")
    def api_iteration(n: int, experiment: str | None = None):
        """Single iteration data as JSON."""
        data = _resolve_data(experiment)
        iterations = data.get("iterations", []) if data else []
        # Look up by iteration number (not positional index)
        iter_map = {it["iteration"]: (idx, it) for idx, it in enumerate(iterations)}
        if n not in iter_map:
            raise HTTPException(404, "Iteration not found")
        idx, iter_data = iter_map[n]
        if idx == 0:
            prev_skill = data.get("initial_skill", "")
        else:
            prev_skill = iterations[idx - 1].get("skill_content", "")
        return {**iter_data, "prev_skill": prev_skill}

    def _load_trajectories(n: int, experiment: str | None) -> dict:
        """Load trajectories from disk on-demand (not cached)."""
        exp_dir = _output_dir
        if experiment and experiment != "default":
            exp_dir = _output_dir / experiment
        traj_path = exp_dir / f"iter_{n}_trajectories.json"
        if traj_path.exists():
            try:
                return json.loads(traj_path.read_text())
            except (json.JSONDecodeError, OSError):
                pass
        return {}

    @app.get("/api/iteration/{n}/trajectories")
    def api_iteration_trajectories(n: int, experiment: str | None = None):
        """Trajectory data for a specific iteration (loaded from disk on-demand)."""
        data = _resolve_data(experiment)
        iterations = data.get("iterations", []) if data else []
        if not any(it["iteration"] == n for it in iterations):
            raise HTTPException(404, "Iteration not found")
        trajectories = _load_trajectories(n, experiment)
        return {"iteration": n, "trajectories": trajectories}

    @app.get("/api/iteration/{n}/task/{name}/trajectory")
    def api_task_trajectory(n: int, name: str, experiment: str | None = None):
        """Trajectory data for a specific task in a specific iteration."""
        data = _resolve_data(experiment)
        iterations = data.get("iterations", []) if data else []
        if not any(it["iteration"] == n for it in iterations):
            raise HTTPException(404, "Iteration not found")
        trajectories = _load_trajectories(n, experiment)
        task_traj = trajectories.get(name)
        if task_traj is None:
            raise HTTPException(404, f"No trajectory for task '{name}' in iteration {n}")
        return {"iteration": n, "task_name": name, "runs": task_traj}

    @app.get("/api/diff-json/{a}/{b}")
    def api_diff_json(a: int, b: int, experiment: str | None = None):
        """Unified diff lines between two iterations' skills."""
        data = _resolve_data(experiment)
        iters = data.get("iterations", [])
        iter_map = {it["iteration"]: it for it in iters}
        if a not in iter_map or b not in iter_map:
            raise HTTPException(400, "Invalid iteration range")

        skill_a = iter_map[a].get("skill_content", "")
        skill_b = iter_map[b].get("skill_content", "")

        diff_lines = list(difflib.unified_diff(
            skill_a.splitlines(keepends=True),
            skill_b.splitlines(keepends=True),
            fromfile=f"Iteration {a}",
            tofile=f"Iteration {b}",
            lineterm="",
        ))

        return {"from": a, "to": b, "lines": diff_lines}

    # --- Repo-level artifacts (skills, prompts, tasks) ---

    def _parse_frontmatter(text: str) -> tuple[dict, str]:
        """Parse YAML frontmatter from markdown. Returns (meta, body)."""
        if text.startswith("---"):
            end = text.find("---", 3)
            if end != -1:
                try:
                    meta = yaml.safe_load(text[3:end]) or {}
                except Exception:
                    meta = {}
                body = text[end + 3:].lstrip("\n")
                return meta, body
        return {}, text

    @app.get("/api/skills")
    def api_skills():
        """Return all skills from skills/ with full file contents."""
        skills_dir = _PROJECT_ROOT / "skills"
        skills = []
        if skills_dir.exists():
            for skill_dir in sorted(skills_dir.iterdir()):
                skill_file = skill_dir / "SKILL.md"
                if not skill_dir.is_dir() or not skill_file.exists():
                    continue
                content = skill_file.read_text()
                meta, _ = _parse_frontmatter(content)
                # Collect all files in the skill directory
                files = []
                for f in sorted(skill_dir.rglob("*")):
                    if f.is_file():
                        rel = str(f.relative_to(skill_dir))
                        try:
                            fc = f.read_text()
                        except (UnicodeDecodeError, OSError):
                            fc = f"<binary: {f.stat().st_size} bytes>"
                        files.append({"path": rel, "content": fc, "size": f.stat().st_size})
                skills.append({
                    "name": skill_dir.name,
                    "type": meta.get("type", "tool"),
                    "description": meta.get("description", ""),
                    "content": content,
                    "files": files,
                })
        return {"skills": skills, "count": len(skills)}

    @app.get("/api/skills/{name}")
    def api_skill_detail(name: str):
        """Return a single skill by directory name."""
        skill_dir = _PROJECT_ROOT / "skills" / name
        skill_file = skill_dir / "SKILL.md"
        if not skill_dir.is_dir() or not skill_file.exists():
            raise HTTPException(404, f"Skill '{name}' not found")
        content = skill_file.read_text()
        meta, _ = _parse_frontmatter(content)
        files = []
        for f in sorted(skill_dir.rglob("*")):
            if f.is_file():
                rel = str(f.relative_to(skill_dir))
                try:
                    fc = f.read_text()
                except (UnicodeDecodeError, OSError):
                    fc = f"<binary: {f.stat().st_size} bytes>"
                files.append({"path": rel, "content": fc, "size": f.stat().st_size})
        return {
            "name": skill_dir.name,
            "type": meta.get("type", "tool"),
            "description": meta.get("description", ""),
            "content": content,
            "files": files,
        }

    @app.get("/api/prompts")
    def api_prompts():
        """Return all prompts from prompts/."""
        prompts_dir = _PROJECT_ROOT / "prompts"
        prompts = []
        if prompts_dir.exists():
            for f in sorted(prompts_dir.glob("*.md")):
                content = f.read_text()
                prompts.append({
                    "name": f.stem,
                    "content": content,
                    "size": f.stat().st_size,
                })
        return {"prompts": prompts, "count": len(prompts)}

    @app.get("/api/tasks")
    def api_tasks():
        """Return task metadata for all tasks from tasks/."""
        tasks_dir = _PROJECT_ROOT / "tasks"
        raw = discover_tasks(tasks_dir) if tasks_dir.is_dir() else []
        tasks = []
        for t in raw:
            task_path = Path(t["task_path"])
            toml_path = task_path / "task.toml"
            meta = {}
            if toml_path.exists():
                try:
                    meta = tomllib.loads(toml_path.read_text()).get("metadata", {})
                except Exception:
                    pass
            # Read instruction.md for description
            instr_path = task_path / "instruction.md"
            description = ""
            if instr_path.exists():
                try:
                    description = instr_path.read_text().strip()
                except OSError:
                    pass
            tasks.append({
                "name": t["task_name"],
                "description": description,
                "category": meta.get("category", ""),
                "tags": meta.get("tags", []),
                "difficulty": meta.get("difficulty", ""),
            })
        return {"tasks": tasks, "count": len(tasks)}

    @app.get("/api/tasks/{name}/files")
    def api_task_files(name: str):
        """Return full file tree for a single task."""
        tasks_dir = _PROJECT_ROOT / "tasks"
        task_path = tasks_dir / name
        if not task_path.is_dir():
            raise HTTPException(404, f"Task '{name}' not found")
        files = []
        for f in sorted(task_path.rglob("*")):
            if f.is_file() and f.name != "__pycache__" and "__pycache__" not in str(f):
                rel = str(f.relative_to(task_path))
                is_text = True
                try:
                    content = f.read_text()
                except (UnicodeDecodeError, OSError):
                    content = f"<binary: {f.stat().st_size} bytes>"
                    is_text = False
                files.append({"path": rel, "content": content, "size": f.stat().st_size, "is_text": is_text})
        return {"task": name, "files": files, "count": len(files)}

    @app.post("/api/tasks/{name}/seed")
    async def api_task_seed(name: str, request: Request):
        """Parse task Dockerfile, detect required envs, seed each one."""
        tasks_dir = _PROJECT_ROOT / "tasks"
        task_path = tasks_dir / name
        if not task_path.is_dir():
            raise HTTPException(404, f"Task '{name}' not found")

        # Parse config.toml for env info
        config_path = _PROJECT_ROOT / "config.toml"
        config = tomllib.loads(config_path.read_text()) if config_path.exists() else {}
        env_map = {k: v for k, v in config.items() if k.startswith("claw-")}

        # Detect required envs from Dockerfile (COPY or FROM lines mentioning claw-*)
        dockerfile = task_path / "environment" / "Dockerfile"
        required_envs = set()
        if dockerfile.exists():
            dockerfile_text = dockerfile.read_text()
            for env_name in env_map:
                if env_name in dockerfile_text:
                    required_envs.add(env_name)


        if not required_envs:
            return {"task": name, "seeded": [], "error": "Could not detect required environments"}

        # Seed each required env
        results = []
        async with httpx.AsyncClient(timeout=30) as client:
            for env_name in sorted(required_envs):
                port = env_map[env_name].get("port")
                if not port:
                    results.append({"env": env_name, "status": "error", "detail": "No port configured"})
                    continue
                url = f"http://localhost:{port}/_admin/seed?scenario=task:{name}"
                try:
                    resp = await client.post(url)
                    # Use the request host so links work for remote access
                    req_host = request.headers.get("host", "localhost").split(":")[0]
                    # Capture manifest path so solve/evaluate can find it
                    if resp.status_code < 400:
                        body = resp.json()
                        mp = body.get("manifest_path")
                        if mp:
                            _manifest_paths[name] = mp
                    results.append({
                        "env": env_name,
                        "status": "ok" if resp.status_code < 400 else "error",
                        "detail": resp.text[:500],
                        "ui_url": f"http://{req_host}:{port}/",
                    })
                except httpx.ConnectError:
                    raise HTTPException(503, f"Environment {env_name} is not running on port {port}")
                except Exception as e:
                    results.append({"env": env_name, "status": "error", "detail": str(e)})

        return {"task": name, "seeded": results}

    @app.post("/api/tasks/{name}/solve")
    async def api_task_solve(name: str):
        """Run the task's solution/solve.sh against live claw envs."""
        tasks_dir = _PROJECT_ROOT / "tasks"
        task_path = tasks_dir / name
        if not task_path.is_dir():
            raise HTTPException(404, f"Task '{name}' not found")

        solve_sh = task_path / "solution" / "solve.sh"
        if not solve_sh.exists():
            raise HTTPException(400, f"Task '{name}' has no solution/solve.sh")

        # Parse config.toml for env info
        config_path = _PROJECT_ROOT / "config.toml"
        config = tomllib.loads(config_path.read_text()) if config_path.exists() else {}
        env_map = {k: v for k, v in config.items() if k.startswith("claw-")}

        env_vars = {**os.environ}
        env_vars["TASKS_DIR"] = str(tasks_dir)
        if name in _manifest_paths:
            env_vars["NEEDLE_MANIFEST"] = _manifest_paths[name]
        for claw_name, cfg in env_map.items():
            port = cfg.get("port")
            env_var = cfg.get("env_var")
            if port and env_var:
                env_vars[env_var] = f"http://localhost:{port}"

        import logging
        logger = logging.getLogger("clawbench.dashboard")
        logger.info("[solve] task=%s solve_sh=%s", name, solve_sh)

        proc = subprocess.run(
            ["bash", str(solve_sh)],
            capture_output=True,
            text=True,
            timeout=120,
            cwd=str(task_path / "solution"),
            env=env_vars,
        )

        logger.info("[solve] exit_code=%d", proc.returncode)
        if proc.stdout.strip():
            logger.info("[solve] stdout: %s", proc.stdout[:1000])
        if proc.stderr.strip():
            logger.warning("[solve] stderr: %s", proc.stderr[:1000])

        if proc.returncode != 0:
            detail = proc.stderr[:500] if proc.stderr else proc.stdout[:500]
            raise HTTPException(500, f"solve.sh failed (exit {proc.returncode}): {detail}")

        return {"task": name, "status": "ok"}

    @app.post("/api/tasks/{name}/evaluate")
    async def api_task_evaluate(name: str, request: Request):
        """Run task test.sh against live claw envs, return evaluation result.

        Mirrors the Docker-based evaluation flow: fetches admin data from each
        required claw env, then runs the task's test.sh (which calls evaluate.py)
        as a subprocess.
        """
        import tempfile

        tasks_dir = _PROJECT_ROOT / "tasks"
        task_path = tasks_dir / name
        if not task_path.is_dir():
            raise HTTPException(404, f"Task '{name}' not found")

        test_sh = task_path / "tests" / "test.sh"
        if not test_sh.exists():
            raise HTTPException(400, f"Task '{name}' has no tests/test.sh")

        # Parse config.toml for env info
        config_path = _PROJECT_ROOT / "config.toml"
        config = tomllib.loads(config_path.read_text()) if config_path.exists() else {}
        env_map = {k: v for k, v in config.items() if k.startswith("claw-")}

        # Build env vars for claw env URLs so test.sh can find them
        # Uses env_var from config.toml (e.g. "CLAW_GMAIL_URL", "CLAW_DRIVE_URL")
        env_vars = {**os.environ}
        env_vars["TASKS_DIR"] = str(tasks_dir)
        if name in _manifest_paths:
            env_vars["NEEDLE_MANIFEST"] = _manifest_paths[name]
        for claw_name, cfg in env_map.items():
            port = cfg.get("port")
            env_var = cfg.get("env_var")
            if port and env_var:
                env_vars[env_var] = f"http://localhost:{port}"

        with tempfile.TemporaryDirectory() as tmpdir:
            logs_dir = Path(tmpdir) / "verifier"
            logs_dir.mkdir(parents=True)
            env_vars["LOGS_DIR"] = str(logs_dir)
            # Also set /logs symlink target for scripts that hardcode paths
            env_vars["REWARD_FILE"] = str(logs_dir / "reward.json")

            import logging
            logger = logging.getLogger("clawbench.dashboard")

            logger.info("[evaluate] task=%s test_sh=%s cwd=%s", name, test_sh, task_path / "tests")
            logger.info("[evaluate] LOGS_DIR=%s exists=%s", str(logs_dir), logs_dir.exists())

            proc = subprocess.run(
                ["bash", str(test_sh)],
                capture_output=True,
                text=True,
                timeout=60,
                cwd=str(task_path / "tests"),
                env=env_vars,
            )

            logger.info("[evaluate] exit_code=%d", proc.returncode)
            if proc.stdout.strip():
                logger.info("[evaluate] stdout: %s", proc.stdout[:1000])
            if proc.stderr.strip():
                logger.warning("[evaluate] stderr: %s", proc.stderr[:1000])

            # Read reward.json written by evaluate.py
            reward_file = logs_dir / "reward.json"
            if reward_file.exists():
                result = json.loads(reward_file.read_text())
                logger.info("[evaluate] reward.json: %s", json.dumps(result)[:1000])
            elif proc.returncode == 0 and proc.stdout.strip():
                # Some evaluators print JSON to stdout
                try:
                    result = json.loads(proc.stdout.strip().split("\n")[-1])
                    logger.info("[evaluate] parsed from stdout: %s", json.dumps(result)[:1000])
                except json.JSONDecodeError:
                    raise HTTPException(500, f"evaluate produced non-JSON output: {proc.stdout[:500]}")
            else:
                detail = proc.stderr[:500] if proc.stderr else proc.stdout[:500]
                raise HTTPException(500, f"test.sh failed (exit {proc.returncode}): {detail}")

        raw = result.get("reward", result.get("score", 0))
        reward = max(-1.0, min(1.0, float(raw)))
        logger.info("[evaluate] task=%s reward=%s", name, reward)
        return {
            "task_name": name,
            "reward": reward,
            "done": reward >= 1.0,
            "diff_summary": result.get("diff_summary"),
            "action_count": result.get("action_count"),
            "details": result.get("details"),
        }

    # --- Settings / Config ---

    @app.get("/api/settings")
    def api_settings():
        """Return current pipeline settings and detected API keys."""
        import os

        models = [
            {"id": "anthropic/claude-sonnet-4-6", "name": "Claude Sonnet 4.6", "provider": "Anthropic"},
            {"id": "anthropic/claude-opus-4-6", "name": "Claude Opus 4.6", "provider": "Anthropic"},
            {"id": "anthropic/claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", "provider": "Anthropic"},
        ]

        agents = [
            {"id": "claude-code", "name": "Claude Code"},
            {"id": "openclaw", "name": "OpenClaw"},
            {"id": "openhands", "name": "OpenHands"},
        ]

        skills_dir = _PROJECT_ROOT / "skills"
        skills = []
        if skills_dir.exists():
            for skill_dir in sorted(skills_dir.iterdir()):
                skill_file = skill_dir / "SKILL.md"
                if skill_dir.is_dir() and skill_file.exists():
                    content = skill_file.read_text()
                    name = skill_dir.name
                    description = ""
                    for line in content.split("\n"):
                        if line.strip().startswith("description:"):
                            description = line.split(":", 1)[1].strip().strip('"')
                            break
                    parts = name.split("-")
                    group = parts[0] if len(parts) == 1 else "-".join(parts[:2]) if parts[0] in ("gmail",) else parts[0]
                    skills.append({
                        "id": str(skill_file),
                        "name": name,
                        "description": description,
                        "group": group,
                    })

        # Discover task dirs from top-level tasks/ directory
        tasks_root = _PROJECT_ROOT / "tasks"

        # config.toml raw + parsed environments
        config_path = _PROJECT_ROOT / "config.toml"
        config_toml_raw = config_path.read_text() if config_path.exists() else ""
        config = tomllib.loads(config_toml_raw) if config_toml_raw else {}
        environments = {
            k: {"port": v.get("port"), "db_path": v.get("db_path"), "env_var": v.get("env_var")}
            for k, v in config.items()
            if k.startswith("claw-")
        }

        # Auth tokens (new set)
        auth_token_vars = ["GOOGLE_API_KEY", "DAYTONA_API_KEY"]
        auth_tokens = {}
        for var in auth_token_vars:
            val = os.environ.get(var, "")
            auth_tokens[var] = {"set": bool(val)}

        # Dir paths + counts
        prompts_dir = _PROJECT_ROOT / "prompts"
        skills_count = sum(1 for d in skills_dir.iterdir() if d.is_dir() and (d / "SKILL.md").exists()) if skills_dir.exists() else 0
        tasks_count = sum(1 for d in tasks_root.iterdir() if d.is_dir() and (d / "task.toml").exists()) if tasks_root.is_dir() else 0
        prompts_count = sum(1 for f in prompts_dir.glob("*.md")) if prompts_dir.exists() else 0

        return {
            "models": models,
            "agents": agents,
            "skills": skills,
            "config_toml": config_toml_raw,
            "environments": environments,
            "auth_tokens": auth_tokens,
            "skills_dir": str(skills_dir),
            "skills_count": skills_count,
            "tasks_dir": str(tasks_root),
            "tasks_count": tasks_count,
            "prompts_dir": str(prompts_dir),
            "prompts_count": prompts_count,
        }

    # --- Optimization trigger ---

    def _drain_log(exp: dict) -> None:
        """Read any available stdout from a process into its log."""
        import fcntl
        proc = exp["process"]
        if not proc.stdout:
            return
        fd = proc.stdout.fileno()
        # Set non-blocking if not already
        if not exp.get("_nb"):
            fl = fcntl.fcntl(fd, fcntl.F_GETFL)
            fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
            exp["_nb"] = True
            exp["_buf"] = b""
        try:
            chunk = os.read(fd, 65536)
            if chunk:
                exp["_buf"] += chunk
        except BlockingIOError:
            pass
        # Split complete lines from the buffer
        buf = exp["_buf"]
        while b"\n" in buf:
            line, buf = buf.split(b"\n", 1)
            exp["log"].append(line.decode("utf-8", errors="replace").rstrip("\r"))
        exp["_buf"] = buf

    @app.post("/api/optimize")
    async def api_start_optimization(request: Request):
        """Start an optimize subprocess.  Multiple can run in parallel."""
        body = {}
        try:
            body = await request.json()
        except Exception:
            pass

        experiment = body.get("experiment", "default")

        # If this experiment already running, reject
        if experiment in _experiments:
            existing = _experiments[experiment]
            if existing["process"].poll() is None:
                return {"status": "already_running", "experiment": experiment, "pid": existing["process"].pid}

        pipeline_dir = Path(__file__).resolve().parents[2]
        project_root = pipeline_dir.parents[1]

        skill = body.get("skill", str(project_root / "skills" / "gws-gmail" / "SKILL.md"))
        tasks_dir = body.get("tasks_dir", str(project_root / "tasks"))
        max_calls = body.get("max_calls", 6)
        workers = body.get("workers", 1)
        out_dir = str(_output_dir / experiment) if experiment != "default" else str(_output_dir)

        resume = body.get("resume", False)
        agent = body.get("agent", "claude-code")
        model = body.get("model", "anthropic/claude-sonnet-4-6")
        engine = body.get("engine", "gepa")

        clawbench_bin = Path(sys.executable).parent / "clawbench"
        cmd = [
            str(clawbench_bin), "optimize",
            "--skill", skill,
            "--tasks-dir", tasks_dir,
            "--engine", engine,
            "--max-calls", str(max_calls),
            "--workers", str(workers),
            "--output-dir", out_dir,
            "--agent", agent,
            "--model", model,
        ]
        val_tasks = body.get("val_tasks")
        test_tasks = body.get("test_tasks")
        if val_tasks:
            cmd.extend(["--val-tasks", val_tasks])
        if test_tasks:
            cmd.extend(["--test-tasks", test_tasks])
        if resume:
            cmd.append("--resume")

        env = {**os.environ, "PYTHONUNBUFFERED": "1"}
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            cwd=str(pipeline_dir),
            env=env,
        )

        _experiments[experiment] = {"process": proc, "log": []}

        return {
            "status": "started",
            "experiment": experiment,
            "pid": proc.pid,
            "command": " ".join(cmd),
        }

    @app.get("/api/optimize/status")
    def api_optimize_status():
        """Status of all running/completed experiments."""
        global _run_data

        if not _experiments:
            return {"status": "idle", "experiments": {}}

        result = {}
        any_running = False
        for name, exp in _experiments.items():
            _drain_log(exp)
            rc = exp["process"].poll()
            if rc is None:
                any_running = True
                result[name] = {
                    "status": "running",
                    "pid": exp["process"].pid,
                    "log": exp["log"][-50:],
                }
            else:
                result[name] = {
                    "status": "completed" if rc == 0 else "failed",
                    "exit_code": rc,
                    "log": exp["log"][-100:],
                }

        overall = "running" if any_running else "idle"
        return {"status": overall, "experiments": result}

    return app


def _get_app_from_env() -> FastAPI:
    """Create app from CLAWBENCH_OUTPUT_DIR env var (used by uvicorn --reload)."""
    import os
    output_dir = Path(os.environ["CLAWBENCH_OUTPUT_DIR"])
    return create_app(output_dir)


# Module-level app instance for `uvicorn clawbench.dashboard:app --reload`
try:
    app = _get_app_from_env()
except (KeyError, Exception):
    pass
