"""ABREKA Web UI — FastAPI application."""

from __future__ import annotations

import json
from pathlib import Path

import markdown as md
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from markupsafe import Markup
from starlette.templating import Jinja2Templates

from abreka.config import load_config, load_goal, load_index, load_prompts
from abreka.text_match import parse_query
from abreka.run_control import stop_after_current_requested
from abreka.runtime_costs import stage_ordered_keys
from abreka.stage_sessions import load_session_entries, stage_session_filename
from abreka.models import WikiPage
from abreka.persistence import (
    experiment_dir,
    load_backlog_index,
    load_backlog_item,
    load_experiment,
    normalize_backlog_id,
)
from abreka.wiki import (
    InvalidSlugError,
    WikiError,
    WikiPageNotFoundError,
    delete_wiki_page,
    load_wiki_index,
    load_wiki_page,
    normalize_slug,
    save_wiki_page,
    wiki_page_path,
)
from abreka.webui.files import (
    get_pygments_css,
    list_experiment_files,
    render_file_content,
    safe_resolve,
)

_TEMPLATES_DIR = Path(__file__).parent / "templates"


def _guide_path() -> Path:
    return Path(__file__).resolve().parent.parent / "docs" / "user_guide.md"


def create_app(abreka_dir: Path) -> FastAPI:
    """Create the FastAPI application bound to a specific abreka project."""
    app = FastAPI(title="ABREKA Web UI")

    templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
    templates.env.auto_reload = True
    templates.env.cache = None  # disable template caching (Jinja2/Py3.14 compat)
    templates.env.filters["status_color"] = _status_color
    templates.env.filters["backlog_status_color"] = _backlog_status_color
    templates.env.filters["tool_summary"] = _tool_summary
    templates.env.filters["md"] = _md_filter
    templates.env.filters["filesize"] = _filesize_filter
    templates.env.filters["human_duration"] = _human_duration_filter

    # -- Routes ----------------------------------------------------------------

    @app.get("/", response_class=HTMLResponse)
    async def dashboard(request: Request):
        config = load_config(abreka_dir)
        goal = load_goal(abreka_dir)
        index = load_index(abreka_dir)

        status_counts: dict[str, int] = {}
        for entry in index.values():
            s = entry.get("status", "unknown")
            status_counts[s] = status_counts.get(s, 0) + 1

        stop_requested = stop_after_current_requested(abreka_dir)

        recent = sorted(
            index.values(), key=lambda e: e.get("created_at", ""), reverse=True
        )[:10]

        backlog_index = load_backlog_index(abreka_dir)
        backlog_open = sum(
            1 for e in backlog_index.values() if e.get("status") == "open"
        )

        timeline = _compute_metric_timeline(
            index,
            config.experiment.primary_metric,
            config.experiment.metric_direction,
        )

        return templates.TemplateResponse(
            request,
            "dashboard.html",
            {
                "config": config,
                "goal": goal,
                "index": index,
                "status_counts": status_counts,
                "stop_requested": stop_requested,
                "recent": recent,
                "total": len(index),
                "backlog_open": backlog_open,
                "timeline": timeline,
            },
        )

    @app.get("/experiments", response_class=HTMLResponse)
    async def experiment_list(request: Request):
        index = load_index(abreka_dir)
        config = load_config(abreka_dir)
        status_filter = request.query_params.get("status", "")
        sort_by = request.query_params.get("sort", "created")
        order = request.query_params.get("order", "desc")

        pm_split, pm_key = _parse_metric_ref(config.experiment.primary_metric)

        if sort_by == "created":
            sort_key = lambda e: e.get("created_at", "")
        elif sort_by == "metric":
            neg_inf = float("-inf")
            sort_key = lambda e: e.get("metrics", {}).get(pm_split, {}).get(pm_key) if e.get("metrics", {}).get(pm_split, {}).get(pm_key) is not None else neg_inf
        else:
            sort_key = lambda e: e.get("id", "")
        experiments = sorted(index.values(), key=sort_key, reverse=(order != "asc"))
        if status_filter:
            experiments = [e for e in experiments if e.get("status") == status_filter]

        return templates.TemplateResponse(
            request,
            "experiments.html",
            {
                "experiments": experiments,
                "status_filter": status_filter,
                "sort_by": sort_by,
                "order": order,
                "primary_metric": config.experiment.primary_metric,
                "pm_split": pm_split,
                "pm_key": pm_key,
            },
        )

    @app.get("/experiments/{exp_id}", response_class=HTMLResponse)
    async def experiment_detail(request: Request, exp_id: str):
        exp_id = f"{int(exp_id):04d}"
        exp = load_experiment(abreka_dir, exp_id)
        config = load_config(abreka_dir)
        exp_path = experiment_dir(abreka_dir, exp_id)

        findings = ""
        findings_path = exp_path / "findings.md"
        if findings_path.is_file():
            findings = findings_path.read_text()

        run_output = ""
        run_output_path = exp_path / "artifacts" / "run_output.txt"
        if run_output_path.is_file():
            run_output = run_output_path.read_text()

        sessions = _discover_sessions(exp_path)
        runtime_rows, runtime_total_seconds = _build_runtime_rows(exp.runtime_costs)

        index = load_index(abreka_dir)
        children = sorted(
            eid for eid, entry in index.items() if exp_id in entry.get("parents", [])
        )

        file_entries = list_experiment_files(exp_path)

        return templates.TemplateResponse(
            request,
            "experiment.html",
            {
                "exp": exp,
                "exp_id": exp_id,
                "config": config,
                "findings": findings,
                "run_output": run_output,
                "sessions": sessions,
                "children": children,
                "runtime_rows": runtime_rows,
                "runtime_total_seconds": runtime_total_seconds,
                "file_entries": file_entries,
                "pygments_css": pygments_css,
            },
        )

    @app.get("/experiments/{exp_id}/sessions/{stage}", response_class=HTMLResponse)
    async def session_view(request: Request, exp_id: str, stage: str):
        exp_id = f"{int(exp_id):04d}"
        exp = load_experiment(abreka_dir, exp_id)
        exp_path = experiment_dir(abreka_dir, exp_id)

        filename = stage_session_filename(stage)
        session_path = exp_path / "artifacts" / filename

        entries = load_session_entries(session_path)
        blocks = parse_pi_session_entries(entries)

        return templates.TemplateResponse(
            request,
            "session.html",
            {
                "exp": exp,
                "exp_id": exp_id,
                "stage": stage,
                "blocks": blocks,
            },
        )

    pygments_css = get_pygments_css()

    @app.get("/experiments/{exp_id}/files", response_class=HTMLResponse)
    async def files_list(request: Request, exp_id: str):
        exp_id = f"{int(exp_id):04d}"
        exp = load_experiment(abreka_dir, exp_id)
        exp_path = experiment_dir(abreka_dir, exp_id)
        entries = list_experiment_files(exp_path)
        return templates.TemplateResponse(
            request,
            "files_list.html",
            {"exp": exp, "exp_id": exp_id, "entries": entries},
        )

    @app.get("/experiments/{exp_id}/raw/{path:path}")
    async def file_raw(exp_id: str, path: str):
        exp_id = f"{int(exp_id):04d}"
        exp_path = experiment_dir(abreka_dir, exp_id)
        resolved = safe_resolve(exp_path, path)
        if resolved is None:
            return JSONResponse({"error": "Invalid path."}, status_code=400)
        if not resolved.is_file():
            return JSONResponse({"error": "File not found."}, status_code=404)
        return FileResponse(resolved)

    @app.get("/experiments/{exp_id}/files/{path:path}", response_class=HTMLResponse)
    async def file_view(request: Request, exp_id: str, path: str):
        exp_id = f"{int(exp_id):04d}"
        exp = load_experiment(abreka_dir, exp_id)
        exp_path = experiment_dir(abreka_dir, exp_id)

        resolved = safe_resolve(exp_path, path)
        if resolved is None:
            return HTMLResponse("Invalid path.", status_code=400)
        if not resolved.is_file():
            return HTMLResponse("File not found.", status_code=404)

        fc = render_file_content(resolved)
        return templates.TemplateResponse(
            request,
            "file_view.html",
            {
                "exp": exp,
                "exp_id": exp_id,
                "path": path,
                "fc": fc,
                "pygments_css": pygments_css,
            },
        )

    @app.get("/tree", response_class=HTMLResponse)
    async def experiment_tree(request: Request):
        index = load_index(abreka_dir)
        config = load_config(abreka_dir)

        children: dict[str, list[str]] = {}
        all_ids = set(index.keys())
        for eid, entry in index.items():
            for pid in entry.get("parents", []):
                children.setdefault(pid, []).append(eid)

        roots = [
            eid
            for eid, entry in index.items()
            if not entry.get("parents") or not any(p in all_ids for p in entry["parents"])
        ]
        roots.sort()

        pm_split, pm_key = _parse_metric_ref(config.experiment.primary_metric)

        return templates.TemplateResponse(
            request,
            "tree.html",
            {
                "index": index,
                "children": children,
                "roots": roots,
                "primary_metric": config.experiment.primary_metric,
                "pm_split": pm_split,
                "pm_key": pm_key,
            },
        )

    @app.get("/backlog", response_class=HTMLResponse)
    async def backlog_list(request: Request):
        backlog_index = load_backlog_index(abreka_dir)
        status_filter = request.query_params.get("status", "")
        sort_by = request.query_params.get("sort", "priority")

        items = list(backlog_index.values())
        if status_filter:
            items = [e for e in items if e.get("status") == status_filter]

        if sort_by == "priority":
            items.sort(key=lambda e: (-e.get("priority", 0), e.get("id", "")))
        elif sort_by == "time":
            items.sort(key=lambda e: e.get("created_at", ""), reverse=True)
        else:
            items.sort(key=lambda e: e.get("id", ""))

        return templates.TemplateResponse(
            request,
            "backlog.html",
            {
                "items": items,
                "status_filter": status_filter,
                "sort_by": sort_by,
            },
        )

    @app.get("/backlog/{item_id}", response_class=HTMLResponse)
    async def backlog_detail(request: Request, item_id: str):
        item_id = normalize_backlog_id(item_id)
        item = load_backlog_item(abreka_dir, item_id)
        return templates.TemplateResponse(
            request,
            "backlog_detail.html",
            {"item": item},
        )

    @app.get("/search", response_class=HTMLResponse)
    async def search_experiments(request: Request):
        index = load_index(abreka_dir)
        config = load_config(abreka_dir)

        q = request.query_params.get("q", "").strip()
        status_filters = request.query_params.getlist("status")
        tag_filters = request.query_params.getlist("tag")
        has_metrics = request.query_params.get("has_metrics", "")
        parent_filter = request.query_params.get("parent", "").strip()
        root_only = request.query_params.get("root_only", "") == "1"
        sort_by = request.query_params.get("sort", "created")
        order = request.query_params.get("order", "desc")

        experiments = list(index.values())

        # Compute global facets (before filtering) for tag list
        all_tags: dict[str, int] = {}
        for e in experiments:
            for t in e.get("tags", []):
                all_tags[t] = all_tags.get(t, 0) + 1

        # Apply filters
        search_error = ""
        if q:
            try:
                pattern = parse_query(q)
            except ValueError as exc:
                search_error = str(exc)
                experiments = []
                pattern = None
            if pattern is not None:
                experiments = [
                    e for e in experiments
                    if pattern.search(e.get("hypothesis", ""))
                    or pattern.search(e.get("method", ""))
                    or any(pattern.search(t) for t in e.get("tags", []))
                ]

        if status_filters:
            experiments = [
                e for e in experiments if e.get("status") in status_filters
            ]

        if tag_filters:
            tag_set = set(tag_filters)
            experiments = [
                e for e in experiments
                if tag_set.intersection(e.get("tags", []))
            ]

        if has_metrics == "yes":
            experiments = [e for e in experiments if e.get("metrics")]
        elif has_metrics == "no":
            experiments = [e for e in experiments if not e.get("metrics")]

        if root_only:
            experiments = [e for e in experiments if not e.get("parents")]

        if parent_filter:
            experiments = [
                e for e in experiments if parent_filter in e.get("parents", [])
            ]

        # Compute facet counts from filtered results
        status_counts: dict[str, int] = {}
        tag_counts: dict[str, int] = {}
        for e in experiments:
            s = e.get("status", "unknown")
            status_counts[s] = status_counts.get(s, 0) + 1
            for t in e.get("tags", []):
                tag_counts[t] = tag_counts.get(t, 0) + 1

        # Sort
        pm_split, pm_key = _parse_metric_ref(config.experiment.primary_metric)
        if sort_by == "metric":
            neg_inf = float("-inf")
            sort_key = lambda e: e.get("metrics", {}).get(pm_split, {}).get(pm_key) if e.get("metrics", {}).get(pm_split, {}).get(pm_key) is not None else neg_inf
        elif sort_by == "id":
            sort_key = lambda e: e.get("id", "")
        else:
            sort_key = lambda e: e.get("created_at", "")
        experiments = sorted(experiments, key=sort_key, reverse=(order != "asc"))

        return templates.TemplateResponse(
            request,
            "search.html",
            {
                "experiments": experiments,
                "q": q,
                "status_filters": status_filters,
                "tag_filters": tag_filters,
                "has_metrics": has_metrics,
                "parent_filter": parent_filter,
                "root_only": root_only,
                "sort_by": sort_by,
                "order": order,
                "status_counts": status_counts,
                "tag_counts": tag_counts,
                "all_tags": all_tags,
                "total_results": len(experiments),
                "search_error": search_error,
                "primary_metric": config.experiment.primary_metric,
                "pm_split": pm_split,
                "pm_key": pm_key,
            },
        )

    @app.get("/config", response_class=HTMLResponse)
    async def config_view(request: Request):
        config = load_config(abreka_dir)
        goal = load_goal(abreka_dir)
        prompts = load_prompts(abreka_dir)
        wiki_index = load_wiki_index(abreka_dir)
        return templates.TemplateResponse(
            request,
            "config.html",
            {
                "config": config,
                "goal": goal,
                "prompts": prompts,
                "wiki_count": len(wiki_index),
            },
        )

    @app.get("/wiki", response_class=HTMLResponse)
    async def wiki_list(request: Request):
        index = load_wiki_index(abreka_dir)
        q = request.query_params.get("q", "").strip()
        tag_filter = request.query_params.get("tag", "").strip()

        entries = sorted(index.values(), key=lambda e: e.get("slug", ""))
        all_tags: dict[str, int] = {}
        for e in entries:
            for t in e.get("tags", []):
                all_tags[t] = all_tags.get(t, 0) + 1

        if tag_filter:
            entries = [e for e in entries if tag_filter in e.get("tags", [])]

        search_error = ""
        if q:
            try:
                pattern = parse_query(q)
            except ValueError as exc:
                search_error = str(exc)
                entries = []
                pattern = None
            if pattern is not None:
                # Search across slug, title, summary, tags. Bodies aren't in
                # the index; agents can use the CLI for body search.
                entries = [
                    e for e in entries
                    if pattern.search(e.get("slug", ""))
                    or pattern.search(e.get("title", ""))
                    or pattern.search(e.get("summary", ""))
                    or any(pattern.search(t) for t in e.get("tags", []))
                ]

        return templates.TemplateResponse(
            request,
            "wiki.html",
            {
                "entries": entries,
                "all_tags": all_tags,
                "q": q,
                "tag_filter": tag_filter,
                "search_error": search_error,
            },
        )

    @app.get("/wiki/{slug}", response_class=HTMLResponse)
    async def wiki_detail(request: Request, slug: str):
        try:
            page = load_wiki_page(abreka_dir, slug)
        except WikiPageNotFoundError:
            return HTMLResponse("Wiki page not found.", status_code=404)
        return templates.TemplateResponse(
            request,
            "wiki_page.html",
            {"page": page},
        )

    @app.get("/guide", response_class=HTMLResponse)
    async def user_guide(request: Request):
        guide_path = _guide_path()
        text = guide_path.read_text() if guide_path.is_file() else ""
        return templates.TemplateResponse(
            request,
            "guide.html",
            {"guide": text},
        )

    # -- API endpoints ---------------------------------------------------------

    @app.get("/api/experiments/{exp_id}/file/{path:path}")
    async def api_file_content(exp_id: str, path: str):
        exp_id = f"{int(exp_id):04d}"
        exp_path = experiment_dir(abreka_dir, exp_id)
        resolved = safe_resolve(exp_path, path)
        if resolved is None:
            return JSONResponse({"error": "Invalid path."}, status_code=400)
        if not resolved.is_file():
            return JSONResponse({"error": "File not found."}, status_code=404)
        fc = render_file_content(resolved)
        return JSONResponse({
            "language": fc.language,
            "original_size": fc.original_size,
            "truncated": fc.truncated,
            "is_binary": fc.is_binary,
            "is_image": fc.is_image,
            "raw_url": f"/experiments/{exp_id}/raw/{path}",
            "highlighted_html": fc.highlighted_html,
        })

    @app.put("/api/goal")
    async def save_goal(request: Request):
        data = await request.json()
        goal_path = abreka_dir / "goal.md"
        goal_path.write_text(data["content"])
        return JSONResponse({"ok": True})

    @app.put("/api/prompts/{name}")
    async def save_prompt(request: Request, name: str):
        if not name.isalnum() and not all(c.isalnum() or c == "_" for c in name):
            return JSONResponse({"error": "Invalid prompt name"}, status_code=400)
        prompt_path = abreka_dir / "prompts" / f"{name}.md"
        if not prompt_path.is_file():
            return JSONResponse({"error": "Prompt not found"}, status_code=404)
        data = await request.json()
        prompt_path.write_text(data["content"])
        return JSONResponse({"ok": True})

    # Wiki API ---------------------------------------------------------

    def _normalize_tags(raw) -> list[str]:
        if isinstance(raw, list):
            return [str(t).strip() for t in raw if str(t).strip()]
        if isinstance(raw, str):
            return [t.strip() for t in raw.split(",") if t.strip()]
        return []

    @app.get("/api/wiki")
    async def api_list_wiki():
        index = load_wiki_index(abreka_dir)
        return JSONResponse({"pages": sorted(index.values(), key=lambda e: e.get("slug", ""))})

    @app.get("/api/wiki/{slug}")
    async def api_get_wiki(slug: str):
        try:
            page = load_wiki_page(abreka_dir, slug)
        except WikiPageNotFoundError:
            return JSONResponse({"error": "wiki page not found"}, status_code=404)
        return JSONResponse(
            {
                "slug": page.slug,
                "title": page.title,
                "summary": page.summary,
                "tags": page.tags,
                "body": page.body,
                "created_at": page.created_at.isoformat(),
                "updated_at": page.updated_at.isoformat(),
                "created_by_exp": page.created_by_exp,
                "updated_by_exp": page.updated_by_exp,
            }
        )

    @app.post("/api/wiki")
    async def api_create_wiki(request: Request):
        data = await request.json()
        title = (data.get("title") or "").strip()
        if not title:
            return JSONResponse({"error": "title is required"}, status_code=400)
        try:
            slug = normalize_slug(data.get("slug") or title)
        except InvalidSlugError as exc:
            return JSONResponse({"error": str(exc)}, status_code=400)
        if wiki_page_path(abreka_dir, slug).is_file():
            return JSONResponse(
                {"error": f"wiki page {slug!r} already exists"}, status_code=409,
            )
        page = WikiPage(
            slug=slug,
            title=title,
            summary=(data.get("summary") or "").strip(),
            tags=_normalize_tags(data.get("tags")),
            body=data.get("content") or "",
        )
        save_wiki_page(page, abreka_dir)
        return JSONResponse({"ok": True, "slug": page.slug})

    @app.put("/api/wiki/{slug}")
    async def api_update_wiki(request: Request, slug: str):
        try:
            page = load_wiki_page(abreka_dir, slug)
        except WikiPageNotFoundError:
            return JSONResponse({"error": "wiki page not found"}, status_code=404)
        data = await request.json()
        if "title" in data:
            page.title = (data.get("title") or "").strip()
        if "summary" in data:
            page.summary = (data.get("summary") or "").strip()
        if "tags" in data:
            page.tags = _normalize_tags(data.get("tags"))
        if "content" in data:
            page.body = data.get("content") or ""
        from datetime import datetime, timezone
        page.updated_at = datetime.now(timezone.utc)
        try:
            save_wiki_page(page, abreka_dir)
        except WikiError as exc:
            return JSONResponse({"error": str(exc)}, status_code=400)
        return JSONResponse({"ok": True})

    @app.delete("/api/wiki/{slug}")
    async def api_delete_wiki(slug: str):
        try:
            delete_wiki_page(abreka_dir, slug)
        except WikiPageNotFoundError:
            return JSONResponse({"error": "wiki page not found"}, status_code=404)
        except InvalidSlugError as exc:
            return JSONResponse({"error": str(exc)}, status_code=400)
        return JSONResponse({"ok": True})

    return app


# -- Session parser ------------------------------------------------------------


def parse_pi_session_entries(entries: list[dict]) -> list[dict]:
    """Parse native pi session entries into a flat list of conversation blocks."""
    blocks: list[dict] = []
    pending_tools: dict[str, dict] = {}

    by_id = {
        entry.get("id"): entry
        for entry in entries
        if entry.get("type") != "session" and entry.get("id")
    }
    if by_id:
        leaf_id = next(reversed(by_id))
        branch_ids: set[str] = set()
        while leaf_id:
            branch_ids.add(leaf_id)
            parent = by_id.get(leaf_id, {}).get("parentId")
            leaf_id = parent if parent in by_id else None
        active_entries = [
            entry for entry in entries
            if entry.get("type") == "session" or entry.get("id") in branch_ids
        ]
    else:
        active_entries = entries

    for entry in active_entries:
        if entry.get("type") != "message":
            continue
        msg = entry.get("message", {})
        role = msg.get("role")

        if role == "user":
            content = msg.get("content", [])
            if isinstance(content, str):
                text = content
            else:
                text = "".join(
                    c.get("text", "")
                    for c in content
                    if isinstance(c, dict) and c.get("type") == "text"
                )
            if text.strip():
                blocks.append({"type": "user", "text": text})
            continue

        if role == "assistant":
            thinking_parts: list[str] = []
            text_parts: list[str] = []
            for content in msg.get("content", []):
                if not isinstance(content, dict):
                    continue
                ctype = content.get("type")
                if ctype == "thinking":
                    thinking_parts.append(content.get("thinking", ""))
                elif ctype == "text":
                    text_parts.append(content.get("text", ""))
                elif ctype == "toolCall":
                    pending_tools[content.get("id", "")] = {
                        "name": content.get("name", "unknown"),
                        "args": content.get("arguments", {}),
                    }
            thinking = "".join(thinking_parts)
            text = "".join(text_parts)
            if thinking or text:
                blocks.append(
                    {
                        "type": "assistant",
                        "thinking": thinking,
                        "text": text,
                        "model": msg.get("model", ""),
                        "usage": msg.get("usage", {}),
                    }
                )
            continue

        if role == "toolResult":
            tool_id = msg.get("toolCallId", "")
            info = pending_tools.pop(
                tool_id,
                {
                    "name": msg.get("toolName", "unknown"),
                    "args": {},
                },
            )
            result_text = "".join(
                c.get("text", "")
                for c in msg.get("content", [])
                if isinstance(c, dict) and c.get("type") == "text"
            )
            blocks.append(
                {
                    "type": "tool",
                    "name": info["name"],
                    "args": info["args"],
                    "result": result_text,
                    "is_error": msg.get("isError", False),
                }
            )
            continue

        if role == "bashExecution":
            blocks.append(
                {
                    "type": "tool",
                    "name": "bash",
                    "args": {"command": msg.get("command", "")},
                    "result": msg.get("output", ""),
                    "is_error": msg.get("exitCode") not in (0, None),
                }
            )

    return blocks


# -- Helpers -------------------------------------------------------------------


def _discover_sessions(exp_path: Path) -> list[dict]:
    """Return list of available session files for an experiment."""
    sessions = []
    artifacts = exp_path / "artifacts"
    for stage_name in ("propose", "implement", "test"):
        filename = stage_session_filename(stage_name)
        path = artifacts / filename
        if path.is_file():
            size_kb = path.stat().st_size / 1024
            sessions.append({"stage": stage_name, "filename": filename, "size_kb": size_kb})

    for path in sorted(artifacts.glob("pi_session_run_repair_*.jsonl")):
        stage_name = path.stem.removeprefix("pi_session_")
        size_kb = path.stat().st_size / 1024
        sessions.append({"stage": stage_name, "filename": path.name, "size_kb": size_kb})

    filename = stage_session_filename("evaluate")
    path = artifacts / filename
    if path.is_file():
        size_kb = path.stat().st_size / 1024
        sessions.append({"stage": "evaluate", "filename": filename, "size_kb": size_kb})

    return sessions


def _build_runtime_rows(runtime_costs: dict[str, dict[str, object]] | None) -> tuple[list[dict], float]:
    """Normalize runtime-cost data for template rendering."""
    rows: list[dict] = []
    total_runtime = 0.0

    for stage in stage_ordered_keys(runtime_costs or {}):
        info = (runtime_costs or {}).get(stage, {})
        if not isinstance(info, dict):
            continue
        try:
            attempts = int(info.get("attempts", 0) or 0)
        except (TypeError, ValueError):
            attempts = 0
        try:
            total = float(info.get("seconds_total", 0.0) or 0.0)
        except (TypeError, ValueError):
            total = 0.0
        try:
            last = float(info.get("seconds_last", 0.0) or 0.0)
        except (TypeError, ValueError):
            last = 0.0
        rows.append(
            {
                "stage": stage,
                "attempts": attempts,
                "seconds_total": total,
                "seconds_last": last,
                "last_status": str(info.get("last_status", "")),
                "last_started_at": str(info.get("last_started_at", "")),
                "last_finished_at": str(info.get("last_finished_at", "")),
            }
        )
        total_runtime += total

    return rows, total_runtime


def _parse_metric_ref(ref: str) -> tuple[str, str]:
    """Parse 'val:accuracy' into ('val', 'accuracy')."""
    parts = ref.split(":")
    if len(parts) > 1:
        return parts[0], parts[-1]
    return "", parts[0]


def _compute_metric_timeline(
    index: dict, primary_metric: str, direction: str
) -> dict:
    """Compute dashboard chart data: points sorted by experiment number.

    Each point carries ``id``, ``x`` (int id), ``y`` (raw metric value),
    ``branch_root`` (topmost ancestor via ``parents[0]``), ``status``,
    ``hypothesis``, and ``best_so_far`` (running optimum under ``direction``).
    """
    split, key = _parse_metric_ref(primary_metric)
    maximize = direction != "minimize"

    def root_of(eid: str) -> str:
        cur = eid
        seen: set[str] = set()
        while True:
            if cur in seen:
                return cur
            seen.add(cur)
            entry = index.get(cur)
            if not entry:
                return cur
            parents = entry.get("parents") or []
            if not parents or parents[0] not in index:
                return cur
            cur = parents[0]

    points: list[dict] = []
    for eid, entry in index.items():
        metrics = entry.get("metrics") or {}
        raw = metrics.get(split, {}).get(key) if split else metrics.get(key)
        if raw is None:
            continue
        try:
            y = float(raw)
            x = int(eid)
        except (TypeError, ValueError):
            continue
        points.append(
            {
                "id": eid,
                "x": x,
                "y": y,
                "branch_root": root_of(eid),
                "status": entry.get("status", ""),
                "hypothesis": entry.get("hypothesis", ""),
            }
        )

    points.sort(key=lambda p: p["x"])

    running: float | None = None
    for p in points:
        y = p["y"]
        if running is None:
            running = y
        elif maximize:
            if y > running:
                running = y
        else:
            if y < running:
                running = y
        p["best_so_far"] = running

    return {
        "primary_metric": primary_metric,
        "direction": direction,
        "points": points,
    }


# -- Template filters ----------------------------------------------------------


def _status_color(status: str) -> str:
    return {
        "proposed": "#3b82f6",
        "implementing": "#6366f1",
        "testing": "#f59e0b",
        "running": "#eab308",
        "completed": "#22c55e",
        "failed": "#ef4444",
    }.get(status, "#6b7280")


def _backlog_status_color(status: str) -> str:
    return {
        "open": "#3b82f6",
        "resolved": "#22c55e",
        "dismissed": "#6b7280",
    }.get(status, "#6b7280")


def _tool_summary(tool: dict) -> str:
    name = tool.get("name", "?")
    args = tool.get("args", {})
    if name == "bash":
        cmd = args.get("command", "")
        if len(cmd) > 100:
            cmd = cmd[:100] + "..."
        return f"$ {cmd}"
    if name in ("read", "write", "edit"):
        path = args.get("file_path", args.get("path", ""))
        return f"{name} {path}"
    if name == "glob":
        return f"glob {args.get('pattern', '')}"
    if name == "grep":
        return f"grep {args.get('pattern', '')}"
    return name


def _filesize_filter(value: int | float) -> str:
    """Format a byte count as a human-readable size."""
    if value < 1024:
        return f"{int(value)} B"
    if value < 1048576:
        return f"{value / 1024:.1f} KB"
    return f"{value / 1048576:.1f} MB"


def _human_duration_filter(value: int | float) -> str:
    """Format seconds as a human-friendly duration."""
    total_seconds = max(0.0, float(value))
    hours = int(total_seconds // 3600)
    remaining = total_seconds - (hours * 3600)
    minutes = int(remaining // 60)
    seconds = remaining - (minutes * 60)

    parts: list[str] = []
    if hours:
        parts.append(f"{hours}h")
    if hours or minutes:
        parts.append(f"{minutes}m")
    parts.append(f"{seconds:.2f}s")
    return " ".join(parts)


def _md_filter(text: str) -> Markup:
    """Convert markdown text to HTML."""
    if not text:
        return Markup("")
    html = md.markdown(text, extensions=["fenced_code", "tables", "nl2br"])
    return Markup(html)
