#!/usr/bin/env python3
"""cc-ci results dashboard (§4.5, D7).

A small stdlib HTTP service served at `ci.commoninternet.net` (root; the comment-bridge keeps the
more-specific `/hook` route). It polls the Drone API for the cc-ci repo's recipe-CI builds
(event=custom, which carry the RECIPE build param), groups the latest run per recipe, and renders a
YunoHost-CI-like overview: a table of recipes with a pass/fail/running status badge, last-tested
ref, when, and a link to the canonical Drone run. Also serves an embeddable SVG badge per recipe at
`/badge/<recipe>.svg`. Read-only (Drone API token, never written to the page). Python stdlib only.

Config (env): DRONE_URL, CI_REPO, DRONE_TOKEN_FILE, DASH_LISTEN (default 0.0.0.0:8080),
POLL_INTERVAL (default 60), CACHE_TTL (default 30).
"""

import html
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

DRONE_URL = os.environ.get("DRONE_URL", "https://drone.ci.commoninternet.net")
CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci")
CACHE_TTL = int(os.environ.get("CACHE_TTL", "30"))
# Per-recipe history display cap (phase dash): a long-lived recipe (plausible/custom-html have 30+
# runs) stays bounded; newest runs are kept (the list is sorted newest-first before the slice).
HISTORY_CAP = int(os.environ.get("HISTORY_CAP", "30"))

# Phase 3 (R3/R6/U2.3): per-run artifacts (results.json, summary card PNG, app screenshot, level
# badge) written by run_recipe_ci.py under this host dir, bind-mounted read-only into the dashboard
# container (see nix/modules/dashboard.nix). Served at the stable URL /runs/<id>/<file>.
CCCI_RUNS_DIR = os.environ.get("CCCI_RUNS_DIR", "/var/lib/cc-ci-runs")
# Strict allow-list of servable filenames → content type. NOTHING outside this set is served, so the
# route cannot be used to read arbitrary files even before the path-traversal guard.
_RUN_FILES = {
    "results.json": "application/json",
    "summary.png": "image/png",
    "screenshot.png": "image/png",
    "badge.svg": "image/svg+xml",
    "summary.html": "text/html; charset=utf-8",
    "lint.txt": "text/plain; charset=utf-8",
}
_RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")


def _read(path):
    with open(path) as fh:
        return fh.read().strip()


DRONE_TOKEN = _read(os.environ["DRONE_TOKEN_FILE"])

_CACHE = {"ts": 0.0, "recipes": []}
# Raw custom builds (newest-first), cached within CACHE_TTL. Feeds the OVERVIEW (latest-per-recipe).
# The per-recipe HISTORY page no longer reads this slice — it sources the full history from the local
# run artifacts instead (see _local_history / phase dash), because this Drone slice is capped at the
# latest 100 builds and drops a recipe's older runs out of view.
_BUILDS = {"ts": 0.0, "builds": []}
# Per-recipe history sourced from the LOCAL run artifacts under CCCI_RUNS_DIR (complete: 300+ runs,
# durable, independent of Drone's 100-build window). Whole-dir scan grouped by recipe, cached CACHE_TTL.
_LOCAL = {"ts": 0.0, "by_recipe": {}}

_COLORS = {
    "success": "#3fb950",
    "failure": "#f85149",
    "error": "#f85149",
    "running": "#d29922",
    "pending": "#d29922",
    "killed": "#8b949e",
}

# Level → colour ramp, kept in sync with runner/harness/card.py LEVEL_COLOR (the dashboard is a
# standalone stdlib service that doesn't import the runner harness, so the small map is duplicated).
_LEVEL_COLOR = {
    0: "#e5534b",
    1: "#e0823d",
    2: "#e0823d",
    3: "#d9b343",
    4: "#a0b93f",
    5: "#3fb950",  # bright green — full 5-rung climb incl. lint (phase lvl5)
}


def level_color(level):
    try:
        return _LEVEL_COLOR.get(int(level), "#8b949e")
    except (TypeError, ValueError):
        return "#8b949e"


def log(*a):
    print(*a, file=sys.stderr, flush=True)


def _results_for(number):
    """Read a run's results.json from the bind-mounted runs dir (R5: the grid surfaces the real
    level/version/screenshot/flags from the artifact, not just Drone's pass/fail). Traversal-guarded
    like serve_run_file; returns {} on any miss so the overview degrades to Drone-only fields."""
    if number in (None, ""):
        return {}
    base = os.path.realpath(CCCI_RUNS_DIR)
    real = os.path.realpath(os.path.join(base, str(number), "results.json"))
    if not real.startswith(base + os.sep):
        return {}
    try:
        with open(real) as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return {}


def _drone(path):
    req = urllib.request.Request(
        f"{DRONE_URL}{path}", headers={"Authorization": f"Bearer {DRONE_TOKEN}"}
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read())


def _custom_recipe_builds():
    """All event=custom recipe-CI builds (newest first), each carrying a real RECIPE param. The
    cc-ci repo's own name isn't a recipe under test (e.g. an Adversary `!testme` on the cc-ci PR) so
    it's filtered out. Cached (CACHE_TTL) and shared by the overview + history. None on fetch error."""
    now = time.time()
    if now - _BUILDS["ts"] <= CACHE_TTL and _BUILDS["builds"]:
        return _BUILDS["builds"]
    try:
        builds = _drone(f"/api/repos/{CI_REPO}/builds?per_page=100")
    except (urllib.error.URLError, OSError, ValueError) as e:
        log("drone fetch failed", e)
        return None
    own = CI_REPO.rsplit("/", 1)[-1]
    out = []
    for b in builds or []:
        if b.get("event") != "custom":
            continue
        recipe = (b.get("params") or {}).get("RECIPE")
        if not recipe or recipe == own:
            continue
        out.append(b)
    out.sort(key=lambda b: b.get("number", 0), reverse=True)
    _BUILDS["builds"] = out
    _BUILDS["ts"] = now
    return out


def _build_row(b):
    """Project a Drone build (+ its results.json artifact, if present) into a display row. The level/
    version/screenshot/flags come from the run's results.json so the grid mirrors the real artifact
    (R5/cardinal: never greener than the run); they're absent until U0+ artifacts exist for a run."""
    ref = (b.get("params") or {}).get("REF") or ""
    res = _results_for(b.get("number"))
    return {
        "recipe": (b.get("params") or {}).get("RECIPE"),
        "status": b.get("status", "unknown"),
        "number": b.get("number"),
        "ref": ref[:8],
        "version": res.get("version") or ref[:12] or "—",
        "level": res.get("level"),
        "has_screenshot": bool(res.get("screenshot")),
        "flags": res.get("flags") or {},
        "finished": b.get("finished") or 0,
        "url": f"{DRONE_URL}/{CI_REPO}/{b.get('number')}",
    }


def latest_per_recipe():
    """Latest recipe-CI build per recipe, augmented from results.json (R5). None on fetch error."""
    builds = _custom_recipe_builds()
    if builds is None:
        return None
    latest = {}
    for b in builds:  # newest-first → first seen per recipe is the latest
        recipe = (b.get("params") or {}).get("RECIPE")
        if recipe not in latest:
            latest[recipe] = b
    return [_build_row(latest[r]) for r in sorted(latest)]


def _numeric_id(n):
    """run dir name as int for sort tiebreak; -1 for named ids (m2r-*, ab-*) so the PRIMARY sort key
    (finished timestamp) decides their position, never int() on a non-numeric id (would crash)."""
    try:
        return int(n)
    except (TypeError, ValueError):
        return -1


def _run_status(res):
    """Overall pass/fail for a finished run, derived from its per-stage results map (results.json has
    no single top-level status field). Any failed/errored stage → failure; all pass/skip → success;
    empty/unknown → unknown. A skip alone is not a failure."""
    vals = list((res.get("results") or {}).values())
    if any(v in ("fail", "error") for v in vals):
        return "failure"
    if vals and all(v in ("pass", "skip") for v in vals):
        return "success"
    return "unknown"


def _local_history_row(run_id, res):
    """Project a local run artifact (results.json) into the same display-row shape _build_row emits,
    so render_history is unchanged. `number` is the run dir name (the /runs/<id>/ path + _results_for
    key); link to the Drone build when the id is numeric, else to the local summary card."""
    ref = res.get("ref") or ""
    url = (
        f"{DRONE_URL}/{CI_REPO}/{run_id}"
        if str(run_id).isdigit()
        else f"/runs/{run_id}/summary.html"
    )
    return {
        "recipe": res.get("recipe"),
        "status": _run_status(res),
        "number": run_id,
        "ref": ref[:8],
        "version": res.get("version") or ref[:12] or "—",
        "level": res.get("level"),
        "has_screenshot": bool(res.get("screenshot")),
        "flags": res.get("flags") or {},
        "finished": res.get("finished") or 0,
        "url": url,
    }


def _local_history():
    """Scan CCCI_RUNS_DIR once (cached CACHE_TTL), group runs by recipe sorted newest-first by the
    `finished` timestamp. Run dirs with no/malformed results.json (in-flight / failed-early) are
    skipped via _results_for ({} on miss) — never raises, never emits a garbage row. {recipe: [row]}."""
    now = time.time()
    if now - _LOCAL["ts"] <= CACHE_TTL and _LOCAL["by_recipe"]:
        return _LOCAL["by_recipe"]
    by_recipe = {}
    try:
        names = os.listdir(CCCI_RUNS_DIR)
    except OSError as e:
        log("local runs scan failed", e)
        return _LOCAL["by_recipe"]
    for name in names:
        res = _results_for(name)  # traversal-guarded read; {} on miss / malformed / non-dir
        recipe = res.get("recipe")
        if not recipe:
            continue
        by_recipe.setdefault(recipe, []).append(_local_history_row(name, res))
    # Sort newest-first by finished timestamp (ids are MIXED numeric + named, so a numeric/lexical id
    # sort would misorder — timestamp is the only correct key); numeric id is a stable tiebreak only.
    for rows in by_recipe.values():
        rows.sort(key=lambda r: (r["finished"], _numeric_id(r["number"])), reverse=True)
    _LOCAL["by_recipe"] = by_recipe
    _LOCAL["ts"] = now
    return by_recipe


def history_for(recipe):
    """All runs for one recipe (newest first, display-capped at HISTORY_CAP), sourced from the LOCAL
    run artifacts under CCCI_RUNS_DIR — complete + durable, independent of Drone's 100-build window
    (phase dash root cause). [] when the recipe has no local runs."""
    return _local_history().get(recipe, [])[:HISTORY_CAP]


def recipes_cached():
    now = time.time()
    if now - _CACHE["ts"] > CACHE_TTL:
        fresh = latest_per_recipe()
        if fresh is not None:
            _CACHE["recipes"] = fresh
            _CACHE["ts"] = now
    return _CACHE["recipes"]


def _ago(ts):
    if not ts:
        return "—"
    d = int(time.time() - ts)
    if d < 60:
        return f"{d}s ago"
    if d < 3600:
        return f"{d // 60}m ago"
    if d < 86400:
        return f"{d // 3600}h ago"
    return f"{d // 86400}d ago"


_PAGE_CSS = """
body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;margin:0;padding:0}
.wrap{max-width:1100px;margin:0 auto;padding:1.5rem 1rem 3rem}
h1{font-size:1.5rem;margin:.2rem 0;display:flex;align-items:center;gap:.5rem}
a{color:#58a6ff;text-decoration:none} a:hover{text-decoration:underline}
.sub{color:#8b949e;font-size:.9rem;margin:.3rem 0 1.2rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem}
.card{background:#161b22;border:1px solid #21262d;border-radius:.6rem;overflow:hidden;display:flex;flex-direction:column}
.shot{position:relative;display:block;height:140px;background:#0d1117 center/cover no-repeat;border-bottom:1px solid #21262d}
.shot .ph{display:flex;height:100%;align-items:center;justify-content:center;color:#484f58;font-size:.8rem}
.lvl{position:absolute;top:.5rem;right:.5rem;color:#fff;font-weight:700;font-size:.8rem;padding:.15rem .5rem;border-radius:.5rem;box-shadow:0 1px 3px #0008}
.body{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.4rem;flex:1}
.name{font-weight:700;font-size:1.05rem;color:#e6edf3}
.row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.82rem}
.pill{color:#fff;padding:.08rem .5rem;border-radius:.5rem;font-size:.75rem;font-weight:600}
code{background:#0d1117;border:1px solid #21262d;border-radius:.3rem;padding:0 .3rem;font-size:.78rem;color:#c9d1d9}
.flags{display:flex;gap:.4rem;font-size:.72rem;color:#8b949e}
.foot{margin-top:auto;display:flex;justify-content:space-between;font-size:.8rem;padding-top:.3rem;border-top:1px solid #21262d}
table{border-collapse:collapse;width:100%;margin-top:1rem}
th,td{text-align:left;padding:.5rem .7rem;border-bottom:1px solid #21262d;font-size:.88rem}
th{color:#8b949e;font-weight:600;font-size:.8rem;text-transform:uppercase}
.flower{flex:0 0 auto}
"""

# Inline sunflower (matches the summary card; no emoji font dependency in the page header).
_FLOWER = (
    '<svg class="flower" width="26" height="26" viewBox="0 0 28 28">'
    '<g fill="#f0b429">'
    + "".join(
        f'<ellipse cx="14" cy="5.5" rx="2.6" ry="5.5" transform="rotate({a} 14 14)"/>'
        for a in range(0, 360, 45)
    )
    + '</g><circle cx="14" cy="14" r="5" fill="#7a4f1d"/></svg>'
)


def _level_pill(level):
    """The big corner LEVEL badge (R5). '—' (grey) when no results.json level yet."""
    if level is None:
        return '<span class="lvl" style="background:#8b949e">level —</span>'
    return f'<span class="lvl" style="background:{level_color(level)}">level {int(level)}</span>'


def _flags_html(flags):
    out = []
    if flags.get("clean_teardown"):
        out.append('<span title="clean teardown">✔ teardown</span>')
    if flags.get("no_secret_leak"):
        out.append('<span title="no secret leak">✔ no-leak</span>')
    return f'<div class="flags">{"".join(out)}</div>' if out else ""


def _card(r):
    color = _COLORS.get(r["status"], "#8b949e")
    num = r["number"]
    run_url = html.escape(r["url"])
    # Screenshot thumbnail (clickable → full summary card). Placeholder when no screenshot captured.
    if r["has_screenshot"]:
        shot = (
            f'<a class="shot" href="/runs/{num}/summary.png" '
            f'style="background-image:url(/runs/{num}/screenshot.png)" '
            f'title="view summary card"><span>{_level_pill(r["level"])}</span></a>'
        )
    else:
        shot = (
            f'<a class="shot" href="{run_url}" title="open run">'
            f'<span class="ph">no screenshot</span>{_level_pill(r["level"])}</a>'
        )
    return (
        f'<div class="card">{shot}<div class="body">'
        f'<div class="name">{html.escape(r["recipe"])}</div>'
        f'<div class="row"><span class="pill" style="background:{color}">{html.escape(r["status"])}</span>'
        f"<code>{html.escape(r['version'])}</code></div>"
        f"{_flags_html(r['flags'])}"
        f'<div class="foot"><a href="{run_url}">run #{num} · {_ago(r["finished"])}</a>'
        f'<a href="/recipe/{html.escape(r["recipe"])}">history →</a></div>'
        f"</div></div>"
    )


def _page(title, inner):
    return (
        f'<!doctype html><html><head><meta charset="utf-8"><title>{html.escape(title)}</title>'
        f'<meta name="viewport" content="width=device-width,initial-scale=1">'
        f'<meta http-equiv="refresh" content="30"><style>{_PAGE_CSS}</style></head>'
        f'<body><div class="wrap">{inner}</div></body></html>'
    )


def render_overview(rows):
    cards = "\n".join(_card(r) for r in rows) or '<p class="sub">no recipe runs yet</p>'
    inner = (
        f"<h1>{_FLOWER} cc-ci — Co-op Cloud recipe CI</h1>"
        '<p class="sub">Latest <code>!testme</code> run per enrolled recipe — level, status, version, '
        "app screenshot. Click a card for its summary card; “history” for past runs. "
        "Auto-refreshes every 30s.</p>"
        f'<div class="grid">{cards}</div>'
    )
    return _page("cc-ci — Co-op Cloud recipe CI", inner)


def render_history(recipe, rows):
    trs = []
    for r in rows:
        color = _COLORS.get(r["status"], "#8b949e")
        lvl = (
            "—"
            if r["level"] is None
            else f'<b style="color:{level_color(r["level"])}">L{int(r["level"])}</b>'
        )
        shot = f'<a href="/runs/{r["number"]}/summary.png">card</a>' if r["has_screenshot"] else "—"
        trs.append(
            f'<tr><td><a href="{html.escape(r["url"])}">#{r["number"]}</a></td>'
            f'<td><span class="pill" style="background:{color}">{html.escape(r["status"])}</span></td>'
            f"<td>{lvl}</td><td><code>{html.escape(r['version'])}</code></td>"
            f"<td>{_ago(r['finished'])}</td><td>{shot}</td></tr>"
        )
    body = "\n".join(trs) or '<tr><td colspan="6">no runs for this recipe yet</td></tr>'
    inner = (
        f"<h1>{_FLOWER} {html.escape(recipe)} — run history</h1>"
        '<p class="sub"><a href="/">← all recipes</a> · every <code>!testme</code> run, newest first.</p>'
        "<table><thead><tr><th>Run</th><th>Status</th><th>Level</th><th>Version</th>"
        "<th>When</th><th>Card</th></tr></thead><tbody>"
        f"{body}</tbody></table>"
    )
    return _page(f"{recipe} — cc-ci history", inner)


def _badge_svg(label, msg, color):
    """Two-box shields-style SVG (grey label | coloured message). Stdlib-only, deterministic sizing."""
    lw = max(44, 7 * len(label) + 12)
    mw = max(40, 7 * len(msg) + 12)
    w = lw + mw
    return (
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" '
        f'aria-label="{html.escape(label)}: {html.escape(msg)}">'
        f'<rect width="{lw}" height="20" fill="#555"/>'
        f'<rect x="{lw}" width="{mw}" height="20" fill="{color}"/>'
        f'<g fill="#fff" font-family="Verdana,Geneva,sans-serif" font-size="11">'
        f'<text x="6" y="14">{html.escape(label)}</text>'
        f'<text x="{lw + 6}" y="14">{html.escape(msg)}</text></g></svg>'
    )


def render_badge(recipe, status):
    """Status fallback badge (used when a recipe has no results.json level yet)."""
    return _badge_svg("cc-ci", status, _COLORS.get(status, "#8b949e"))


def render_level_badge(recipe, level):
    """Per-recipe latest-LEVEL badge (R6): 'cc-ci: <recipe> | level N', coloured by level —
    embeddable in a recipe README (`/badge/<recipe>.svg`) and shown on the dashboard."""
    return _badge_svg(f"cc-ci: {recipe}", f"level {int(level)}", level_color(level))


def serve_run_file(run_id, fname):
    """Resolve a whitelisted per-run artifact to (content_type, bytes), or None if it must not / can
    not be served. Defends against path traversal three ways: the filename must be in the explicit
    allow-list (so no arbitrary name), the run_id must match a conservative charset (no `/`, no `..`),
    and the realpath of the target must still live inside CCCI_RUNS_DIR. Read-only."""
    ctype = _RUN_FILES.get(fname)
    if ctype is None or not _RUN_ID_RE.match(run_id or ""):
        return None
    base = os.path.realpath(CCCI_RUNS_DIR)
    real = os.path.realpath(os.path.join(base, run_id, fname))
    if not (real == base or real.startswith(base + os.sep)) or not os.path.isfile(real):
        return None
    with open(real, "rb") as fh:
        return ctype, fh.read()


class Handler(BaseHTTPRequestHandler):
    def _route(self, path):
        """Resolve a request path to (code, body, content_type). Shared by GET and HEAD so they
        never diverge. `body` is bytes/str for GET; HEAD sends only the status + headers."""
        if path in ("/healthz", "/dashboard/healthz"):
            return 200, "ok", "text/plain"
        if path.startswith("/badge/") and path.endswith(".svg"):
            recipe = path[len("/badge/") : -len(".svg")]
            row = next((r for r in recipes_cached() if r["recipe"] == recipe), None)
            # R6: per-recipe LATEST-LEVEL badge (from results.json). Fall back to a status badge when
            # the recipe has no level yet (never ran / failed before emitting results.json).
            if row and row.get("level") is not None:
                return 200, render_level_badge(recipe, row["level"]), "image/svg+xml"
            return 200, render_badge(recipe, row["status"] if row else "unknown"), "image/svg+xml"
        if path.startswith("/runs/"):
            # /runs/<run_id>/<file> — stable URL for a run's results.json / summary.png / screenshot /
            # badge (R3/R6). Whitelisted + traversal-guarded by serve_run_file.
            parts = path[len("/runs/") :].split("/")
            if len(parts) == 2:
                got = serve_run_file(parts[0], parts[1])
                if got is not None:
                    return 200, got[1], got[0]
            return 404, "not found", "text/plain"
        if path.startswith("/recipe/"):
            recipe = path[len("/recipe/") :]
            if _RUN_ID_RE.match(recipe):
                rows = history_for(recipe) or []
                return 200, render_history(recipe, rows), "text/html; charset=utf-8"
            return 404, "not found", "text/plain"
        if path == "/":
            return 200, render_overview(recipes_cached()), "text/html; charset=utf-8"
        return 404, "not found", "text/plain"

    def _send(self, code, body, ctype="text/html; charset=utf-8", head_only=False):
        data = body.encode() if isinstance(body, str) else body
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        if not head_only:
            self.wfile.write(data)

    def do_GET(self):
        path = self.path.split("?")[0].rstrip("/") or "/"
        code, body, ctype = self._route(path)
        self._send(code, body, ctype)

    def do_HEAD(self):
        # Same routing as GET, headers only (no body) — enables cheap existence checks, e.g. the
        # comment-bridge deciding image-vs-text fallback for the PR comment (U3).
        path = self.path.split("?")[0].rstrip("/") or "/"
        code, body, ctype = self._route(path)
        self._send(code, body, ctype, head_only=True)

    def log_message(self, *a):
        pass


def main():
    host, _, port = os.environ.get("DASH_LISTEN", "0.0.0.0:8080").rpartition(":")
    srv = ThreadingHTTPServer((host or "0.0.0.0", int(port)), Handler)
    log(f"dashboard listening on {host or '0.0.0.0'}:{port}")
    srv.serve_forever()


if __name__ == "__main__":
    main()
