"""The read-mostly console routes + the one write (``POST /run`` = submit a request).

``GET /`` opens the first project on Catalog; ``GET /p/{slug}/{view}`` selects a
lens (catalog · running · history · ci); ``POST /p/{slug}/run`` submits a run
**request** to the warden and re-renders Running with the honest outcome
(``started`` / ``coalesced`` / ``queued`` / ``refused``) — it never executes a test.

Every handler is non-mutating except that one POST, and that POST only forwards the
user's intent to the warden (the sole executor). A missing project renders honest
absence (HTTP 200); an unreachable warden renders the fail-closed ribbon — never a
500, never a blank, never a fabricated pass.
"""

from __future__ import annotations

import asyncio
import json
import uuid
from typing import TYPE_CHECKING, Any, cast

from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from starlette.concurrency import run_in_threadpool

from ..warden import RunRequest, WardenUnreachable
from ..warden.leases import age_seconds, now_iso
from ..warden.outcomes import DISPLAY_ORDER
from .discovery import Project, discover_projects
from .reads import ProjectReader

if TYPE_CHECKING:
    from collections.abc import AsyncIterator
    from pathlib import Path

    from fastapi.templating import Jinja2Templates

VIEWS: tuple[str, ...] = ("catalog", "running", "history", "ci")
VIEW_LABELS: dict[str, str] = {
    "catalog": "Catalog",
    "running": "Running",
    "history": "History",
    "ci": "CI",
}
#: The rail legend — the state language this tool pins (mirrors the wireframe).
STATE_LEGEND: tuple[tuple[str, str], ...] = (
    ("running", "running (lease held)"),
    ("queued", "queued (serialized)"),
    ("idle", "idle"),
    ("down", "warden down (fail-closed)"),
)

#: Live-stream tuning — poll the NDJSON this often; give up after this much idle.
_STREAM_POLL_S = 0.4
_STREAM_IDLE_S = 90.0
#: Event kinds that end a run's live stream (spec §4.3).
_TERMINAL_KINDS = frozenset({"test_end", "lease_released", "cancelled"})


# --------------------------------------------------------------- display helpers --
def _hhmmss(ts: str) -> str:
    return ts[11:19] if len(ts) >= 19 and ts[10] == "T" else ts


def _record_outcome(record: dict[str, Any]) -> str:
    """The run's verbatim outcome for display — full supervisor fidelity.

    The recorder preserves the verbatim result on the audit trail (an
    ``outcome`` event) whenever the fedres 0.1 enum clamped it (``hung``/
    ``crashed`` → ``error``, ``quarantined`` → ``skipped``); records written
    before the fidelity work simply have no such event and render their
    ``result`` unchanged.
    """
    for entry in record.get("audit") or []:
        if isinstance(entry, dict) and entry.get("event") == "outcome" and entry.get("note"):
            return str(entry["note"])
    return str(record.get("result", "unknown"))


def _age_s(ts: str) -> int:
    try:
        return max(0, int(age_seconds(ts, now_iso())))
    except (ValueError, TypeError):
        return 0


def _indicator(status: dict[str, Any]) -> tuple[str, str]:
    """``(state, label)`` for the rail dot: down | running | queued | idle."""
    if status.get("broker") == "down":
        return "down", "warden down"
    running = status.get("running") or []
    queued = status.get("queued") or []
    if running:
        return "running", f"running {running[0].get('test_id', '')}".strip()
    if queued:
        return "queued", f"{len(queued)} queued"
    return "idle", "idle"


def _pill(running: int, queued: int, any_down: bool) -> dict[str, str]:
    if running:
        state, label = "running", f"{running} running"
    elif any_down:
        state, label = "down", "warden down"
    else:
        state, label = "idle", "idle"
    parts: list[str] = []
    if queued:
        parts.append(f"{queued} queued")
    if any_down and state != "down":
        parts.append("warden down")
    return {"state": state, "label": label, "counts": " · ".join(parts)}


def _console_lines(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Render NDJSON events to console rows (seq · ts · kind · text)."""
    lines: list[dict[str, Any]] = []
    for event in events:
        kind = str(event.get("kind", ""))
        lines.append(
            {
                "seq": event.get("seq", ""),
                "ts": _hhmmss(str(event.get("ts", ""))),
                "kind": kind,
                "kind_class": kind.replace("_", "-"),
                "text": _event_text(event, kind),
            }
        )
    return lines


def _event_text(event: dict[str, Any], kind: str) -> str:
    if event.get("msg"):
        return str(event["msg"])
    if kind == "test_end":
        return f"result {event.get('result', '?')}"
    if kind == "assert":
        name = event.get("name") or event.get("test_id") or "assert"
        return f"{name} → {event.get('result', '?')}"
    if kind == "progress":
        return f"{round(float(event.get('progress', 0)) * 100)}%"
    if kind == "test_start":
        return f"start · {event.get('test_id', '')}".strip()
    return kind


def _running_view(reader: ProjectReader, status: dict[str, Any]) -> dict[str, Any]:
    """The lease registry made visible: running cards, queue, audit, live console."""
    running_raw = status.get("running") or []
    cards: list[dict[str, Any]] = []
    for lease in running_raw:
        holder = lease.get("holder") or {}
        hb_age = _age_s(str(lease.get("heartbeat_at", "")))
        ttl = int(lease.get("ttl_s", 0) or 0)
        cards.append(
            {
                "run_id": lease.get("run_id", ""),
                "test_id": lease.get("test_id", ""),
                "key": lease.get("key", ""),
                "origin": holder.get("origin", ""),
                "who": holder.get("who", ""),
                "progress_pct": round(float(lease.get("progress", 0) or 0) * 100),
                "heartbeat_age": hb_age,
                "ttl_s": ttl,
                "stalled": ttl > 0 and hb_age > ttl,
                "watchers": lease.get("watchers", 0),
            }
        )
    selected_run = cards[0]["run_id"] if cards else None
    events = reader.live_events(selected_run)
    return {
        "cards": cards,
        "selected_run": selected_run,
        "console": _console_lines(events),
        "last_seq": max((int(e.get("seq") or 0) for e in events), default=0),
        "queue": [
            {
                "run_id": q.get("run_id", ""),
                "test_id": q.get("test_id", ""),
                "origin": q.get("origin", ""),
                "position": q.get("position", i + 1),
            }
            for i, q in enumerate(status.get("queued") or [])
        ],
        "audit": [
            {
                "ts": _hhmmss(str(a.get("ts", ""))),
                "event": a.get("event", ""),
                "test_id": a.get("test_id", ""),
                "origin": a.get("origin", ""),
                "attached_to": a.get("attached_to", ""),
                "note": a.get("note", ""),
            }
            for a in (status.get("recent") or [])
        ],
    }


def _fmt_config(config: dict[str, Any]) -> str:
    if not config:
        return "(defaults)"
    return " ".join(f"{k}={v}" for k, v in sorted(config.items()))


def _coerce_config(raw: dict[str, str], schema: list[dict[str, Any]]) -> dict[str, Any]:
    """Turn ``cfg.<name>`` form values into a typed overlay using the config schema."""
    types = {field["name"]: field.get("type", "string") for field in schema}
    config: dict[str, Any] = {}
    for name, value in raw.items():
        kind = types.get(name, "string")
        if kind == "int":
            try:
                config[name] = int(value)
            except ValueError:
                config[name] = value
        elif kind == "bool":
            config[name] = value.lower() in {"true", "1", "on", "yes"}
        else:
            config[name] = value
    return config


def _find_test(catalog: dict[str, Any] | None, test_id: str) -> dict[str, Any] | None:
    for category in (catalog or {}).get("categories", []):
        for group in category.get("groups", []):
            for test in group.get("tests", []):
                if isinstance(test, dict) and test.get("id") == test_id:
                    return cast("dict[str, Any]", test)
    return None


# --------------------------------------------------------------------- the router --
def build_router(templates: Jinja2Templates, roots: list[Path] | None) -> APIRouter:
    router = APIRouter()

    def render(request: Request, name: str, **ctx: object) -> Response:
        return templates.TemplateResponse(request, name, ctx)

    def _chrome(active_slug: str | None) -> dict[str, Any]:
        metas: list[dict[str, Any]] = []
        running_total = queued_total = 0
        any_down = False
        for project in discover_projects(roots):
            status = ProjectReader(project.root).status()
            state, label = _indicator(status)
            running_total += len(status.get("running") or [])
            queued_total += len(status.get("queued") or [])
            any_down = any_down or status.get("broker") == "down"
            metas.append(
                {
                    "slug": project.slug,
                    "name": project.name,
                    "kind": project.kind,
                    "state": state,
                    "label": label,
                    "live": bool(status.get("running")),
                }
            )
        first = metas[0]["slug"] if metas else None
        return {
            "projects": metas,
            "pill": _pill(running_total, queued_total, any_down),
            "active": active_slug,
            "ci_slug": active_slug or first,
            "legend": STATE_LEGEND,
        }

    def _render_project(
        request: Request,
        slug: str,
        view: str,
        *,
        hist_filter: str = "all",
        hist_test: str = "all",
        hist_origin: str = "all",
        outcome: dict[str, Any] | None = None,
    ) -> Response:
        view = view if view in VIEWS else "catalog"
        active = next((p for p in discover_projects(roots) if p.slug == slug), None)
        chrome = _chrome(slug)
        if active is None:
            # Honest-absence: a slug we don't know renders UNKNOWN, never a 500.
            return render(
                request,
                "console.html",
                chrome=chrome,
                project={"slug": slug, "name": slug, "kind": ""},
                known=False,
                broker="down",
                view="catalog",
                views=VIEWS,
                view_labels=VIEW_LABELS,
                counts={"running": 0, "queued": 0, "history": 0},
                data={},
                outcome=None,
                hist_filter=hist_filter,
            )

        reader = ProjectReader(active.root)
        status = reader.status()
        broker = str(status.get("broker", "down"))
        catalog = reader.catalog()
        history = reader.history()
        data = _view_data(
            view, reader, status, catalog, history, hist_filter, hist_test, hist_origin, active
        )
        return render(
            request,
            "console.html",
            chrome=chrome,
            project={"slug": active.slug, "name": active.name, "kind": active.kind},
            known=True,
            broker=broker,
            view=view,
            views=VIEWS,
            view_labels=VIEW_LABELS,
            counts={
                "running": len(status.get("running") or []),
                "queued": len(status.get("queued") or []),
                "history": len(history),
            },
            data=data,
            outcome=outcome,
            hist_filter=hist_filter,
        )

    def _view_data(
        view: str,
        reader: ProjectReader,
        status: dict[str, Any],
        catalog: dict[str, Any] | None,
        history: list[dict[str, Any]],
        hist_filter: str,
        hist_test: str,
        hist_origin: str,
        project: Project,
    ) -> dict[str, Any]:
        running_ids = {r.get("test_id") for r in (status.get("running") or [])}
        if view == "catalog":
            return {
                "catalog": catalog,
                "running_ids": running_ids,
                "staleness": reader.catalog_staleness(),
            }
        if view == "running":
            return _running_view(reader, status)
        if view == "history":
            # A row matches result AND test AND origin. The result match is on the
            # verbatim outcome OR the record's clamped result, so "error" still finds
            # hung/crashed runs while "hung" narrows to the fact.
            def _match(record: dict[str, Any]) -> bool:
                return (
                    hist_filter in ("all", _record_outcome(record), record.get("result"))
                    and hist_test in ("all", record.get("test_id"))
                    and hist_origin in ("all", record.get("origin"))
                )

            rows = [
                {
                    **record,
                    "outcome": _record_outcome(record),
                    "started_hhmmss": _hhmmss(str(record.get("started_at", ""))),
                    "config_str": _fmt_config(record.get("effective_config") or {}),
                    "coalesced": any(
                        a.get("event") == "coalesced" for a in record.get("audit", [])
                    ),
                }
                for record in history
                if _match(record)
            ]
            return {
                "rows": rows,
                "filters": ("all", *DISPLAY_ORDER),
                "tests": sorted({str(r["test_id"]) for r in history if r.get("test_id")}),
                "origins": sorted({str(r["origin"]) for r in history if r.get("origin")}),
                "active": {"result": hist_filter, "test": hist_test, "origin": hist_origin},
                "total": len(history),
            }
        # ci: CI runs that joined the lease are run-records with origin "ci"
        ci_runs = [r for r in history if r.get("origin") == "ci"]
        repo_url = f"{project.ci_host}/repos/{project.ci_repo}" if project.ci_repo else ""
        return {
            "ci_runs": [
                {
                    **r,
                    "outcome": _record_outcome(r),
                    "started_hhmmss": _hhmmss(str(r.get("started_at", ""))),
                }
                for r in ci_runs
            ],
            "woodpecker": project.ci_host,
            "ci_repo": project.ci_repo,
            "ci_repo_url": repo_url,
            "slug": project.slug,
        }

    @router.get("/", response_class=HTMLResponse)
    def index(request: Request) -> Response:
        projects = discover_projects(roots)
        if not projects:
            return render(request, "empty.html", chrome=_chrome(None))
        return _render_project(request, projects[0].slug, "catalog")

    @router.get("/p/{slug}", response_class=HTMLResponse)
    def project_root(request: Request, slug: str) -> Response:
        return _render_project(request, slug, "catalog")

    @router.get("/p/{slug}/{view}", response_class=HTMLResponse)
    def project_view(
        request: Request,
        slug: str,
        view: str,
        result: str = "all",
        test: str = "all",
        origin: str = "all",
    ) -> Response:
        return _render_project(
            request, slug, view, hist_filter=result, hist_test=test, hist_origin=origin
        )

    @router.get("/p/{slug}/stream/{run_id}")
    async def stream_run(request: Request, slug: str, run_id: str, since: int = 0) -> Response:
        """SSE of a run's live NDJSON, from ``?since=<seq>``. Tails the file the warden
        appends to — the file *is* the record, so a dropped connection resumes from
        ``seq`` and loses nothing (spec §4.3). Read-only; the console never executes.

        This is the live-log **telemetry** layer only; it ends the stream on a
        terminal kind (or idle) and never asserts authority state (that stays the
        warden's ``status``). Progressive enhancement: the Running view keeps its
        ``<meta refresh>`` fallback, and the client drops it only when this streams.
        """
        active = next((p for p in discover_projects(roots) if p.slug == slug), None)
        if active is None:
            return Response(status_code=404)
        reader = ProjectReader(active.root)

        async def gen() -> AsyncIterator[str]:
            last = since
            idle = 0.0
            while idle < _STREAM_IDLE_S:
                if await request.is_disconnected():
                    return
                events = await run_in_threadpool(reader.live_events, run_id)
                fresh = [e for e in events if int(e.get("seq") or 0) > last]
                if fresh:
                    idle = 0.0
                    for event in fresh:
                        last = int(event.get("seq") or 0)
                        yield f"data: {json.dumps(_console_lines([event])[0])}\n\n"
                        if str(event.get("kind")) in _TERMINAL_KINDS:
                            yield "event: end\ndata: {}\n\n"
                            return
                else:
                    idle += _STREAM_POLL_S
                await asyncio.sleep(_STREAM_POLL_S)
            yield "event: end\ndata: {}\n\n"

        return StreamingResponse(
            gen(),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
        )

    @router.post("/p/{slug}/run", response_class=HTMLResponse)
    async def run_submit(request: Request, slug: str) -> Response:
        active = next((p for p in discover_projects(roots) if p.slug == slug), None)
        form = await request.form()
        test_id = str(form.get("test_id", "")).strip()
        origin = str(form.get("origin", "web")) or "web"
        independent = str(form.get("independent", "")).lower() in {"true", "1", "yes"}
        if active is None or not test_id:
            return _render_project(request, slug, "running")

        reader = ProjectReader(active.root)
        catalog = reader.catalog()
        test = _find_test(catalog, test_id)
        schema = (test or {}).get("config_schema", [])
        raw_cfg = {k[4:]: str(v) for k, v in form.items() if k.startswith("cfg.")}
        config = _coerce_config(raw_cfg, schema)
        who = "founder" if origin == "web" else ""

        request_obj = RunRequest(
            run_id="run_" + uuid.uuid4().hex[:12],
            test_id=test_id,
            config=config,
            origin=origin,
            who=who,
            independent=independent,
        )
        outcome: dict[str, Any]
        try:
            response = reader.client.run(request_obj)
        except WardenUnreachable:
            outcome = {
                "status": "refused",
                "test_id": test_id,
                "note": "test authority unreachable — request refused (fail-closed); nothing started",
            }
        else:
            outcome = {
                "status": response.get("status"),
                "run_id": response.get("run_id"),
                "position": response.get("position"),
                "test_id": test_id,
                "config_str": _fmt_config(config),
            }
        return _render_project(request, slug, "running", outcome=outcome)

    @router.post("/p/{slug}/cancel", response_class=HTMLResponse)
    async def cancel_run(request: Request, slug: str) -> Response:
        # Forward an operator cancel to the warden (the sole executor) and re-render
        # Running with the honest outcome. Fail-closed if the warden is down; honest
        # "not found" if the run already finished — never a 500, never a fabricated kill.
        active = next((p for p in discover_projects(roots) if p.slug == slug), None)
        form = await request.form()
        run_id = str(form.get("run_id", "")).strip()
        if active is None or not run_id:
            return _render_project(request, slug, "running")

        reader = ProjectReader(active.root)
        outcome: dict[str, Any]
        try:
            response = reader.client.cancel(run_id)
        except WardenUnreachable:
            outcome = {
                "status": "refused",
                "run_id": run_id,
                "note": "test authority unreachable — cancel not delivered (fail-closed)",
            }
        else:
            found = bool(response.get("found"))
            outcome = {
                "status": "cancelled" if found else "not-found",
                "run_id": run_id,
                "note": (
                    f"cancel delivered — the {response.get('state')} run was signalled; "
                    "its lease releases when the child exits, then the queue advances"
                    if found
                    else "run not found — it may have already finished (nothing to cancel)"
                ),
            }
        return _render_project(request, slug, "running", outcome=outcome)

    return router
