#!/usr/bin/env python3
"""HTML renderer for the AI Assurance Audit report (two-axis, six-section layout).

Produces a self-contained HTML report in the brief's editorial design system.
The report runs two independent axes in parallel throughout: SECURITY (green,
"can it prove a real student did the work") and DESIGN (maroon, "does the work
measure real thinking and address AI use"). The two never collapse into one
score, so a reader cannot mistake "secured" for "done".

The renderer is deterministic over an authored ``narrative["report"]`` object
(same deterministic/AI-judgment split as assurance_brief_html): Claude authors
the plain-language rollup fields during the audit; this module escapes every
value and skips a section when its data is absent. Section 03's SECURITY half
is the exception: it is sourced directly from the scorecard's
``security_ledger`` (per-assessment secured/unsecured + AI-forward/resistant
labels; see assurance_audit.score_course) and renders even without a
narrative. The verdict-matrix dot and the headline stat band lead with those
same per-assessment labels, not the deprecated assurance_coverage_pct/tier
(kept in the scorecard for back-compat only; see docs/plan-assurance-binary-
labels.md). Sections 01 (How to Read) and 06 (Method) are static boilerplate.

narrative["report"] shape (all fields optional):
{
  "period_lines": ["ASUO", "FY24-26"],           # right-aligned masthead dateline
  "verdict": {                                   # section 02
    "proof":  {"answer","detail"},               # SECURITY read
    "design": {"answer","detail"},               # DESIGN read
    "x"?, "y"?,                                   # 0..1 dot overrides (else from security_ledger counts)
    "tension": str,                              # "The Tension:" focus line
    "stats": [{"value","label","kind"?}]         # stat band; kind: sec|warn|plain
  },
  "snapshot": {                                  # section 03; SECURITY half comes from
                                                  # scorecard.security_ledger, not this object
    "measures": {"segments":[{"level","pct"}], "note": str},  # DESIGN: Bloom bar, one segment per level
    "ai_use": str                                # DESIGN: AI-use-in-instructions paragraph
  },
  "gaps": {                                      # section 04
    "lede": str,
    "security": {"items":[{"lead","text"}], "note": str},   # SECURITY gaps
    "design":   {"items":[{"lead","text"}], "note": str}    # DESIGN gaps
  },
  "recommendations": [                           # section 05, by assessment type
    {"type","weight_pct","tags":[{"label","kind"}],         # kind: sec|vul
     "forward":[str], "resistant":[str], "last_resort": str}],
  "method": {"scanned": str, "excluded": str, "note": str,
             "calibration": str,                # section 06: house-calibration disclosure
             "sources": [{"label": str, "cite": str}]}  # section 06: references block
}

Public API
----------
render(scorecard, narrative=None, *, generated_date="") -> str

CLI
---
    assurance_report_html.py --scorecard PATH [--narrative PATH]
                              --output PATH.html [--generated-date YYYY-MM-DD]
                              [--pdf PATH.pdf]

Security: every dynamic value passes through html.escape() before interpolation.
Treat all scorecard and narrative fields as untrusted.
"""
from __future__ import annotations

import argparse
import html
import json
import sys
from pathlib import Path

_scripts_dir = Path(__file__).resolve().parent
if str(_scripts_dir) not in sys.path:
    sys.path.insert(0, str(_scripts_dir))

from assurance_brief_html import (  # noqa: E402
    _verdict_dot_xy, _course_code, _verdict_svg, _safe_url, _url_label,
)


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

def _e(value: object) -> str:
    """HTML-escape any value (outermost on every interpolation)."""
    return html.escape("" if value is None else str(value))


def _pct(value: object) -> str:
    """Render a weight as a compact percent (drops a trailing .0)."""
    try:
        n = float(value)
    except (TypeError, ValueError):
        return _e(value)
    return f"{int(n)}%" if n == int(n) else f"{n:g}%"


_APA_MINOR_WORDS = frozenset(
    "a an and as at but by for in nor of off on or per so the to up via vs yet".split()
)


def _apa_title_case(text: str) -> str:
    """Title-case a heading or label per APA 7 rules (keeps acronyms)."""
    raw = ("" if text is None else str(text)).strip()
    if not raw:
        return ""
    words = raw.split()
    last = len(words) - 1

    def cap_part(part: str, force: bool) -> str:
        if not part:
            return part
        if len(part) > 1 and part.isupper():
            return part
        low = part.lower()
        if not force and len(part) <= 3 and low in _APA_MINOR_WORDS:
            return low
        return part[0].upper() + part[1:]

    out = []
    for i, word in enumerate(words):
        force = i == 0 or i == last
        out.append("-".join(cap_part(p, force) for p in word.split("-")))
    return " ".join(out)


# ---------------------------------------------------------------------------
# CSS (brief editorial system, extended for the report's tables/panels/recs)
# ---------------------------------------------------------------------------

_CSS = """
  :root{
    --ink:#1c1a17; --ink-strong:#12100e; --muted:#736c60; --secondary:#423e37;
    --rule:#e4ded3; --rule-soft:#efeae1; --paper:#ffffff; --box:#d3ccbe;
    --accent:#8c1d40; --gold:#ffc627; --good:#2f7d32; --goodwash:#f0f5f0;
    --serif:'Iowan Old Style','Palatino Linotype','Book Antiqua',Palatino,Georgia,serif;
    --sans:'Helvetica Neue',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
  }
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
  body{font-family:var(--sans);background:var(--paper);color:var(--ink);line-height:1.45;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;}
  .page{max-width:720px;margin:0 auto;padding:34px 34px 40px;}
  .masthead{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;padding-bottom:11px;border-bottom:1px solid var(--ink-strong);}
  h1{font-family:var(--serif);font-size:27px;font-weight:600;letter-spacing:-0.4px;color:var(--ink-strong);line-height:1.02;}
  .masthead .sub1{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);margin-top:7px;}
  .masthead .canvaslink{font-size:9.5px;letter-spacing:.3px;margin-top:5px;}
  .masthead .canvaslink a{color:var(--accent);text-decoration:none;border-bottom:1px solid var(--box);}
  .masthead .term{flex:none;text-align:right;font-size:9px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);padding-bottom:2px;}
  .goldline{height:3px;background:var(--gold);margin:0 0 24px;}
  .eyebrow{font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--accent);margin-bottom:11px;}
  .subhead{font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;margin:20px 0 10px;padding-bottom:6px;border-bottom:1.5px solid currentColor;}
  .subhead .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;vertical-align:middle;background:currentColor;}
  .subhead.sec{color:var(--good);} .subhead.des{color:var(--accent);}
  .subhead .q{color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;font-size:10px;}
  section{margin-bottom:26px;}
  p{font-size:12.5px;color:var(--secondary);line-height:1.5;margin-bottom:8px;}
  .lede{font-size:12.5px;color:var(--secondary);} b{color:var(--ink-strong);font-weight:700;}
  .runin{font-size:12px;color:var(--secondary);margin-bottom:8px;line-height:1.5;} .runin b{color:var(--ink-strong);}
  .note{font-size:11px;color:var(--muted);font-style:italic;margin-top:6px;line-height:1.4;}
  .frame{border:1px solid var(--box);padding:11px 15px;margin:10px 0 6px;font-size:12.5px;color:var(--ink-strong);line-height:1.5;}
  .g{color:var(--good);font-weight:700;} .m{color:var(--accent);font-weight:700;}
  .stats{display:flex;border:1px solid var(--box);}
  .stat{flex:1;padding:9px 6px 8px;text-align:center;border-left:1px solid var(--rule-soft);}
  .stat:first-child{border-left:none;}
  .fig{display:block;font-family:var(--serif);font-size:21px;font-weight:600;color:var(--ink-strong);letter-spacing:-0.3px;line-height:1.05;}
  .fig.sec{color:var(--good);} .fig.warn{color:var(--accent);} .fig.sm{font-size:15px;}
  .lab{display:block;font-size:8.5px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--muted);margin-top:4px;line-height:1.25;}
  .tworeads{border:1px solid var(--box);padding:15px 17px;display:grid;grid-template-columns:214px 1fr;gap:20px;align-items:center;margin-bottom:14px;}
  .read{margin-bottom:12px;} .read:last-child{margin-bottom:0;}
  .rlab{font-size:9.5px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:3px;}
  .rlab .dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:middle;}
  .rlab.sec{color:var(--good);} .rlab.sec .dot{background:var(--good);}
  .rlab.des{color:var(--accent);} .rlab.des .dot{background:var(--accent);}
  .rval{font-size:11.5px;color:var(--secondary);line-height:1.42;} .rval b{color:var(--ink-strong);}
  .focus{border-left:3px solid var(--accent);padding:2px 0 2px 13px;font-size:12.5px;color:var(--ink-strong);margin:12px 0;line-height:1.5;} .focus b{color:var(--accent);}
  table{width:100%;border-collapse:collapse;font-size:12px;margin:2px 0 6px;}
  th{font-size:8.5px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--muted);text-align:left;padding:6px 8px;border-bottom:1px solid var(--box);}
  td{padding:7px 8px;border-bottom:1px solid var(--rule);color:var(--secondary);vertical-align:top;line-height:1.38;} td b{color:var(--ink-strong);}
  .bar{height:6px;background:var(--rule-soft);display:flex;min-width:60px;border:1px solid var(--rule);} .bar i{background:var(--good);display:block;}
  .bar.bloom i + i{border-left:1px solid var(--paper);}
  .legend{display:flex;flex-wrap:wrap;gap:4px 14px;margin:6px 0 2px;}
  .lk{display:flex;align-items:center;gap:5px;font-size:9.5px;color:var(--muted);letter-spacing:.2px;}
  .lk i{width:9px;height:9px;display:block;border:1px solid #b9b1a2;flex:none;}
  .lk b{color:var(--secondary);font-weight:700;}
  .tag{font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;} .tag.sec{color:var(--good);} .tag.vul{color:var(--accent);}
  .twocol{display:grid;grid-template-columns:1fr 1fr;gap:15px;}
  .cpanel{border:1px solid var(--box);padding:12px 14px;}
  .chead{font-size:9.5px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--accent);margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--rule);}
  .chead.sec{color:var(--good);}
  .chead .s{display:block;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;font-size:9.5px;font-style:italic;margin-top:2px;}
  .ops{counter-reset:op;} .op{position:relative;padding-left:22px;margin-bottom:8px;font-size:11.5px;color:var(--secondary);line-height:1.4;}
  .op::before{counter-increment:op;content:counter(op);position:absolute;left:0;top:-1px;font-family:var(--serif);font-size:13px;font-weight:700;color:var(--accent);}
  .op b{color:var(--ink-strong);}
  .rec{border:1px solid var(--box);margin-bottom:12px;}
  .rec .head{padding:8px 14px;border-bottom:1px solid var(--rule);font-size:12px;color:var(--secondary);} .rec .head b{font-size:12.5px;color:var(--ink-strong);}
  .rec .body{padding:11px 14px;}
  .move{margin-bottom:9px;} .move:last-child{margin-bottom:0;}
  .move .t{font-size:8.5px;font-weight:700;letter-spacing:1px;text-transform:uppercase;margin-bottom:3px;}
  .move ul{margin:0;padding-left:15px;font-size:11.5px;color:var(--secondary);line-height:1.4;}
  .move.fwd{border:1px solid var(--good);background:var(--goodwash);padding:8px 11px;} .move.fwd .t{color:var(--good);} .move.fwd .t span{color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;}
  .move.res .t{color:var(--secondary);}
  .move.last .t{color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0;font-style:italic;font-size:11px;}

  @page{size:Letter;margin:14mm;}
  @media print{
    html,body{background:var(--paper);}
    body,.goldline,.stat,.op::before,.move.fwd,.bar,.bar i,.lk i{-webkit-print-color-adjust:exact;print-color-adjust:exact;}
    .page{padding:0;max-width:100%;margin:0;}
    section,.stats,.tworeads,.twocol,.masthead,.rec{break-inside:avoid;}
    table{break-inside:auto;} tr{break-inside:avoid;}
    .eyebrow,h1,.subhead{break-after:avoid;}
    p,li,.op{orphans:3;widows:3;}
  }
"""


# ---------------------------------------------------------------------------
# Section renderers
# ---------------------------------------------------------------------------

def _render_masthead(course_name: str, report: dict, level_label: str = "",
                     course_url: str = "") -> str:
    """Render the report masthead.

    level_label (assurance accuracy v2, workstream 4c): when the scorecard
    carries scorecard["course"]["level_label"] (authored via
    course_level.detect_level -- see SKILL.md's Course-Level Calibration
    step), show it in the sub line, e.g. "Assessment Assurance Report |
    100-level". Absent when the course code did not parse to a level; the
    static "Assessment Assurance Report" line still renders on its own.
    """
    lines = report.get("period_lines") or []
    term = ""
    if lines:
        term = '<div class="term">' + "<br>".join(_e(x) for x in lines) + "</div>"
    sub1 = "Assessment Assurance Report"
    if level_label:
        sub1 = f"{sub1} | {_e(level_label)}"
    link = ""
    safe = _safe_url(course_url)
    if safe:
        link = f'<div class="canvaslink"><a href="{_e(safe)}">{_e(_url_label(safe))}</a></div>'
    return f"""
  <header class="masthead">
    <div><h1>{_e(course_name)}</h1><div class="sub1">{sub1}</div>{link}</div>
    {term}
  </header>
  <div class="goldline"></div>"""


# Section 01 -- static boilerplate; identical every course, so not data-driven.
# Secured wording (docs/plan-assurance-ai-led.md): a real basis to trust the
# student did the work, via either of two kinds of control. Identity/condition
# control (proctoring, LockDown Browser) is a deterministic API fact. Authorship
# evidence (drafts, decision logs, revision history, reflection, a graded
# AI-use appendix) is NOT an API flag -- it is AI-judged from the assessment's
# actual prompt/rubric/syllabus, with the evidence cited. "Peer review" alone
# still does not count: it does not verify authorship. Proctoring is named a
# last resort here for the same reason it is last resort in the
# recommendations (see SKILL.md): weak against current AI, unscalable, and a
# barrier to access -- naming it here describes what the tool currently counts
# as secured, not a recommendation to add more of it.
_HOW_TO_READ = """
  <section id="how-to-read">
    <div class="eyebrow">01 &nbsp; How to Read This Report</div>
    <div class="frame">This report answers <b>two independent questions</b>. <span class="g">Security:</span> can it prove a real student did the work? <span class="m">Design:</span> does that work measure real thinking and address AI use? They do not move together. A course can be fully secured and still need redesign, and every section below reports on both.</div>
    <p class="runin"><b>Secured vs. Unsecured</b> <span class="g">(Security).</span> Secured means there is a real basis to trust the student did the work, found one of two ways: an identity or condition control (a proctored exam or one locked with LockDown Browser -- proctoring is a last resort, named here only to describe what the course already has in place, not a first recommendation), or authorship evidence (milestoned drafts, a decision log, revision history, a graded reflection, or a graded AI-use appendix). Everything else is unsecured: the grade cannot rule out AI or another person.</p>
    <p class="runin"><b>AI-Resistant vs. AI-Forward</b> <span class="m">(Design).</span> AI-resistant makes a task harder to hand to AI. AI-forward teaches and grades good judgment about using AI. Both are design moves, separate from whether the work is secured.</p>
  </section>"""


def _stat_cell(value, label, kind="plain") -> str:
    cls = "fig"
    if kind == "sec":
        cls += " sec"
    elif kind == "warn":
        cls += " warn"
    # Text values (not a bare number/percent) render smaller so they fit the cell.
    if any(c.isalpha() for c in str(value)):
        cls += " sm"
    return (f'<div class="stat"><span class="{cls}">{_e(value)}</span>'
            f'<span class="lab">{_e(label)}</span></div>')


def _graded_ledger_items(sc: dict) -> list[dict]:
    """Return the graded entries from scorecard['security_ledger'], safely."""
    ledger = sc.get("security_ledger") or []
    if not isinstance(ledger, list):
        return []
    return [item for item in ledger if isinstance(item, dict) and item.get("graded")]


def _default_stats(sc: dict, report: dict) -> list[dict]:
    """Fallback stat band from the scorecard when the narrative omits one.

    Leads with a per-assessment "N/M Secured" count (plan: assurance-binary-
    labels %6a) sourced from security_ledger, not the deprecated coverage
    percentage -- no percentage renders in this featured headline.
    """
    stats: list[dict] = []
    graded = _graded_ledger_items(sc)
    if graded:
        secured_n = sum(1 for item in graded if item.get("security") == "secured")
        stats.append({"value": f"{secured_n}/{len(graded)}", "label": "Secured", "kind": "sec"})
    rating = sc.get("ai_forward_rating")
    if rating:
        stats.append({"value": _apa_title_case(rating), "label": "Design", "kind": "warn"})
    lane = (sc.get("dimensions") or {}).get("D") or {}
    lane_summary = lane.get("lane_summary") or {}
    item_count = sum(int(lane_summary.get(k) or 0) for k in
                     ("lane1_count", "lane2_count", "mismatch_count", "unclassified_count"))
    if item_count:
        stats.append({"value": item_count, "label": "Graded Items", "kind": "plain"})
    types = (report.get("snapshot") or {}).get("security_types") or []
    if types:
        stats.append({"value": len(types), "label": "Assessment Types", "kind": "plain"})
    return stats


def _render_bottom_line(sc: dict, report: dict) -> str:
    """Section 02: The Bottom Line -- verdict matrix + two parallel reads + stats."""
    verdict = report.get("verdict") or {}
    course_name = ((sc.get("course") or {}).get("name")) or ""
    code = _course_code(course_name)
    dot_x, dot_y = _verdict_dot_xy(sc, {"verdict": verdict})
    svg = _verdict_svg(code, dot_x, dot_y)

    proof = verdict.get("proof") or {}
    design = verdict.get("design") or {}
    reads = ""
    if proof.get("answer") or proof.get("detail"):
        reads += (f'<div class="read"><div class="rlab sec"><span class="dot"></span>'
                  f'Security: can it prove a real student did the work?</div>'
                  f'<div class="rval"><b>{_e(proof.get("answer"))}</b> {_e(proof.get("detail"))}</div></div>')
    if design.get("answer") or design.get("detail"):
        reads += (f'<div class="read"><div class="rlab des"><span class="dot"></span>'
                  f'Design: does it measure real thinking?</div>'
                  f'<div class="rval"><b>{_e(design.get("answer"))}</b> {_e(design.get("detail"))}</div></div>')

    tension = verdict.get("tension")
    tension_html = (f'<div class="focus"><b>The Tension:</b> {_e(tension)}</div>'
                    if tension else "")

    stats = verdict.get("stats") or _default_stats(sc, report)
    stats_html = ""
    if stats:
        cells = "".join(_stat_cell(s.get("value"), s.get("label"), s.get("kind") or "plain")
                        for s in stats)
        stats_html = f'<div class="stats">{cells}</div>'

    return f"""
  <section id="the-bottom-line">
    <div class="eyebrow">02 &nbsp; The Bottom Line</div>
    <div class="tworeads">
      <div>{svg}</div>
      <div>{reads}</div>
    </div>
    {tension_html}
    {stats_html}
  </section>"""


# The Design-axis Bloom bar uses the canonical Bloom's-taxonomy scale (the
# Vanderbilt CFT pyramid): blue at Remember rising through green and yellow to
# red at Create. Readers already know this scale, which beats any palette we
# would invent.
#
# Two consequences the palette has to live with. First, the hues are not ordered
# by luminance, so the ordinal reading comes from the legend and the fixed
# left-to-right order, never from color alone; that redundancy is also what keeps
# the bar usable for a colorblind reader, since a rainbow fails on hue alone.
# Second, the scale necessarily borrows green, which elsewhere in this report
# means Security. The Bloom greens are held well away from the Security green
# (#2f7d32) and the Design maroon (#8c1d40) in hue and lightness, and the legend
# names every level, so a band cannot be misread as an axis signal.
_BLOOM_ORDER = ("Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create")
_BLOOM_COLORS = {
    "Remember": "#4f81bd",   # blue
    "Understand": "#63b0e0",  # light blue
    "Apply": "#34bf5a",       # green (brighter than the Security green, on purpose)
    "Analyze": "#8cc63f",     # yellow-green
    "Evaluate": "#fdc82f",    # yellow
    "Create": "#ee5a3a",      # red-orange
}
_BLOOM_FALLBACK = "#b9b1a2"
_BLOOM_RANK = {level: i for i, level in enumerate(_BLOOM_ORDER)}


def _bloom_segments(segments: list) -> list[tuple[str, float]]:
    """Return [(level, pct)] in canonical Bloom order, dropping zero/invalid weight.

    Unknown levels sort last so a mislabeled level still renders rather than
    silently vanishing.
    """
    out: list[tuple[str, float]] = []
    for seg in segments:
        if not isinstance(seg, dict):
            continue
        level = str(seg.get("level") or "").strip()
        try:
            pct = max(0.0, min(100.0, float(seg.get("pct"))))
        except (TypeError, ValueError):
            continue
        if not level or pct <= 0.0:
            continue
        out.append((level, pct))
    out.sort(key=lambda s: _BLOOM_RANK.get(s[0], len(_BLOOM_ORDER)))
    return out


_SECURITY_BASIS_LABEL = {"identity": "Identity", "authorship": "Authorship", "none": "None"}
_DESIGN_AXIS_LABEL = {"ai_forward": "AI-Forward", "ai_resistant": "AI-Resistant", "n/a": "N/A"}


def _render_security_ledger_table(ledger_items: list[dict]) -> str:
    """Per-assessment SECURITY table: Assessment | Secured? | Basis | AI-Forward/Resistant.

    Sourced directly from scorecard['security_ledger'] (deterministic), not the
    narrative. Plain labels only, no share percentage (plan: assurance-binary-
    labels %6a). A needs_ai_review item renders unsecured with a small "needs
    review" marker, never a false secure.
    """
    if not ledger_items:
        return ""
    rows = ""
    for item in ledger_items:
        secured = item.get("security") == "secured"
        if secured:
            tag = '<span class="tag sec">Secured</span>'
        elif item.get("needs_ai_review"):
            tag = '<span class="tag vul">Unsecured</span> <span class="note" style="display:inline">(needs review)</span>'
        else:
            tag = '<span class="tag vul">Unsecured</span>'
        basis = _SECURITY_BASIS_LABEL.get(item.get("security_basis"), _e(item.get("security_basis")))
        design = _DESIGN_AXIS_LABEL.get(item.get("design"), _e(item.get("design")))
        rows += (f"<tr><td><b>{_e(item.get('title'))}</b></td>"
                 f"<td>{tag}</td>"
                 f"<td>{basis}</td>"
                 f"<td>{design}</td></tr>\n")
    return f"""
    <div class="subhead sec"><span class="dot"></span>Security <span class="q">: can it prove a real student did the work?</span></div>
    <table>
      <tr><th>Assessment</th><th>Secured?</th><th>Basis</th><th>AI-Forward / Resistant</th></tr>
      {rows}
    </table>"""


def _render_snapshot(sc: dict, report: dict) -> str:
    """Section 03: Course Snapshot -- SECURITY ledger + DESIGN measures/AI-use.

    The SECURITY half is fully deterministic (scorecard['security_ledger']) and
    renders even without a narrative. The DESIGN half (Bloom measures, AI-use
    paragraph) stays narrative-authored, unchanged.
    """
    snap = report.get("snapshot") or {}
    security_block = _render_security_ledger_table(_graded_ledger_items(sc))
    measures = snap.get("measures") or {}
    ai_use = snap.get("ai_use")
    if not (security_block or measures or ai_use):
        return ""

    design_block = ""
    if measures or ai_use:
        design_parts = ['<div class="subhead des"><span class="dot"></span>Design '
                        '<span class="q">: does the work measure real thinking and address AI use?</span></div>']
        segments = _bloom_segments(measures.get("segments") or [])
        if segments:
            bars = "".join(
                f'<i style="width:{w:g}%;background:{_BLOOM_COLORS.get(level, _BLOOM_FALLBACK)}"></i>'
                for level, w in segments
            )
            legend = "".join(
                f'<span class="lk"><i style="background:{_BLOOM_COLORS.get(level, _BLOOM_FALLBACK)}"></i>'
                f'<b>{_e(level)}</b> {_pct(w)}</span>'
                for level, w in segments
            )
            design_parts.append('<p class="runin" style="margin-bottom:5px"><b>What the Work Measures:</b> read from each item&rsquo;s prompt. Levels run from Remember on the left to Create on the right.</p>')
            design_parts.append(f'<div class="bar bloom" style="height:18px;margin:2px 0 0">{bars}</div>')
            design_parts.append(f'<div class="legend">{legend}</div>')
            m_note = measures.get("note")
            if m_note:
                design_parts.append(f'<p class="note" style="margin-top:2px">{_e(m_note)}</p>')
        if ai_use:
            design_parts.append(f'<p class="runin" style="margin-top:10px"><b>AI Use in Instructions:</b> {_e(ai_use)}</p>')
        design_block = "\n    ".join(design_parts)

    return f"""
  <section id="course-snapshot">
    <div class="eyebrow">03 &nbsp; Course Snapshot</div>
    {security_block}
    {design_block}
  </section>"""


def _gap_panel(css_class: str, heading: str, sub: str, gap: dict) -> str:
    items = gap.get("items") or []
    ops = "".join(
        f'<div class="op"><b>{_e(i.get("lead"))}</b> {_e(i.get("text"))}</div>'
        for i in items
    )
    note = gap.get("note")
    note_html = f'<p class="note">{_e(note)}</p>' if note else ""
    return f"""
      <div class="cpanel">
        <div class="chead {css_class}">{_e(heading)}<span class="s">{_e(sub)}</span></div>
        <div class="ops">{ops}</div>
        {note_html}
      </div>"""


def _render_gaps(report: dict) -> str:
    """Section 04: Where It Needs Work -- SECURITY gaps beside DESIGN gaps."""
    gaps = report.get("gaps") or {}
    security = gaps.get("security") or {}
    design = gaps.get("design") or {}
    if not (security.get("items") or design.get("items")):
        return ""
    lede = gaps.get("lede") or (
        "Security gaps are what a student could hand to AI. Design gaps are what "
        "needs redesign to measure real thinking, even where security is fine."
    )
    return f"""
  <section id="where-it-needs-work">
    <div class="eyebrow">04 &nbsp; Where It Needs Work</div>
    <p class="lede">{_e(lede)}</p>
    <div class="twocol">
      {_gap_panel("sec", "Security Gaps", "AI-substitutable, unsecured weight", security)}
      {_gap_panel("", "Design Gaps", "shallow or low AI-forward, even if secured", design)}
    </div>
  </section>"""


_TAG_KIND = {"sec": "sec", "vul": "vul"}


def _render_recommendations(report: dict) -> str:
    """Section 05: Recommendations by Assessment Type. AI-forward emphasized."""
    recs = report.get("recommendations") or []
    if not recs:
        return ""
    blocks = ""
    for r in recs:
        head_bits = [f'<b>{_e(r.get("type"))}</b>']
        if r.get("weight_pct") is not None:
            head_bits.append(f'{_pct(r.get("weight_pct"))} of grade')
        for tag in (r.get("tags") or []):
            kind = _TAG_KIND.get(tag.get("kind"), "vul")
            head_bits.append(f'<span class="tag {kind}">{_e(tag.get("label"))}</span>')
        head = " &middot; ".join(head_bits)

        body = ""
        forward = r.get("forward") or []
        if forward:
            lis = "".join(f"<li>{_e(m)}</li>" for m in forward)
            body += (f'<div class="move fwd"><div class="t">AI-Forward '
                     f'<span>&middot; recommended</span></div><ul>{lis}</ul></div>')
        resistant = r.get("resistant") or []
        if resistant:
            lis = "".join(f"<li>{_e(m)}</li>" for m in resistant)
            body += f'<div class="move res"><div class="t">AI-Resistant</div><ul>{lis}</ul></div>'
        last = r.get("last_resort")
        if last:
            body += f'<div class="move last"><div class="t">Last resort: {_e(last)}</div></div>'

        blocks += f"""
    <div class="rec">
      <div class="head">{head}</div>
      <div class="body">{body}</div>
    </div>"""

    return f"""
  <section id="recommendations">
    <div class="eyebrow">05 &nbsp; Recommendations by Assessment Type</div>
    {blocks}
  </section>"""


def _render_method(report: dict) -> str:
    """Section 06: Method and Caveats -- static defaults, overridable."""
    method = report.get("method") or {}
    scanned = method.get("scanned") or (
        "Assignment groups, classic quizzes, discussions, the syllabus, and module overview pages."
    )
    excluded = method.get("excluded") or (
        "Unpublished items and items marked omit-from-final-grade. Edge case: a "
        "practically-optional assignment not flagged omit-from-final-grade is still "
        "counted; verify in the shell."
    )
    note = method.get("note") or (
        "Within-course diagnostic, not cross-course comparable. The AI-judged layer "
        "is bounded and labeled. Verify details in the course shell before acting."
    )
    calibration = method.get("calibration") or ""
    calibration_html = (
        f'\n    <p class="note"><b>Calibration:</b> {_e(calibration)}</p>' if calibration else ""
    )
    sources = method.get("sources") or []
    sources_html = ""
    if sources:
        rows = "".join(
            f'\n      <p class="note"><b>{_e(s.get("label"))}:</b> {_e(s.get("cite"))}</p>'
            for s in sources
        )
        sources_html = f"""
    <p class="runin"><b>Sources</b></p>{rows}"""
    return f"""
  <section id="method">
    <div class="eyebrow">06 &nbsp; Method and Caveats</div>
    <p class="runin"><b>Scanned:</b> {_e(scanned)}</p>
    <p class="runin"><b>Excluded:</b> {_e(excluded)}</p>
    <p class="note">{_e(note)}</p>{calibration_html}{sources_html}
  </section>"""


# ---------------------------------------------------------------------------
# Main render function
# ---------------------------------------------------------------------------

def render(
    scorecard: dict,
    narrative: dict | None = None,
    *,
    generated_date: str = "",
) -> str:
    """Render an assurance audit report as self-contained HTML.

    scorecard: dict from assurance_audit.score_course (drives the verdict-matrix
        dot and stat fallbacks). Any field may be missing; degrades gracefully.
    narrative: optional dict; the report reads its authored ``report`` object.
        When absent, the deterministic verdict/stats/method still render.
    generated_date: ISO date string for the footer; blank omits it.
    """
    scorecard = scorecard or {}
    report = (narrative or {}).get("report") or {}
    course = scorecard.get("course") or {}
    course_name = course.get("name") or "Course"
    level_label = course.get("level_label") or ""
    course_url = course.get("url") or ""
    date_display = _e(generated_date) if generated_date else ""

    body = "".join(
        s for s in (
            _render_masthead(course_name, report, level_label, course_url),
            _HOW_TO_READ,
            _render_bottom_line(scorecard, report),
            _render_snapshot(scorecard, report),
            _render_gaps(report),
            _render_recommendations(report),
            _render_method(report),
        ) if s
    )

    footer_date = f" &middot; {date_display}" if date_display else ""
    return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{_e(course_name)}: Assessment Assurance Report</title>
<style>
{_CSS}
</style>
</head>
<body>
<div class="page">
{body}
  <div class="report-footer" style="text-align:center;padding:16px 0 0;font-size:10px;letter-spacing:.3px;color:var(--muted);border-top:1px solid var(--rule);margin-top:8px;">
    Generated by ASU Canvas Course Builder (AI Assurance Audit){footer_date}
  </div>
</div>
</body>
</html>"""


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Render an AI Assurance Audit scorecard to self-contained HTML."
    )
    parser.add_argument("--scorecard", required=True, help="Path to scorecard JSON file.")
    parser.add_argument("--narrative", default=None, help="Path to narrative sidecar JSON (holds the 'report' object).")
    parser.add_argument("--output", required=True, help="Output HTML file path.")
    parser.add_argument("--generated-date", default="", help="Date string for the footer (YYYY-MM-DD). Omit to leave blank.")
    parser.add_argument("--pdf", default=None, help="Optional PDF output path via the Playwright exporter (never installs it).")
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> None:
    args = _parse_args(argv)

    scorecard_path = Path(args.scorecard)
    if not scorecard_path.exists():
        print(f"Error: scorecard file not found: {scorecard_path}", file=sys.stderr)
        sys.exit(1)
    scorecard = json.loads(scorecard_path.read_text(encoding="utf-8"))

    narrative = None
    if args.narrative:
        narrative_path = Path(args.narrative)
        if not narrative_path.exists():
            print(f"Error: narrative file not found: {narrative_path}", file=sys.stderr)
            sys.exit(1)
        narrative = json.loads(narrative_path.read_text(encoding="utf-8"))

    html_out = render(scorecard, narrative, generated_date=args.generated_date)

    output_path = Path(args.output)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(html_out, encoding="utf-8")
    print(f"Report written to: {output_path}")

    if args.pdf:
        import report_pdf_exporter

        try:
            pdf_path = report_pdf_exporter.export_pdf(html_path=output_path, output_path=args.pdf)
        except report_pdf_exporter.PDFExportUnavailable as exc:
            print(str(exc), file=sys.stderr)
            sys.exit(1)
        print(f"PDF written to: {pdf_path}")


if __name__ == "__main__":
    main()
