"""Runner service — wraps AgentWorkflow and emits real-time events."""

from __future__ import annotations

import json
import logging
import os
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from server.event_bus import event_bus
from server.models import EventType, RunEvent

logger = logging.getLogger(__name__)

# Sequence counter per run (thread-safe)
_seq_counters: dict[str, int] = {}
_seq_lock = threading.Lock()


def _next_seq(run_id: str) -> int:
    """Return the next sequence number for a run_id (thread-safe)."""
    with _seq_lock:
        val = _seq_counters.get(run_id, 0) + 1
        _seq_counters[run_id] = val
        return val


def _make_event(
    run_id: str, event_type: EventType, data: dict[str, Any] | None = None
) -> RunEvent:
    return RunEvent(
        run_id=run_id,
        seq=_next_seq(run_id),
        type=event_type,
        data=data or {},
        timestamp=datetime.now(tz=timezone.utc),
    )


_FULL_DETAIL_RUNS = 5  # Show last N runs with full results in prompt
_MAX_RESULT_CHARS = 500  # Truncate individual result summaries


def _build_experiment_context(
    session_id: str,
) -> tuple[str, dict[str, Any]]:
    """Fetch session data from the store and build prompt context + state files.

    Runs synchronously (called from a background thread) by creating a
    temporary event loop with a fresh DB connection to avoid sharing the
    async connection across threads.

    Returns (prompt_context_string, state_files_dict).
    """
    import asyncio

    from server.services.store import StoreService

    async def _fetch() -> tuple[dict | None, list, list]:
        store = StoreService()
        await store.init_db()
        try:
            session = await store.get_session(session_id)
            history = await store.get_session_history(session_id)
            memory = await store.get_memory_entries(session_id)
            return session, history, memory
        finally:
            await store.close()

    loop = asyncio.new_event_loop()
    try:
        session, history, memory = loop.run_until_complete(_fetch())
    finally:
        loop.close()

    if not session:
        return "", {}

    # ── Approach 1: Build prompt context string ──────────────────────
    parts: list[str] = ["## Experiment Context\n"]
    parts.append(f"**Experiment:** {session.get('title', 'Untitled')}")
    if session.get("hypothesis"):
        parts.append(f"**Hypothesis:** {session['hypothesis']}")
    if session.get("description"):
        parts.append(f"**Description:** {session['description']}")
    parts.append("")

    # Group history into (task, result) pairs
    run_pairs: list[dict[str, str]] = []
    i = 0
    while i < len(history) - 1:
        if history[i]["role"] == "user" and history[i + 1]["role"] == "assistant":
            run_pairs.append({
                "task": history[i]["content"],
                "result": history[i + 1]["content"],
                "run_id": history[i].get("run_id", ""),
                "timestamp": history[i].get("timestamp", ""),
                "status": history[i + 1].get("status", ""),
            })
            i += 2
        else:
            i += 1

    if run_pairs:
        total = len(run_pairs)
        parts.append(f"### Previous Run Results ({total} total, most recent first)\n")

        # Recent runs: full detail
        recent = list(reversed(run_pairs[-_FULL_DETAIL_RUNS:]))
        for idx, pair in enumerate(recent):
            run_num = total - idx
            task_preview = pair["task"][:100]
            result_preview = pair["result"][:_MAX_RESULT_CHARS]
            if len(pair["result"]) > _MAX_RESULT_CHARS:
                result_preview += "... (truncated, full result in _experiment_context/)"
            parts.append(f"#### Run {run_num} — \"{task_preview}\"")
            if pair.get("timestamp"):
                parts.append(f"*{pair['timestamp']}*")
            parts.append(f"**Result:** {result_preview}\n")

        # Older runs: one-line summaries
        older = list(reversed(run_pairs[:-_FULL_DETAIL_RUNS])) if total > _FULL_DETAIL_RUNS else []
        if older:
            parts.append("### Earlier Runs (summary)\n")
            for idx, pair in enumerate(older):
                run_num = total - _FULL_DETAIL_RUNS - idx
                parts.append(f"- Run {run_num}: \"{pair['task'][:60]}\"")

    parts.append("")

    # Memory entries
    if memory:
        parts.append("### Experiment Memory\n")
        for entry in memory:
            parts.append(f"- [{entry['type']}] {entry['content'][:200]}")
        parts.append("")

    # Instructions for the manager
    parts.append("### Instructions for Continuing This Experiment\n")
    parts.append(
        "You are continuing an existing experiment. Use the previous results to:\n"
        "- Build upon successful findings rather than repeating work\n"
        "- Refine or correct earlier results if the current task asks for it\n"
        "- Reference specific previous runs when relevant\n"
        "- Full previous results are available in `_experiment_context/` workspace files\n"
    )

    prompt_context = "\n".join(parts)

    # ── Approach 2: Build state files dict ───────────────────────────
    state_files: dict[str, Any] = {
        "experiment.json": {
            "id": session.get("id"),
            "title": session.get("title"),
            "hypothesis": session.get("hypothesis"),
            "description": session.get("description"),
            "tags": session.get("tags"),
            "created_at": session.get("created_at"),
        },
        "memory.json": memory,
        "runs": [],
    }

    # Build per-run summaries for file output (all runs, not truncated)
    for idx, pair in enumerate(run_pairs):
        state_files["runs"].append({
            "run_number": idx + 1,
            "run_id": pair.get("run_id", ""),
            "task": pair["task"],
            "result": pair["result"],
            "timestamp": pair.get("timestamp", ""),
        })

    # Build the markdown brief (complete, not truncated)
    brief_parts = [f"# Experiment: {session.get('title', 'Untitled')}\n"]
    if session.get("hypothesis"):
        brief_parts.append(f"**Hypothesis:** {session['hypothesis']}\n")
    if session.get("description"):
        brief_parts.append(f"**Description:** {session['description']}\n")
    brief_parts.append(f"## Runs ({len(run_pairs)} total)\n")
    for idx, pair in enumerate(run_pairs):
        brief_parts.append(f"### Run {idx + 1}: {pair['task'][:120]}\n")
        brief_parts.append(f"{pair['result']}\n")
    if memory:
        brief_parts.append("## Memory\n")
        for entry in memory:
            brief_parts.append(f"- [{entry['type']}] {entry['content']}\n")
    state_files["experiment_brief.md"] = "\n".join(brief_parts)

    return prompt_context, state_files


def _write_experiment_state_files(
    workspace_dir: Path, state_files: dict[str, Any]
) -> None:
    """Write experiment state files to the workspace for worker access."""
    ctx_dir = workspace_dir / "workspace" / "_experiment_context"
    ctx_dir.mkdir(parents=True, exist_ok=True)

    # experiment.json
    (ctx_dir / "experiment.json").write_text(
        json.dumps(state_files.get("experiment.json", {}), indent=2, default=str),
        encoding="utf-8",
    )

    # memory.json
    (ctx_dir / "memory.json").write_text(
        json.dumps(state_files.get("memory.json", []), indent=2, default=str),
        encoding="utf-8",
    )

    # Per-run summaries
    for run_data in state_files.get("runs", []):
        num = run_data.get("run_number", 0)
        fname = f"run_{num:03d}_summary.json"
        (ctx_dir / fname).write_text(
            json.dumps(run_data, indent=2, default=str),
            encoding="utf-8",
        )

    # Markdown brief
    brief = state_files.get("experiment_brief.md", "")
    if brief:
        (ctx_dir / "experiment_brief.md").write_text(brief, encoding="utf-8")

    logger.info(
        "Wrote experiment context to %s (%d runs, %d memory entries)",
        ctx_dir,
        len(state_files.get("runs", [])),
        len(state_files.get("memory.json", [])),
    )


class _RunDirWatcher:
    """Watches the delegation loop run directory for new files and emits events.

    The DelegationLoopRunner writes JSON files to disk as it progresses:
      - run_manifest.json (at start)
      - iterations/001/manager_decision.json
      - iterations/001/delegations/<worker>/envelope.json
      - iterations/001/delegations/<worker>/result.json
      - iterations/001/budget_snapshot.json
      - run_completion.json (at end)

    This watcher polls for new files and translates them into RunEvent objects.
    """

    def __init__(self, run_id: str, workspace_dir: Path) -> None:
        self._run_id = run_id
        self._workspace_dir = workspace_dir
        self._seen_files: set[str] = set()
        self._stop = threading.Event()

    def stop(self) -> None:
        self._stop.set()

    def _find_run_dir(self) -> Path | None:
        """Locate the delegation loop run directory under workspace/runs/."""
        runs_dir = self._workspace_dir / "workspace" / "runs"
        if not runs_dir.exists():
            return None
        # Pick the latest run dir
        candidates = sorted(
            [d for d in runs_dir.iterdir() if d.is_dir()], key=lambda d: d.name
        )
        return candidates[-1] if candidates else None

    def _read_json(self, path: Path) -> dict[str, Any] | list[Any] | None:
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except (FileNotFoundError, json.JSONDecodeError, OSError):
            return None

    def _read_text(self, path: Path) -> str | None:
        try:
            return path.read_text(encoding="utf-8")
        except (FileNotFoundError, OSError):
            return None

    def _process_file(self, path: Path, rel: str) -> None:
        """Translate a newly-observed file into one or more events."""
        if rel in self._seen_files:
            return
        self._seen_files.add(rel)

        parts = rel.replace("\\", "/").split("/")

        # run_manifest.json -> run.start
        if rel == "run_manifest.json":
            data = self._read_json(path)
            if data:
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(self._run_id, EventType.RUN_START, data),
                )

        # iterations/NNN/manager_decision.json -> iteration.start + iteration.decision
        elif "manager_decision.json" in rel:
            data = self._read_json(path)
            if data:
                iteration = parts[1] if len(parts) >= 3 else "?"
                # Emit iteration.start for this iteration (if not already emitted)
                iter_start_key = f"_iter_start_{iteration}"
                if iter_start_key not in self._seen_files:
                    self._seen_files.add(iter_start_key)
                    event_bus.emit_threadsafe(
                        self._run_id,
                        _make_event(
                            self._run_id,
                            EventType.ITERATION_START,
                            {"iteration": iteration},
                        ),
                    )
                # Extract delegations info for richer detail
                delegations = data.get("delegations", [])
                delegation_summaries = []
                for d in delegations if isinstance(delegations, list) else []:
                    if isinstance(d, dict):
                        delegation_summaries.append({
                            "worker": d.get("worker_id", d.get("id", "?")),
                            "task": str(d.get("instructions", d.get("task", "")))[:200],
                            "tools": d.get("tools_allowed", []),
                        })
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(
                        self._run_id,
                        EventType.ITERATION_DECISION,
                        {
                            "iteration": iteration,
                            "delegations": delegation_summaries,
                            **data,
                        },
                    ),
                )

        # iterations/NNN/budget_snapshot.json -> budget.update
        elif "budget_snapshot.json" in rel:
            data = self._read_json(path)
            if data:
                iteration = parts[1] if len(parts) >= 3 else "?"
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(
                        self._run_id,
                        EventType.BUDGET_UPDATE,
                        {"iteration": iteration, **data},
                    ),
                )

        # iterations/NNN/validation.json -> log (validation results)
        elif "validation.json" in rel:
            data = self._read_json(path)
            if data:
                iteration = parts[1] if len(parts) >= 3 else "?"
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(
                        self._run_id,
                        EventType.LOG,
                        {
                            "kind": "validation",
                            "iteration": iteration,
                            "message": f"Validation results for iteration {iteration}",
                            "validation": data,
                        },
                    ),
                )

        # delegations/<worker>/envelope.json -> worker.spawn
        elif rel.endswith("envelope.json") and "delegations" in rel:
            data = self._read_json(path)
            if data:
                worker_id = path.parent.name
                iteration = parts[1] if len(parts) >= 3 else "?"
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(
                        self._run_id,
                        EventType.WORKER_SPAWN,
                        {
                            "worker_id": worker_id,
                            "iteration": iteration,
                            "instructions": str(data.get("instructions", "")),
                            "tools_allowed": data.get("tools_allowed", []),
                            "skills": [
                                str(s)[:200] for s in data.get("skills", [])
                            ] if data.get("skills") else [],
                            "code_mode": data.get("tool_config", {}).get(
                                "code_mode", data.get("code_mode")
                            ),
                        },
                    ),
                )

        # delegations/<worker>/result.json -> worker.complete
        elif rel.endswith("result.json") and "delegations" in rel:
            data = self._read_json(path)
            if data:
                worker_id = path.parent.name
                iteration = parts[1] if len(parts) >= 3 else "?"
                # Extract all findings
                findings = data.get("findings", data.get("result", data))
                tools_created = data.get("tools_created", [])
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(
                        self._run_id,
                        EventType.WORKER_COMPLETE,
                        {
                            "worker_id": worker_id,
                            "iteration": iteration,
                            "confidence": data.get("confidence"),
                            "error": data.get("error"),
                            "has_error": bool(data.get("error")),
                            "result": findings,
                            "tools_created": [
                                t if isinstance(t, str) else t.get("name", "?")
                                for t in tools_created
                            ] if isinstance(tools_created, list) else [],
                        },
                    ),
                )

        # delegations/<worker>/tool_calls.json -> tool.call (one per call)
        elif rel.endswith("tool_calls.json") and "delegations" in rel:
            data = self._read_json(path)
            if isinstance(data, list):
                worker_id = path.parent.name
                for i, tc in enumerate(data):
                    if isinstance(tc, dict):
                        result = tc.get("result", {})
                        result_data = result if isinstance(result, dict) else {"value": result}
                        event_bus.emit_threadsafe(
                            self._run_id,
                            _make_event(
                                self._run_id,
                                EventType.TOOL_CALL,
                                {
                                    "worker_id": worker_id,
                                    "call_index": i,
                                    "tool": tc.get("tool", "unknown"),
                                    "arguments": tc.get("arguments", tc.get("args", {})),
                                    "ok": result_data.get("ok", True) if isinstance(result_data, dict) else True,
                                    "output": str(result_data.get("output", result_data.get("stdout", "")))[:1000] if isinstance(result_data, dict) else str(result)[:1000],
                                    "error": str(result_data.get("error", result_data.get("stderr", "")))[:500] if isinstance(result_data, dict) else None,
                                },
                            ),
                        )

        # rolling_summary.json -> log (confidence trend)
        elif rel.endswith("rolling_summary.json"):
            data = self._read_json(path)
            if data:
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(
                        self._run_id,
                        EventType.LOG,
                        {
                            "kind": "rolling_summary",
                            "message": f"Confidence trend: iteration {data.get('current_iteration', '?')}",
                            "confidence": data.get("confidence"),
                            "history": data.get("history", []),
                        },
                    ),
                )

        # run_completion.json -> run.complete
        elif rel == "run_completion.json":
            data = self._read_json(path)
            if data:
                event_bus.emit_threadsafe(
                    self._run_id,
                    _make_event(self._run_id, EventType.RUN_COMPLETE, data),
                )

    def watch(self) -> None:
        """Poll the run directory until stop() is called or completion is detected."""
        while not self._stop.is_set():
            run_dir = self._find_run_dir()
            if run_dir and run_dir.exists():
                self._scan_dir(run_dir)
                # If we see completion, do one final scan and stop
                if "run_completion.json" in self._seen_files:
                    self._scan_dir(run_dir)
                    break
            self._stop.wait(timeout=0.2)

    def _scan_dir(self, run_dir: Path) -> None:
        """Walk the run directory for JSON and markdown files.

        Files are sorted so that run_completion.json is always processed
        LAST — otherwise a bulk scan can emit 'run.complete' before
        iteration/worker events, causing WebSocket clients to disconnect
        before they receive the full event stream.
        """
        try:
            all_paths: list[tuple[str, Path]] = []
            for pattern in ("*.json", "*.md"):
                for path in run_dir.rglob(pattern):
                    try:
                        rel = str(path.relative_to(run_dir))
                    except ValueError:
                        continue
                    all_paths.append((rel, path))

            # Sort: run_manifest first, then iterations in order
            # (manager_decision → envelope → result → budget),
            # rolling_summary late, run_completion last.
            def _sort_key(item: tuple[str, Path]) -> tuple[int, str]:
                rel = item[0]
                if rel == "run_manifest.json":
                    return (0, rel)
                if rel == "run_completion.json":
                    return (99, rel)
                if "rolling_summary" in rel:
                    return (98, rel)
                # Within iterations: manager_decision → envelope → result →
                # tool_calls → validation → budget_snapshot
                basename = Path(rel).name
                file_order = {
                    "manager_decision.json": 0,
                    "envelope.json": 1,
                    "result.json": 3,
                    "tool_calls.json": 4,
                    "validation.json": 5,
                    "budget_snapshot.json": 6,
                }
                priority = file_order.get(basename, 2)
                return (1, f"{rel.split('/')[1] if '/' in rel else '000'}_{priority:02d}_{rel}")

            all_paths.sort(key=_sort_key)

            for rel, path in all_paths:
                self._process_file(path, rel)
        except OSError:
            pass


# ---------------------------------------------------------------------------
# Active runs registry (for stop support)
# ---------------------------------------------------------------------------

_active_runs: dict[str, dict[str, Any]] = {}
_active_lock = threading.Lock()


class RunnerService:
    """Orchestrates AgentWorkflow runs with event streaming."""

    def __init__(self) -> None:
        pass

    def start_run(
        self,
        run_id: str,
        config: dict[str, Any],
        session_id: str | None = None,
    ) -> str:
        """Launch an AgentWorkflow run in a background thread.

        Parameters
        ----------
        run_id : str
            Unique run identifier (pre-generated by the caller).
        config : dict
            WorkflowConfig fields (task, model, budget params, etc.).
        session_id : str, optional
            Session/experiment ID. When provided, previous run results
            and experiment memory are injected into the manager prompt.

        Returns
        -------
        str
            The run_id.
        """
        thread = threading.Thread(
            target=self._run_workflow,
            args=(run_id, config, session_id),
            daemon=True,
            name=f"awp-run-{run_id[:8]}",
        )
        with _active_lock:
            _active_runs[run_id] = {"thread": thread, "stop": False}
        thread.start()
        return run_id

    def stop_run(self, run_id: str) -> bool:
        """Signal a run to stop. Returns True if the run was found."""
        with _active_lock:
            info = _active_runs.get(run_id)
            if info is None:
                return False
            info["stop"] = True
        return True

    def is_running(self, run_id: str) -> bool:
        with _active_lock:
            info = _active_runs.get(run_id)
            if info is None:
                return False
            return info["thread"].is_alive()

    def _run_workflow(self, run_id: str, config: dict[str, Any], session_id: str | None = None) -> None:
        """Execute the workflow synchronously in a background thread."""
        from server.services.store import StoreService

        result: dict[str, Any] | None = None
        status = "failed"

        # Emit run.start (mask secrets and api_key to avoid leaking in logs)
        safe_config = dict(config)
        safe_config.pop("api_key", None)
        if "secrets" in safe_config:
            safe_config["secrets"] = {
                k: "***" for k in safe_config["secrets"]
            }
        event_bus.emit_threadsafe(
            run_id,
            _make_event(run_id, EventType.RUN_START, {"run_id": run_id, **safe_config}),
        )

        try:
            # Lazy import to avoid circular deps and allow running without AWP installed
            from awp.data.workflow import AgentWorkflow

            # Build AgentWorkflow kwargs from config
            wf_kwargs = self._config_to_workflow_kwargs(config)

            # Inject secrets as environment variables so LLMClient can find API keys
            _injected_env_keys: list[str] = []
            for key, value in wf_kwargs.get("secrets", {}).items():
                if key.endswith("_API_KEY") or key.endswith("_KEY"):
                    if key not in os.environ or not os.environ[key]:
                        os.environ[key] = value
                        _injected_env_keys.append(key)

            # If no explicit api_key, detect the correct key from
            # the model string using provider-routing rules:
            #   provider/model  → OpenRouter  (OPENROUTER_API_KEY)
            #   gpt-*, o1-*, o3 → OpenAI     (OPENAI_API_KEY)
            #   claude-*        → Anthropic   (ANTHROPIC_API_KEY)
            #   ollama/*        → local       (no key)
            if not wf_kwargs.get("api_key"):
                model = wf_kwargs.get("model", "")
                model_lower = model.lower().strip()
                secrets_dict = wf_kwargs.get("secrets", {})

                # Determine which key name this model needs
                import re
                if model_lower.startswith("ollama/") or model_lower.startswith("localhost"):
                    # Local Ollama – no key needed, set dummy to skip validation
                    wf_kwargs["api_key"] = "ollama-local"
                elif re.match(r"^(gpt-|o[0-9]|dall-e|text-|tts-|whisper)", model_lower):
                    # Direct OpenAI model
                    preferred_key = "OPENAI_API_KEY"
                    wf_kwargs["api_key"] = (
                        secrets_dict.get(preferred_key, "")
                        or os.environ.get(preferred_key, "")
                    )
                elif model_lower.startswith("claude-"):
                    # Direct Anthropic model
                    preferred_key = "ANTHROPIC_API_KEY"
                    wf_kwargs["api_key"] = (
                        secrets_dict.get(preferred_key, "")
                        or os.environ.get(preferred_key, "")
                    )
                else:
                    # provider/model format → OpenRouter
                    preferred_key = "OPENROUTER_API_KEY"
                    wf_kwargs["api_key"] = (
                        secrets_dict.get(preferred_key, "")
                        or os.environ.get(preferred_key, "")
                    )

                # Fallback: try any *_API_KEY from secrets
                if not wf_kwargs.get("api_key"):
                    for key, value in secrets_dict.items():
                        if key.endswith("_API_KEY") and value:
                            wf_kwargs["api_key"] = value
                            break

            # Validate that we actually have an API key before starting
            is_local = wf_kwargs.get("api_key") == "ollama-local"
            if not is_local and not wf_kwargs.get("api_key") and not os.environ.get("LLM_API_KEY"):
                raise ValueError(
                    "No API key configured. Add the required key "
                    "in the Settings panel (API Keys section) or set the "
                    "LLM_API_KEY environment variable."
                )

            # Create output_dir for watching
            output_dir = wf_kwargs.get("output_dir")
            if not output_dir:
                import tempfile

                tmp = tempfile.mkdtemp(prefix="awp_ui_run_")
                wf_kwargs["output_dir"] = tmp
                output_dir = tmp

            workspace_dir = Path(output_dir)

            # Inject experiment context from previous runs (if in a session)
            if session_id:
                try:
                    ctx, files = _build_experiment_context(session_id)
                    if ctx:
                        wf_kwargs["experiment_context"] = ctx
                    if files:
                        _write_experiment_state_files(workspace_dir, files)
                except Exception as exc:
                    logger.warning("Failed to load experiment context: %s", exc)

            # Start the directory watcher
            watcher = _RunDirWatcher(run_id, workspace_dir)
            watcher_thread = threading.Thread(
                target=watcher.watch,
                daemon=True,
                name=f"awp-watcher-{run_id[:8]}",
            )
            watcher_thread.start()

            # Execute the workflow (blocking)
            wf = AgentWorkflow(**wf_kwargs)
            result = wf.run()

            # Do a final scan to catch any files the watcher missed, then stop
            run_dir = watcher._find_run_dir()
            if run_dir and run_dir.exists():
                watcher._scan_dir(run_dir)
            watcher.stop()
            watcher_thread.join(timeout=3)

            status = result.get("status", "complete")

            # Inject workspace path into result metadata for graph builder
            if result and isinstance(result, dict):
                metadata = result.setdefault("metadata", {})
                metadata["workspace"] = str(workspace_dir)
                metadata["output_dir"] = str(output_dir)

            # Emit agent.complete with the result
            event_bus.emit_threadsafe(
                run_id,
                _make_event(
                    run_id,
                    EventType.AGENT_COMPLETE,
                    {
                        "agent_name": "Manager",
                        "node_id": "manager",
                        "result": result.get("result", result),
                        "confidence": result.get("result", {}).get("confidence")
                        if isinstance(result.get("result"), dict)
                        else None,
                    },
                ),
            )

        except ImportError as exc:
            logger.error(
                "AWP runtime not installed: %s. "
                "Install with: pip install -e packages/awp-runtime/",
                exc,
            )
            status = "error"
            result = {"error": f"AWP runtime not available: {exc}"}
            event_bus.emit_threadsafe(
                run_id,
                _make_event(
                    run_id, EventType.ERROR, {"message": str(exc)}
                ),
            )

        except Exception as exc:
            logger.exception("Run %s failed with exception", run_id)
            status = "error"
            result = {"error": str(exc)}
            event_bus.emit_threadsafe(
                run_id,
                _make_event(
                    run_id, EventType.ERROR, {"message": str(exc)}
                ),
            )

        finally:
            # Clean up injected environment variables
            for key in _injected_env_keys:
                os.environ.pop(key, None)

            # Emit run.complete
            event_bus.emit_threadsafe(
                run_id,
                _make_event(
                    run_id,
                    EventType.RUN_COMPLETE,
                    {"status": status, "result": result},
                ),
            )
            # Close the event bus channel for this run
            event_bus.close_run_threadsafe(run_id)

            # Persist result to DB (best-effort from background thread)
            try:
                import asyncio

                loop = event_bus._loop
                if loop and not loop.is_closed():
                    asyncio.run_coroutine_threadsafe(
                        self._persist_result(run_id, status, result),
                        loop,
                    ).result(timeout=10)
            except Exception:
                logger.warning(
                    "Failed to persist result for run %s", run_id, exc_info=True
                )

            # Cleanup active runs
            with _active_lock:
                _active_runs.pop(run_id, None)
            with _seq_lock:
                _seq_counters.pop(run_id, None)

    @staticmethod
    async def _persist_result(
        run_id: str,
        status: str,
        result: dict[str, Any] | None,
    ) -> None:
        """Persist the final result to SQLite."""
        # Import lazily to avoid circular refs at module level
        from server.app import store

        await store.update_run(
            run_id,
            status=status,
            result=result,
            completed_at=datetime.now(tz=timezone.utc).isoformat(),
        )

    @staticmethod
    def _config_to_workflow_kwargs(config: dict[str, Any]) -> dict[str, Any]:
        """Map a WorkflowConfig dict to AgentWorkflow constructor kwargs."""
        kwargs: dict[str, Any] = {}

        # Required
        kwargs["task"] = config["task"]
        kwargs["model"] = config["model"]

        # Optional string/none fields
        for key in ("api_key", "worker_model", "output_dir"):
            if config.get(key):
                kwargs[key] = config[key]

        # Budget ints
        for key in (
            "max_loops",
            "max_total_tokens",
            "max_wall_time",
            "max_tool_calls",
            "max_total_workers",
            "max_depth",
        ):
            if key in config:
                kwargs[key] = config[key]

        # Sandbox
        if "sandbox" in config:
            kwargs["sandbox"] = config["sandbox"]
        if config.get("packages"):
            kwargs["packages"] = config["packages"]

        # Tools
        if config.get("tools") is not None:
            kwargs["tools"] = config["tools"]
        if config.get("forbidden_tools") is not None:
            kwargs["forbidden_tools"] = config["forbidden_tools"]

        # Booleans
        for key in ("code_mode", "tool_creation", "verbose"):
            if key in config:
                kwargs[key] = config[key]

        # Dict/list fields
        if config.get("secrets"):
            kwargs["secrets"] = config["secrets"]
        # Skills: explicit list or scanned from skills_dir
        skills_list = list(config.get("skills") or [])
        skills_dir = config.get("skills_dir", "")
        if skills_dir:
            from pathlib import Path as _P

            sd = _P(skills_dir).expanduser().resolve()
            if sd.is_dir():
                for entry in sorted(sd.iterdir()):
                    if entry.name.startswith("."):
                        continue
                    if entry.is_file() and entry.suffix.lower() in (".md", ".zip", ".skill"):
                        skills_list.append(str(entry))
                    elif entry.is_dir() and (entry / "SKILL.md").exists():
                        skills_list.append(str(entry))
        if skills_list:
            kwargs["skills"] = skills_list

        # Inputs: merge dict inputs and file paths
        inputs: dict[str, Any] = dict(config.get("inputs", {}))
        for i, fpath in enumerate(config.get("input_files", [])):
            name = Path(fpath).stem or f"file_{i}"
            inputs[name] = fpath
        kwargs["inputs"] = inputs

        # Experiment context (injected by session-aware runs)
        if config.get("experiment_context"):
            kwargs["experiment_context"] = config["experiment_context"]

        return kwargs


# Module-level singleton
runner_service = RunnerService()
