#!/usr/bin/env python3
"""Evidence-safe visual primitives shared by CARBON HTML reports."""

from __future__ import annotations

import html
import math
from typing import Any


REPORT_VISUAL_CSS = r"""
.decision-hero{display:grid;grid-template-columns:minmax(260px,.65fr) minmax(0,1.35fr);gap:1px;margin:0 0 24px;border:1px solid var(--line);background:var(--line)}
.decision-mark,.decision-copy{padding:clamp(24px,4vw,46px);background:var(--panel)}
.decision-mark{display:flex;min-height:230px;flex-direction:column;justify-content:space-between;background:linear-gradient(145deg,rgba(255,255,255,.07),transparent 55%),var(--panel)}
.decision-label{color:var(--muted);font-size:10px;font-weight:850;letter-spacing:.15em;text-transform:uppercase}.decision-value{max-width:100%;font-size:clamp(30px,3.8vw,48px);font-weight:880;line-height:.88;letter-spacing:-.06em;overflow-wrap:anywhere;text-transform:uppercase}.decision-hold .decision-value,.decision-risk .decision-value{color:var(--risk)}
.decision-copy h2{margin:0 0 14px;font-size:clamp(28px,4.4vw,52px);line-height:1;letter-spacing:-.055em}.decision-copy p{max-width:760px;color:var(--muted-strong);font-size:17px}.decision-basis{padding-top:18px;border-top:1px solid var(--line);color:var(--muted)!important;font-size:12px!important}
.evidence-spine{display:grid;grid-template-columns:repeat(5,1fr);margin:0 0 34px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.spine-step{position:relative;min-width:0;padding:18px 18px 20px 0}.spine-step:not(:last-child):after{position:absolute;top:27px;right:12px;color:#666;content:'\2192'}.spine-step span{display:block;color:var(--muted);font:800 9px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.12em;text-transform:uppercase}.spine-step strong{display:block;margin-top:8px;font-size:13px;line-height:1.35}.spine-step.is-risk strong{color:var(--risk)}
.visual-section{margin:32px 0;padding:28px 0;border-top:1px solid var(--line)}.visual-head{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-bottom:20px}.visual-head h2,.visual-head h3{margin:0}.visual-head p{max-width:650px;margin:0;color:var(--muted);font-size:12px;text-align:right}
.visual-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;border:1px solid var(--line);background:var(--line)}.visual-panel{min-width:0;padding:24px;background:var(--panel)}.visual-panel h3{margin:0 0 6px;font-size:20px}.visual-panel>.visual-note{margin:0 0 20px;color:var(--muted);font-size:12px}
.rank-bars{display:grid;gap:13px}.rank-row{display:grid;grid-template-columns:minmax(110px,.7fr) minmax(100px,1.3fr) auto;align-items:center;gap:12px}.rank-row span{min-width:0;overflow:hidden;font-size:12px;text-overflow:ellipsis;white-space:nowrap}.rank-track{height:9px;border:1px solid var(--line);background:#030303}.rank-fill{display:block;height:100%;background:#f0f0ed}.rank-row.is-risk .rank-fill{background:var(--risk)}.rank-row b{font:800 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace}
.stack-track{display:flex;height:24px;overflow:hidden;border:1px solid var(--line);background:#030303}.stack-segment{min-width:2px;border-right:1px solid #000;background:#fff}.stack-segment:nth-child(2n){background:#a9a9a4}.stack-segment:nth-child(3n){background:#656560}.stack-legend{display:flex;flex-wrap:wrap;gap:12px;margin-top:12px}.stack-legend span{color:var(--muted);font-size:10px}.stack-legend i{display:inline-block;width:8px;height:8px;margin-right:6px;background:#fff}.stack-empty{display:grid;min-height:90px;place-items:center;border:1px dashed var(--line-bright);color:var(--muted);font-size:12px}
.coverage-heatmap{display:grid;grid-template-columns:repeat(auto-fit,minmax(122px,1fr));gap:1px;border:1px solid var(--line);background:var(--line)}.heat-cell{min-height:108px;padding:15px;background:var(--panel)}.heat-cell span,.heat-cell strong,.heat-cell small{display:block}.heat-cell span{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}.heat-cell strong{margin:14px 0 3px;font-size:22px}.heat-cell small{color:var(--muted)}.heat-cell.is-risk{box-shadow:inset 0 3px 0 var(--risk)}.heat-cell.is-good{box-shadow:inset 0 3px 0 #fff}.heat-cell.is-unknown{background:repeating-linear-gradient(135deg,var(--panel) 0,var(--panel) 8px,#101010 8px,#101010 16px)}
.matrix-wrap{display:grid;grid-template-columns:42px minmax(0,1fr);grid-template-rows:minmax(290px,1fr) 34px}.matrix-y{display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.1em;text-transform:uppercase;writing-mode:vertical-rl;transform:rotate(180deg)}.risk-matrix{position:relative;min-height:290px;border:1px solid var(--line);background:linear-gradient(to right,transparent 49.8%,var(--line) 50%,transparent 50.2%),linear-gradient(to bottom,transparent 49.8%,var(--line) 50%,transparent 50.2%),var(--panel)}.risk-matrix:before{position:absolute;inset:0;background:linear-gradient(135deg,transparent 48%,rgba(255,159,67,.035));content:'';pointer-events:none}.matrix-point{position:absolute;z-index:1;width:13px;height:13px;border:2px solid #000;background:#fff;transform:translate(-50%,-50%) rotate(45deg)}.matrix-point.is-risk{background:var(--risk)}.matrix-point span{position:absolute;top:12px;left:12px;width:max-content;max-width:150px;padding:3px 5px;background:#000;color:#ddd;font-size:9px;transform:rotate(-45deg)}.matrix-x{grid-column:2;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.1em;text-transform:uppercase}.matrix-corners{position:absolute;inset:10px;color:#6e6e6a;font-size:9px;font-weight:800;text-transform:uppercase}.matrix-corners span{position:absolute}.matrix-corners .tl{top:0;left:0}.matrix-corners .tr{top:0;right:0}.matrix-corners .bl{bottom:0;left:0}.matrix-corners .br{right:0;bottom:0;color:var(--risk)}
.driver-list{display:grid;gap:0;border-top:1px solid var(--line)}.driver{display:grid;grid-template-columns:28px 1fr;gap:10px;padding:13px 0;border-bottom:1px solid var(--line)}.driver i{display:grid;width:20px;height:20px;place-items:center;border:1px solid var(--line);border-radius:50%;color:#fff;font:800 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.driver.is-negative i{border-color:var(--risk-line);color:var(--risk)}.driver strong{font-size:13px}.driver p{margin:3px 0;color:var(--muted);font-size:11px}
.persona-trace{overflow-x:auto}.trace-chart{min-width:520px}.trace-axis{display:grid;grid-template-columns:100px repeat(var(--trace-steps),minmax(70px,1fr));gap:1px;background:var(--line)}.trace-axis>span{padding:9px;background:var(--panel);color:var(--muted);font-size:9px;text-align:center}.trace-row{display:grid;grid-template-columns:100px repeat(var(--trace-steps),minmax(70px,1fr));gap:1px;margin-top:1px;background:var(--line)}.trace-row>span{padding:12px;background:var(--panel);font-size:11px}.trace-point{display:grid;min-height:54px;place-items:center;background:var(--panel)}.trace-point i{display:grid;width:28px;height:28px;place-items:center;border:1px solid #777;border-radius:50%;font:800 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.trace-point.is-low i{border-color:var(--risk);color:var(--risk)}
@media(max-width:760px){.decision-hero,.visual-grid{grid-template-columns:1fr}.evidence-spine{grid-template-columns:1fr}.spine-step{padding-right:0;border-bottom:1px solid var(--line)}.spine-step:last-child{border-bottom:0}.spine-step:not(:last-child):after{top:auto;right:4px;bottom:10px;content:'\2193'}.visual-head{align-items:flex-start;flex-direction:column}.visual-head p{text-align:left}.rank-row{grid-template-columns:90px 1fr auto}.matrix-wrap{grid-template-columns:28px minmax(0,1fr)}}
@media(max-width:480px){.decision-mark,.decision-copy{padding:24px 18px}.decision-mark{min-height:190px}.visual-panel{padding:18px}.decision-label,.spine-step span,.heat-cell span,.matrix-y,.matrix-x,.stack-legend span{font-size:11px}.rank-row{grid-template-columns:minmax(0,1fr) auto;gap:8px}.rank-row span{white-space:normal;overflow:visible;text-overflow:clip}.rank-track{grid-column:1/-1;grid-row:2}.matrix-wrap{grid-template-columns:24px minmax(0,1fr)}.risk-matrix{min-height:270px}.matrix-point span{display:none}.matrix-corners{inset:8px;font-size:8px}.matrix-corners .tr,.matrix-corners .bl{display:none}.coverage-heatmap{grid-template-columns:repeat(2,minmax(0,1fr))}.heat-cell{min-height:100px;padding:13px}.driver{grid-template-columns:26px minmax(0,1fr)}}
"""


def _esc(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


def _number(value: Any, default: float = 0.0) -> float:
    try:
        number = float(value)
    except (TypeError, ValueError):
        return default
    return number if math.isfinite(number) else default


def render_decision_hero(label: str, headline: str, summary: str, basis: str = "", tone: str = "neutral") -> str:
    safe_tone = "risk" if tone in {"risk", "hold", "bad"} else "neutral"
    return f'''<section class="decision-hero decision-{safe_tone}" aria-label="Decision summary">
      <div class="decision-mark"><span class="decision-label">Decision</span><strong class="decision-value">{_esc(label)}</strong></div>
      <div class="decision-copy"><h2>{_esc(headline)}</h2><p>{_esc(summary)}</p>{f'<p class="decision-basis">Basis: {_esc(basis)}</p>' if basis else ''}</div>
    </section>'''


def render_evidence_spine(stages: list[dict[str, Any]]) -> str:
    labels = ("Intent", "Risk", "Evidence", "Decision", "Next evidence")
    rows = []
    for index, label in enumerate(labels):
        stage = stages[index] if index < len(stages) and isinstance(stages[index], dict) else {}
        value = stage.get("value") or "NOT MEASURED"
        tone = " is-risk" if stage.get("tone") == "risk" else ""
        rows.append(f'<div class="spine-step{tone}"><span>{_esc(label)}</span><strong>{_esc(value)}</strong></div>')
    return '<section class="evidence-spine" aria-label="Evidence narrative">' + "".join(rows) + "</section>"


def render_ranked_bars(title: str, items: list[dict[str, Any]], note: str = "") -> str:
    clean = [item for item in items if isinstance(item, dict)]
    maximum = max((_number(item.get("value")) for item in clean), default=0)
    rows = []
    for item in clean:
        value = _number(item.get("value"))
        # A zero-valued risk should not receive a decorative minimum-width bar:
        # it made zero critical findings look visually non-zero in stats reports.
        width = 0 if maximum <= 0 or value <= 0 else max(2, round(value / maximum * 100, 2))
        tone = " is-risk" if item.get("tone") == "risk" else ""
        display = item.get("display") if item.get("display") is not None else f"{value:g}"
        rows.append(f'<div class="rank-row{tone}"><span title="{_esc(item.get("label"))}">{_esc(item.get("label"))}</span><div class="rank-track" role="img" aria-label="{_esc(item.get("label"))}: {_esc(display)}"><i class="rank-fill" style="width:{width}%"></i></div><b>{_esc(display)}</b></div>')
    body = "".join(rows) if rows else '<div class="stack-empty">NOT MEASURED</div>'
    return f'<article class="visual-panel"><h3>{_esc(title)}</h3><p class="visual-note">{_esc(note)}</p><div class="rank-bars">{body}</div></article>'


def render_stacked_activity(title: str, items: list[dict[str, Any]], note: str = "") -> str:
    clean = [item for item in items if isinstance(item, dict) and _number(item.get("value")) > 0]
    total = sum(_number(item.get("value")) for item in clean)
    if not clean or total <= 0:
        body = '<div class="stack-empty">NOT MEASURED</div>'
    else:
        segments = "".join(f'<i class="stack-segment" style="width:{_number(item.get("value")) / total * 100:.3f}%" title="{_esc(item.get("label"))}: {_esc(item.get("display") or item.get("value"))}" aria-hidden="true"></i>' for item in clean)
        legend = "".join(f'<span><i></i>{_esc(item.get("label"))} · {_esc(item.get("display") or item.get("value"))}</span>' for item in clean)
        accessible = "; ".join(f'{item.get("label")}: {item.get("display") or item.get("value")}' for item in clean)
        body = f'<div class="stack-track" role="img" aria-label="{_esc(accessible)}">{segments}</div><div class="stack-legend">{legend}</div>'
    return f'<article class="visual-panel"><h3>{_esc(title)}</h3><p class="visual-note">{_esc(note)}</p>{body}</article>'


def render_coverage_heatmap(items: list[dict[str, Any]], title: str = "Coverage map", note: str = "") -> str:
    cells = []
    for item in items:
        if not isinstance(item, dict):
            continue
        state = str(item.get("state") or "unknown").lower()
        cls = "is-risk" if state in {"risk", "failed", "blocked", "missing"} else "is-good" if state in {"good", "passed", "covered", "supported"} else "is-unknown"
        cells.append(f'<article class="heat-cell {cls}"><span>{_esc(item.get("label"))}</span><strong>{_esc(item.get("value") or "—")}</strong><small>{_esc(item.get("detail") or state)}</small></article>')
    body = "".join(cells) if cells else '<article class="heat-cell is-unknown"><span>Coverage</span><strong>—</strong><small>NOT MEASURED</small></article>'
    return f'<section class="visual-section"><div class="visual-head"><h2>{_esc(title)}</h2><p>{_esc(note)}</p></div><div class="coverage-heatmap">{body}</div></section>'


def render_risk_matrix(points: list[dict[str, Any]], title: str = "Risk × evidence", note: str = "") -> str:
    markers = []
    for point in points:
        if not isinstance(point, dict):
            continue
        risk = max(0, min(100, _number(point.get("risk"), 50)))
        evidence = max(0, min(100, _number(point.get("evidence"), 0)))
        tone = " is-risk" if risk >= 60 else ""
        markers.append(f'<i class="matrix-point{tone}" role="img" aria-label="{_esc(point.get("label") or "signal")}: risk {risk:g} of 100, evidence {evidence:g} of 100" style="left:{evidence:.2f}%;top:{100-risk:.2f}%"><span aria-hidden="true">{_esc(point.get("label") or "signal")}</span></i>')
    if not markers:
        markers.append('<span class="stack-empty" style="position:absolute;inset:70px">NOT MEASURED</span>')
    return f'''<section class="visual-section"><div class="visual-head"><h2>{_esc(title)}</h2><p>{_esc(note)}</p></div><div class="matrix-wrap"><div class="matrix-y">Consequence / risk →</div><div class="risk-matrix"><div class="matrix-corners"><span class="tl">High risk · weak evidence</span><span class="tr">High risk · strong evidence</span><span class="bl">Low risk · weak evidence</span><span class="br">Decision grade</span></div>{''.join(markers)}</div><div class="matrix-x">Evidence strength →</div></div></section>'''


def render_confidence_drivers(positive: list[dict[str, Any]], negative: list[dict[str, Any]], title: str = "What moves confidence") -> str:
    def rows(values: list[dict[str, Any]], negative_tone: bool) -> str:
        result = []
        for item in values:
            if not isinstance(item, dict):
                continue
            result.append(f'<div class="driver{" is-negative" if negative_tone else ""}"><i>{"−" if negative_tone else "+"}</i><div><strong>{_esc(item.get("label"))}</strong><p>{_esc(item.get("detail"))}</p></div></div>')
        return "".join(result) or '<div class="stack-empty">No supported drivers recorded</div>'
    return f'''<section class="visual-section"><div class="visual-head"><h2>{_esc(title)}</h2><p>Direction is evidence-qualified; this is not a probability model.</p></div><div class="visual-grid"><article class="visual-panel"><h3>Strengthens</h3><p class="visual-note">Observed evidence that increases decision confidence.</p><div class="driver-list">{rows(positive, False)}</div></article><article class="visual-panel"><h3>Weakens or limits</h3><p class="visual-note">Known gaps, adverse evidence, or stale assumptions.</p><div class="driver-list">{rows(negative, True)}</div></article></div></section>'''


def render_stats_visuals(data: dict[str, Any]) -> str:
    if not isinstance(data, dict):
        return ""
    activity = data.get("activity") if isinstance(data.get("activity"), list) else []
    commands = data.get("commands") if isinstance(data.get("commands"), list) else []
    issues = data.get("issues") if isinstance(data.get("issues"), list) else []
    economics = data.get("economics") if isinstance(data.get("economics"), list) else []
    # Do not fill a report with decorative "NOT MEASURED" cards. A visual
    # exists only when its source supplied an observed counter; prose elsewhere
    # explains how to unlock additional measurements.
    panels = []
    if commands:
        panels.append(render_ranked_bars("Command frequency", commands, "Observed project command history"))
    if issues:
        panels.append(render_ranked_bars("Issue lifecycle", issues, "Found and explicitly verified outcomes"))
    if activity:
        panels.insert(0, render_stacked_activity("Tokens by activity", activity, "Exact host-supplied token counters"))
    if economics:
        panels.append(render_ranked_bars("Time and value scenario", economics, "User-supplied baseline assumptions only"))
    if not panels:
        return ""
    return f'''<section class="visual-section" aria-label="Activity visualizations"><div class="visual-head"><h2>Activity and outcomes</h2><p>Only observed counters are plotted.</p></div><div class="visual-grid">{"".join(panels)}</div></section>'''


def render_persona_trace(steps: list[dict[str, Any]], title: str = "Experience trace") -> str:
    clean = [step for step in steps if isinstance(step, dict)]
    if not clean:
        return '<div class="stack-empty">Step-level experience is NOT MEASURED</div>'
    labels = "".join(f'<span role="columnheader">{_esc(step.get("label") or f"Step {index + 1}")}</span>' for index, step in enumerate(clean))
    rows = []
    for key in ("joy", "happiness", "surprise"):
        points = []
        for step in clean:
            raw = step.get(key)
            value = int(max(1, min(5, _number(raw, 0)))) if _number(raw, 0) else None
            points.append(f'<div class="trace-point{" is-low" if value is not None and value <= 2 else ""}" role="cell"><i>{value if value is not None else "—"}</i></div>')
        rows.append(f'<div class="trace-row" role="row"><span role="rowheader">{_esc(key.title())}</span>{"".join(points)}</div>')
    return f'<div class="persona-trace"><h3>{_esc(title)}</h3><div class="trace-chart" role="table" aria-label="{_esc(title)}" style="--trace-steps:{len(clean)}"><div class="trace-axis" role="row"><span role="columnheader">Measure</span>{labels}</div>{"".join(rows)}</div></div>'
