"""Phase 3 — summary card + level/status badge rendering (plan-phase3-results-ux.md §4.2, R3/R6/U2).

Two render layers, both PURE string builders (unit-testable, deterministic) plus a thin best-effort
Playwright PNG step:

- `render_badge_svg(...)`   → shields-style SVG: "cc-ci | level N" (or a status word), colour by level.
- `render_card_html(data)`  → an HTML results card (recipe+version, the level badge, a per-stage /
                              per-test ✔/✘ table, and the embedded app screenshot) from a results.json
                              dict. Deterministic inline CSS + a relative screenshot.png ref so it
                              renders offline (file://) with no external assets.
- `render_card_png(...)`    → screenshot the HTML card to PNG via the harness Playwright browser.
                              Best-effort: returns None on any failure (cosmetics never block, R7).

The card REPORTS results.json verbatim — it must never present a run greener than its tests
(cardinal guardrail, plan §6). The level + ✔/✘ shown are read straight from the data this module is
handed; it computes nothing.
"""

from __future__ import annotations

import html
import os

# Level → colour ramp (YunoHost-ish): red at the floor, climbing to green at the top (L5 = full
# clean climb incl. lint — phase lvl5).
LEVEL_COLOR = {
    0: "#e5534b",  # red — install failed
    1: "#e0823d",  # orange
    2: "#e0823d",
    3: "#d9b343",  # amber
    4: "#a0b93f",  # yellow-green — above functional, lint not earned
    5: "#3fb950",  # bright green — full climb (lint passed)
}
STATUS_MARK = {"pass": "✔", "fail": "✘", "skip": "–", "error": "✘", "na": "–", "unver": "⊘"}
STATUS_COLOR = {
    "pass": "#3fb950",
    "fail": "#f85149",
    "error": "#f85149",
    "skip": "#8b949e",
    "na": "#8b949e",
    "unver": "#d29922",  # amber — exercised? no: should have run and wasn't verified
}


# Inline-SVG sunflower (🌻) for the card header. Self-contained so it renders deterministically in
# headless chromium, which has no colour-emoji font (the PR comment in U3 keeps the real 🌻 emoji —
# Gitea markdown renders it). 8 petals around a seed disc.
_PETALS = "".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)
)
FLOWER_SVG = (
    '<svg class="flower" width="30" height="30" viewBox="0 0 28 28" aria-label="cc-ci">'
    f'<g fill="#f0b429">{_PETALS}</g><circle cx="14" cy="14" r="5" fill="#7a4f1d"/></svg>'
)


def level_color(level: int) -> str:
    return LEVEL_COLOR.get(int(level), "#8b949e")


def _text_width(s: str) -> int:
    """Rough px width for a Verdana-11 label (badge sizing); good enough for shields-style boxes."""
    return 7 * len(s) + 10


def render_badge_svg(label: str, message: str, color: str) -> str:
    """A two-box shields-style SVG badge (left grey label, right coloured message)."""
    lw = _text_width(label)
    mw = _text_width(message)
    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(message)}">'
        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(message)}</text></g></svg>'
    )


# Amber for UNVERIFIED rung rows in the table (a rung that should have run and wasn't checked).
GAP_COLOR = "#d29922"


def level_badge_svg(level: int) -> str:
    """Per-recipe/-run LEVEL badge: 'cc-ci | level N' coloured by level — NUMBER + COLOUR ONLY
    (operator-specified, phase lvl5). 'Why isn't it higher' lives in the card's per-rung table,
    never on the badge."""
    return render_badge_svg("cc-ci", f"level {int(level)}", level_color(level))


def _stage_rows(stages: list[dict]) -> str:
    rows = []
    for st in stages:
        smark = STATUS_MARK.get(st.get("status", ""), "?")
        scolor = STATUS_COLOR.get(st.get("status", ""), "#8b949e")
        rows.append(
            f'<tr class="stage"><td colspan="2"><span class="mark" style="color:{scolor}">{smark}</span>'
            f"<b>{html.escape(st.get('name', '?'))}</b></td>"
            f'<td class="st" style="color:{scolor}">{html.escape(st.get("status", ""))}</td></tr>'
        )
        for t in st.get("tests", []):
            tmark = STATUS_MARK.get(t.get("status", ""), "?")
            tcolor = STATUS_COLOR.get(t.get("status", ""), "#8b949e")
            ms = t.get("ms", 0)
            rows.append(
                f'<tr class="test"><td class="tmark" style="color:{tcolor}">{tmark}</td>'
                f'<td class="tname">{html.escape(t.get("name", "?"))}</td>'
                f'<td class="tms">{ms} ms</td></tr>'
            )
    return "\n".join(rows) or '<tr><td colspan="3">no stages</td></tr>'


# Friendly rung labels for the skip/unverified rows (the five essential rungs).
RUNG_LABEL = {
    "install": "install",
    "upgrade": "upgrade",
    "backup_restore": "backup/restore",
    "functional": "functional",
    "lint": "lint",
}
SKIP_GREEN = (
    "#57ab5a"  # muted green — an intentional skip reads like a pass (but labelled, never inflating)
)


def _skip_rows(skips: dict) -> str:
    """Render the non-run rungs as stage-like rows (phase lvl5 semantics). An INTENTIONAL skip
    (declared/structural — the rung does not apply, the climb continues past it) is muted green
    with its reason on the line below; an UNVERIFIED rung (should have run, wasn't checked — the
    level cannot rise above it) is amber 'unverified'."""
    rows = []
    for rung, reason in (skips.get("intentional") or {}).items():
        rows.append(
            f'<tr class="stage"><td colspan="2"><span class="mark" style="color:{SKIP_GREEN}">⊘</span>'
            f"<b>{html.escape(RUNG_LABEL.get(rung, rung))}</b></td>"
            f'<td class="st" style="color:{SKIP_GREEN}">intentional skip</td></tr>'
        )
        rows.append(
            f'<tr class="skipreason"><td></td><td colspan="2">{html.escape(reason)}</td></tr>'
        )
    for rung in skips.get("unintentional") or []:
        rows.append(
            f'<tr class="stage"><td colspan="2"><span class="mark" style="color:{GAP_COLOR}">⊘</span>'
            f"<b>{html.escape(RUNG_LABEL.get(rung, rung))}</b></td>"
            f'<td class="st" style="color:{GAP_COLOR}">unverified</td></tr>'
        )
        rows.append(
            '<tr class="skipreason"><td></td><td colspan="2">rung did not run / could not be '
            "checked — the level cannot rise above an unverified rung</td></tr>"
        )
    return "\n".join(rows)


def render_card_html(data: dict, screenshot_rel: str | None = "screenshot.png") -> str:
    """Build the summary-card HTML from a results.json dict. `screenshot_rel` is the relative path to
    the screenshot PNG (same dir as the card) — omitted from the card if None / absent.

    The card shows exactly what the data says: recipe + version, the level, the per-stage/per-test
    ✔/✘ table (+ skip/unverified rung rows — the SOLE carrier of "why isn't the level higher"),
    the invariant flags, and the app screenshot. No computation here. Tolerates old (schema-1)
    artifacts: the ladder height is read off the rungs the artifact actually has."""
    recipe = html.escape(str(data.get("recipe", "?")))
    version = html.escape(str(data.get("version") or data.get("ref") or ""))
    level = int(data.get("level", 0))
    # Old (pre-lvl5) artifacts have a 4-rung ladder — render their "of N" honestly.
    ladder_top = 5 if "lint" in (data.get("rungs") or {}) else 4
    sk = data.get("skips", {}) or {}
    color = level_color(level)
    flags = data.get("flags", {}) or {}
    flag_bits = []
    for key, lbl in (("clean_teardown", "clean teardown"), ("no_secret_leak", "no secret leak")):
        ok = bool(flags.get(key))
        flag_bits.append(
            f'<span class="flag" style="border-color:{"#3fb950" if ok else "#f85149"}">'
            f"{STATUS_MARK['pass'] if ok else STATUS_MARK['fail']} {lbl}</span>"
        )
    show_shot = bool(screenshot_rel) and bool(data.get("screenshot"))
    shot_html = (
        f'<div class="shot"><img src="{html.escape(screenshot_rel)}" alt="app screenshot"/></div>'
        if show_shot
        else '<div class="shot noshot">no screenshot</div>'
    )
    rows = _stage_rows(data.get("stages", [])) + "\n" + _skip_rows(sk)
    return f"""<!doctype html><html><head><meta charset="utf-8"><style>
*{{box-sizing:border-box}}
body{{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:#0d1117;color:#c9d1d9}}
.card{{width:900px;background:#161b22;border:1px solid #30363d;border-radius:12px;overflow:hidden}}
.hd{{display:flex;align-items:center;gap:1rem;padding:1.1rem 1.3rem;border-bottom:1px solid #30363d}}
.flower{{flex:none}}
.title{{flex:1}}
.title h1{{margin:0;font-size:1.4rem}}
.title .ver{{color:#8b949e;font-size:.9rem}}
.lvl{{text-align:center}}
.lvl .num{{display:inline-block;min-width:64px;padding:.3rem .7rem;border-radius:10px;
  font-size:1.6rem;font-weight:700;color:#0d1117;background:{color}}}
.lvl .lbl{{display:block;color:#8b949e;font-size:.72rem;text-transform:uppercase;margin-top:.2rem}}
.ladder{{padding:.4rem 1.3rem;color:#8b949e;font-size:.82rem;border-bottom:1px solid #21262d}}
.body{{display:flex;gap:1rem;padding:1rem 1.3rem}}
.tbl{{flex:1}}
table{{border-collapse:collapse;width:100%;font-size:.85rem}}
td{{padding:.18rem .4rem;border-bottom:1px solid #21262d}}
tr.stage td{{padding-top:.5rem;border-bottom:1px solid #30363d}}
.mark{{font-weight:700;margin-right:.4rem}}
.st{{text-align:right;text-transform:uppercase;font-size:.74rem}}
.test .tmark{{width:1.4rem;text-align:center}}
.test .tname{{color:#c9d1d9;font-family:ui-monospace,monospace;font-size:.8rem}}
.test .tms{{text-align:right;color:#8b949e;font-size:.74rem;width:5rem}}
tr.skipreason td{{color:#8b949e;font-size:.78rem;font-style:italic;padding-top:0;padding-bottom:.45rem;border-bottom:1px solid #21262d}}
.shot{{width:360px;flex:none;border:1px solid #30363d;border-radius:8px;overflow:hidden;background:#0d1117}}
.shot img{{width:100%;display:block}}
.shot.noshot{{display:flex;align-items:center;justify-content:center;height:225px;color:#8b949e;font-size:.85rem}}
.flags{{display:flex;gap:.6rem;padding:.6rem 1.3rem 1rem}}
.flag{{border:1px solid;border-radius:6px;padding:.15rem .5rem;font-size:.78rem;color:#c9d1d9}}
.ladder b{{color:#c9d1d9}}
</style></head><body><div class="card">
<div class="hd">{FLOWER_SVG}
<div class="title"><h1>{recipe}</h1><span class="ver">{version}</span></div>
<div class="lvl"><span class="num">{level}</span><span class="lbl">level</span></div></div>
<div class="ladder"><b>level {level} of {ladder_top}</b></div>
<div class="body"><div class="tbl"><table>{rows}</table></div>{shot_html}</div>
<div class="flags">{"".join(flag_bits)}</div>
</div></body></html>"""


def render_card_png(html_path: str, out_png: str) -> str | None:
    """Render an HTML card file to PNG via Playwright (screenshot the .card element). Best-effort:
    returns out_png on success, None on any failure (cosmetics never block the pipeline, R7)."""
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:  # pragma: no cover
        return None
    try:
        with sync_playwright() as p:
            browser = p.chromium.launch(args=["--no-sandbox"])
            try:
                page = browser.new_context(
                    viewport={"width": 980, "height": 700}, device_scale_factor=2
                ).new_page()
                page.goto(f"file://{os.path.abspath(html_path)}", wait_until="networkidle")
                el = page.query_selector(".card")
                (el or page).screenshot(path=out_png)
            finally:
                browser.close()
        return out_png if os.path.exists(out_png) and os.path.getsize(out_png) > 0 else None
    except Exception as e:  # noqa: BLE001 — cosmetic; never fail a run (R7)
        print(f"  card: PNG render failed (non-fatal): {e}", flush=True)
        return None
