#!/usr/bin/env python3
"""Assessment Assurance Batch Runner -- Component 6.

Aggregates per-course scorecard dicts produced by assurance_audit.score_course
into a cohort-level summary, ranked by count of unsecured graded assessments
descending (coverage as a tiebreak; plan: assurance-binary-labels %6c), then
renders a standalone HTML cohort report.

This runner is FULLY INDEPENDENT of course-audit, audit_report.py, and
criterion_evaluator.  It uses only scorecard dicts and the generic
report-base.html shell.

Public API
----------
aggregate_scorecards(scorecards: list[dict]) -> dict
    Rank + summarize a list of per-course scorecard dicts.

render_batch_report(aggregate, *, generated_at: str) -> str
    Return a self-contained HTML string for the cohort report.
    generated_at is the caller-supplied ISO timestamp string; this function
    does NOT call any clock.

CLI Usage
---------
    python scripts/assurance_batch.py --scorecard-dir PATH [--out PATH]

    Reads every *.json file in scorecard-dir, aggregates, and writes the HTML
    report to courses/reports/{generated_at}-batch-assurance.html (or --out).

Design contracts
----------------
- Ranking uses ONLY deterministic scorecard fields: security_ledger (or the
  lane_balance fallback via _security_counts when a scorecard predates
  security_ledger) and assurance_coverage_pct as the tiebreak. tier /
  assurance_coverage_pct as a standalone ranking key are DEPRECATED, NOT
  FEATURED (kept in the scorecard JSON and in _ATTENTION_TIERS/tier badges
  only; see docs/plan-assurance-binary-labels.md). (#219) aias_transparency_pct
  is gone; ai_policy_in_syllabus and ai_declared_assessments are reported in
  cohort_stats but never feed ranking. The needs_ai_review list and all
  AI-annotation fields never feed ranking, sort order, or cohort_stats.
- No em or en dashes in string literals (use colons/semicolons/periods).
- No import of criterion_evaluator, audit_report, or course-audit modules.
- No zip/IMSCC code.
- generated_at is passed in by the caller; render_batch_report has no internal
  clock calls.
"""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from statistics import mean, median
from typing import Any

_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
_REPO_ROOT = os.path.dirname(_SCRIPTS_DIR)

# ---------------------------------------------------------------------------
# Tier ordering (deprecated, not the ranking key -- see plan: assurance-
# binary-labels %6c. Kept for the attention-list filter below and for
# rendering the tier badge; aggregate_scorecards's primary sort key is now
# unsecured-graded-assessment count, from _security_counts.)
# ---------------------------------------------------------------------------

_TIER_RANK: dict[str, int] = {
    "Assured": 0,
    "Partially Assured": 1,
    "Unassured": 2,
    "Counterfeit": 3,
}

_ATTENTION_TIERS = frozenset({"Unassured", "Counterfeit"})


def _security_counts(sc: dict) -> tuple[int, int]:
    """Return (secured_count, unsecured_count) of graded assessments for one
    course (plan: assurance-binary-labels %6c).

    Prefers the per-assessment security_ledger (secured/unsecured binary
    label). Scorecards written before security_ledger existed fall back to
    lane_balance's lane1 (secured) vs lane2+mismatch+unclassified (not a
    confirmed secured anchor) -- the pre-existing proxy for the same concept
    that every scorecard already carries.
    """
    ledger = sc.get("security_ledger")
    if isinstance(ledger, list) and ledger:
        graded = [item for item in ledger if isinstance(item, dict) and item.get("graded")]
        secured = sum(1 for item in graded if item.get("security") == "secured")
        return secured, len(graded) - secured

    lane_balance = sc.get("lane_balance") or {}
    secured = int(lane_balance.get("lane1") or 0)
    unsecured = sum(int(lane_balance.get(k) or 0) for k in ("lane2", "mismatch", "unclassified"))
    return secured, unsecured


# ---------------------------------------------------------------------------
# Aggregate scorecards
# ---------------------------------------------------------------------------

def aggregate_scorecards(scorecards: list[dict]) -> dict:
    """Aggregate a list of per-course scorecard dicts into a cohort summary.

    Ranking (plan: assurance-binary-labels %6c):
        Worst to best by count of unsecured graded assessments, descending
        (more unsecured graded work needs attention first). Tie-break:
        assurance_coverage_pct descending (higher coverage ranks first among
        courses with the same unsecured count). See _security_counts for how
        the count is derived (security_ledger, or the lane_balance fallback
        for scorecards written before security_ledger existed). tier is no
        longer part of the sort key; it is deprecated-not-featured (kept for
        the attention-list filter and tier badge display only).

    Attention list:
        Courses with tier Counterfeit or Unassured AND has_graded_assessments
        is not False, preserving the ranked order. (#219) A truly empty course
        (has_graded_assessments == False) is excluded here -- it is not
        "certifying on nothing", it has nothing to certify.

    cohort_stats keys:
        tier_counts:                    {tier_name: int}
        certifying_on_nothing:          int  (#219: Unassured/Counterfeit count,
                                         EXCLUDING courses with no graded
                                         assessments)
        no_graded_assessments:          int  (#219: courses with zero graded
                                         assessments, reported separately)
        mean_coverage_pct:              float
        median_coverage_pct:            float
        ai_policy_syllabus_count:       int  (#219: courses with a syllabus-level
                                         AI-use policy declared)
        ai_declared_assessments_totals: {"declared": int, "total": int}  (#219:
                                         summed across the cohort)
        total_courses:                  int

    Only deterministic scorecard fields (tier, assurance_coverage_pct,
    ai_policy_in_syllabus, ai_declared_assessments, has_graded_assessments)
    are used.  needs_ai_review is never accessed here.

    Args:
        scorecards: List of dicts as returned by assurance_audit.score_course.
                    Empty list is valid; returns empty ranked/attention lists
                    and zeroed stats.

    Returns:
        {
            "ranked":       [scorecard, ...],   # best to worst
            "attention":    [scorecard, ...],   # Counterfeit + Unassured first
            "cohort_stats": {...},
        }
    """
    if not scorecards:
        return {
            "ranked": [],
            "attention": [],
            "cohort_stats": {
                "tier_counts": {},
                "certifying_on_nothing": 0,
                "no_graded_assessments": 0,
                "mean_coverage_pct": 0.0,
                "median_coverage_pct": 0.0,
                "ai_policy_syllabus_count": 0,
                "ai_declared_assessments_totals": {"declared": 0, "total": 0},
                "total_courses": 0,
            },
        }

    # -- Sort by unsecured graded-assessment count desc, coverage desc as a
    #    tiebreak (plan: assurance-binary-labels %6c). More unsecured graded
    #    work means the course needs attention first; the deprecated tier
    #    rank no longer drives the primary order (see _TIER_RANK docstring
    #    note and _ATTENTION_TIERS, which still gate the separate attention
    #    list below and are unaffected by this change).
    def _sort_key(sc: dict) -> tuple[int, float]:
        _secured, unsecured = _security_counts(sc)
        coverage = sc.get("assurance_coverage_pct", 0.0)
        return (-unsecured, -coverage)

    ranked = sorted(scorecards, key=_sort_key)

    def _has_graded_assessments(sc: dict) -> bool:
        # Default True for scorecards written before this field existed.
        return sc.get("has_graded_assessments", True)

    # -- Attention list: Counterfeit + Unassured, excluding empty courses -----
    # (#219) a course with zero graded assessments is not "certifying on
    # nothing" -- it has nothing to certify. See no_graded_assessments below.
    attention = [
        sc for sc in ranked
        if sc.get("tier") in _ATTENTION_TIERS and _has_graded_assessments(sc)
    ]

    # -- Cohort stats ---------------------------------------------------------
    tier_counts: dict[str, int] = {}
    coverage_values: list[float] = []
    ai_policy_syllabus_count = 0
    declared_total = 0
    assessed_total = 0
    no_graded_assessments = 0
    certifying_on_nothing = 0

    for sc in scorecards:
        tier = sc.get("tier", "Unassured")
        cov = sc.get("assurance_coverage_pct", 0.0)
        has_ga = _has_graded_assessments(sc)

        tier_counts[tier] = tier_counts.get(tier, 0) + 1
        coverage_values.append(float(cov))

        if sc.get("ai_policy_in_syllabus"):
            ai_policy_syllabus_count += 1
        declared_assessments = sc.get("ai_declared_assessments") or {}
        declared_total += int(declared_assessments.get("count", 0) or 0)
        assessed_total += int(declared_assessments.get("total", 0) or 0)

        if not has_ga:
            no_graded_assessments += 1
        elif tier in _ATTENTION_TIERS:
            certifying_on_nothing += 1

    mean_cov = round(mean(coverage_values), 4) if coverage_values else 0.0
    median_cov = round(median(coverage_values), 4) if coverage_values else 0.0

    return {
        "ranked": ranked,
        "attention": attention,
        "cohort_stats": {
            "tier_counts": tier_counts,
            "certifying_on_nothing": certifying_on_nothing,
            "no_graded_assessments": no_graded_assessments,
            "mean_coverage_pct": mean_cov,
            "median_coverage_pct": median_cov,
            "ai_policy_syllabus_count": ai_policy_syllabus_count,
            "ai_declared_assessments_totals": {
                "declared": declared_total, "total": assessed_total,
            },
            "total_courses": len(scorecards),
        },
    }


# ---------------------------------------------------------------------------
# HTML rendering helpers
# ---------------------------------------------------------------------------

_TIER_BADGE_CLASS: dict[str, str] = {
    "Assured": "badge-pass",
    "Partially Assured": "badge-warn",
    "Unassured": "badge-fail",
    "Counterfeit": "badge-fail",
}

_TIER_VALUE_CLASS: dict[str, str] = {
    "Assured": "pass",
    "Partially Assured": "warn",
    "Unassured": "fail",
    "Counterfeit": "fail",
}


def _esc(text: str) -> str:
    """Minimal HTML escaping for user-supplied strings (matches html.escape(s, quote=True))."""
    return (
        str(text)
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
        .replace("'", "&#x27;")
    )


def _tier_badge(tier: str) -> str:
    cls = _TIER_BADGE_CLASS.get(tier, "badge-warn")
    return f'<span class="report-section-badge {cls}">{_esc(tier)}</span>'


def _ai_policy_cell(sc: dict) -> str:
    """(#219) Two-signal AI-policy display, replacing the single aias_transparency_pct."""
    policy = "Yes" if sc.get("ai_policy_in_syllabus") else "No"
    declared_assessments = sc.get("ai_declared_assessments") or {}
    count = int(declared_assessments.get("count", 0) or 0)
    total = int(declared_assessments.get("total", 0) or 0)
    return (
        f'Syllabus: {policy}'
        f'<br><span style="font-size:11px;color:#71717a;">Assessments: {count}/{total}</span>'
    )


def _empty_course_badge(sc: dict) -> str:
    if sc.get("has_graded_assessments", True):
        return ""
    return (
        ' <span style="background:#e4e4e7;color:#3f3f46;font-size:10px;'
        'padding:1px 6px;border-radius:10px;">NO GRADED ASSESSMENTS</span>'
    )


def _render_attention_section(attention: list[dict]) -> str:
    if not attention:
        return (
            '<div class="report-section">'
            '<div class="report-section-header">'
            '<span class="report-section-title">Attention: Courses Certifying on Nothing</span>'
            '<span class="report-section-badge badge-pass">NONE</span>'
            "</div>"
            '<div class="report-section-body">'
            '<p style="font-size:13px;color:#16a34a;">'
            "No Counterfeit or Unassured courses in this cohort."
            "</p>"
            "</div>"
            "</div>"
        )

    rows_html = ""
    for sc in attention:
        course = sc.get("course", {})
        name = _esc(course.get("name") or course.get("id") or "Unknown Course")
        cid = _esc(str(course.get("id") or ""))
        tier = sc.get("tier", "Unassured")
        secured_n, unsecured_n = _security_counts(sc)
        badge = _tier_badge(tier)
        ai_policy_cell = _ai_policy_cell(sc)
        rows_html += (
            f"<tr>"
            f'<td style="padding:10px 8px;font-size:13px;">{name}'
            f'<br><span style="font-size:11px;color:#71717a;">ID: {cid}</span></td>'
            f'<td style="padding:10px 8px;text-align:center;">{badge}</td>'
            f'<td style="padding:10px 8px;text-align:center;font-size:13px;">{secured_n}</td>'
            f'<td style="padding:10px 8px;text-align:center;font-size:13px;">{unsecured_n}</td>'
            f'<td style="padding:10px 8px;text-align:center;font-size:13px;">{ai_policy_cell}</td>'
            f"</tr>"
        )

    count = len(attention)
    return (
        f'<div class="report-section">'
        f'<div class="report-section-header">'
        f'<span class="report-section-title">Attention: Courses Certifying on Nothing</span>'
        f'<div class="report-section-header-right">'
        f'<span class="report-section-badge badge-fail">{count} COURSE{"S" if count != 1 else ""}</span>'
        f"</div>"
        f"</div>"
        f'<div class="report-section-body">'
        f'<p style="margin-bottom:12px;font-size:13px;color:#555;">'
        f"These courses have tier Counterfeit or Unassured: high-weight assessments lack "
        f"a verified security property, or no secured Lane 1 anchor exists. "
        f"Recommendation: re-lane or secure at least one high-weight component per course."
        f"</p>"
        f'<table style="width:100%;border-collapse:collapse;font-size:13px;">'
        f"<thead>"
        f'<tr style="border-bottom:2px solid #e4e4e7;">'
        f'<th style="text-align:left;padding:8px;font-size:12px;color:#71717a;font-weight:600;">COURSE</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">TIER</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">SECURED</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">UNSECURED</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">AI POLICY</th>'
        f"</tr>"
        f"</thead>"
        f"<tbody>{rows_html}</tbody>"
        f"</table>"
        f"</div>"
        f"</div>"
    )


def _render_ranked_table(ranked: list[dict]) -> str:
    if not ranked:
        return (
            '<div class="report-section">'
            '<div class="report-section-header">'
            '<span class="report-section-title">Full Cohort Ranking</span>'
            '</div>'
            '<div class="report-section-body">'
            '<p style="font-size:13px;color:#71717a;">No scorecards to display.</p>'
            "</div>"
            "</div>"
        )

    rows_html = ""
    for rank_idx, sc in enumerate(ranked, start=1):
        course = sc.get("course", {})
        name = _esc(course.get("name") or course.get("id") or "Unknown Course")
        cid = _esc(str(course.get("id") or ""))
        tier = sc.get("tier", "Unassured")
        secured_n, unsecured_n = _security_counts(sc)
        ai_policy_cell = _ai_policy_cell(sc)
        lane_bal = sc.get("lane_balance", {})
        anchor_count = sc.get("dimensions", {}).get("D", {}).get("anchor_count", 0)
        flags = sc.get("flags", {})
        flag_pills = ""
        if flags.get("counterfeit_assurance"):
            # (#219) this flag now tracks has_mismatch, not tier == Counterfeit;
            # it is a chip layered on the coverage-derived tier, not a tier override.
            flag_pills += '<span style="background:#fecaca;color:#dc2626;font-size:10px;padding:1px 6px;border-radius:10px;margin-right:3px;">COUNTERFEIT CLAIM</span>'
        if flags.get("transparency_gap"):
            flag_pills += '<span style="background:#fef3c7;color:#92400e;font-size:10px;padding:1px 6px;border-radius:10px;margin-right:3px;">NO AI POLICY</span>'
        if flags.get("no_anchor"):
            flag_pills += '<span style="background:#fecaca;color:#dc2626;font-size:10px;padding:1px 6px;border-radius:10px;margin-right:3px;">NO ANCHOR</span>'
        if flags.get("over_surveillance"):
            flag_pills += '<span style="background:#dbeafe;color:#1d4ed8;font-size:10px;padding:1px 6px;border-radius:10px;margin-right:3px;">OVER-SECURED</span>'

        badge = _tier_badge(tier) + _empty_course_badge(sc)
        row_bg = "#fff9f9" if tier in _ATTENTION_TIERS else "white"
        # Built outside the f-string: Python 3.11 forbids backslashes in
        # f-string expression parts, and the fallback contains escaped quotes.
        flag_cell = flag_pills or '<span style="color:#71717a;">--</span>'
        rows_html += (
            f'<tr style="border-bottom:1px solid #f0f0f2;background:{row_bg};">'
            f'<td style="padding:10px 8px;font-size:13px;color:#71717a;text-align:center;">{rank_idx}</td>'
            f'<td style="padding:10px 8px;font-size:13px;">{name}'
            f'<br><span style="font-size:11px;color:#71717a;">ID: {cid}</span></td>'
            f'<td style="padding:10px 8px;text-align:center;">{badge}</td>'
            f'<td style="padding:10px 8px;text-align:center;font-size:13px;">{secured_n}</td>'
            f'<td style="padding:10px 8px;text-align:center;font-size:13px;">{unsecured_n}</td>'
            f'<td style="padding:10px 8px;text-align:center;font-size:13px;">{ai_policy_cell}</td>'
            f'<td style="padding:10px 8px;text-align:center;font-size:13px;">{anchor_count}</td>'
            f'<td style="padding:10px 8px;font-size:12px;">{flag_cell}</td>'
            f"</tr>"
        )

    total = len(ranked)
    return (
        f'<div class="report-section">'
        f'<div class="report-section-header">'
        f'<span class="report-section-title">Full Cohort Ranking</span>'
        f'<div class="report-section-header-right">'
        f'<span class="report-section-badge badge-warn">{total} COURSES</span>'
        f"</div>"
        f"</div>"
        f'<div class="report-section-body">'
        f'<p style="margin-bottom:12px;font-size:13px;color:#555;">'
        f"Ranked worst to best by count of unsecured graded assessments, assurance coverage "
        f"descending as a tiebreak. Deterministic fields only; AI-annotation judgments are "
        f"within-course diagnostics and do not affect ranking."
        f"</p>"
        f'<table style="width:100%;border-collapse:collapse;">'
        f"<thead>"
        f'<tr style="border-bottom:2px solid #e4e4e7;">'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">#</th>'
        f'<th style="text-align:left;padding:8px;font-size:12px;color:#71717a;font-weight:600;">COURSE</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">TIER</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">SECURED</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">UNSECURED</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">AI POLICY</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">ANCHORS</th>'
        f'<th style="text-align:left;padding:8px;font-size:12px;color:#71717a;font-weight:600;">FLAGS</th>'
        f"</tr>"
        f"</thead>"
        f"<tbody>{rows_html}</tbody>"
        f"</table>"
        f"</div>"
        f"</div>"
    )


def _render_cohort_stats_section(stats: dict) -> str:
    tier_counts = stats.get("tier_counts", {})
    total = stats.get("total_courses", 0)
    certifying_on_nothing = stats.get("certifying_on_nothing", 0)
    no_graded_assessments = stats.get("no_graded_assessments", 0)
    mean_cov = stats.get("mean_coverage_pct", 0.0)
    median_cov = stats.get("median_coverage_pct", 0.0)

    tier_rows = ""
    for tier in ["Assured", "Partially Assured", "Unassured", "Counterfeit"]:
        count = tier_counts.get(tier, 0)
        pct = round(count / total * 100.0, 1) if total > 0 else 0.0
        badge = _tier_badge(tier)
        tier_rows += (
            f"<tr>"
            f'<td style="padding:8px;">{badge}</td>'
            f'<td style="padding:8px;text-align:center;font-size:14px;font-weight:600;">{count}</td>'
            f'<td style="padding:8px;text-align:center;font-size:13px;color:#71717a;">{pct:.1f}%</td>'
            f"</tr>"
        )

    certify_color = "fail" if certifying_on_nothing > 0 else "pass"
    return (
        f'<div class="report-section">'
        f'<div class="report-section-header">'
        f'<span class="report-section-title">Cohort Statistics</span>'
        f"</div>"
        f'<div class="report-section-body">'
        f'<div class="summary-bar" style="margin-bottom:20px;">'
        f'<div class="summary-card">'
        f'<div class="summary-card-value">{total}</div>'
        f'<div class="summary-card-label">Total Courses</div>'
        f"</div>"
        f'<div class="summary-card">'
        f'<div class="summary-card-value {certify_color}">{certifying_on_nothing}</div>'
        f'<div class="summary-card-label">Certifying on Nothing</div>'
        f"</div>"
        f'<div class="summary-card">'
        f'<div class="summary-card-value">{no_graded_assessments}</div>'
        f'<div class="summary-card-label">No Graded Assessments</div>'
        f"</div>"
        f'<div class="summary-card">'
        f'<div class="summary-card-value warn">{mean_cov:.1f}%</div>'
        f'<div class="summary-card-label">Mean Coverage</div>'
        f"</div>"
        f'<div class="summary-card">'
        f'<div class="summary-card-value warn">{median_cov:.1f}%</div>'
        f'<div class="summary-card-label">Median Coverage</div>'
        f"</div>"
        f"</div>"
        f'<table style="width:100%;border-collapse:collapse;max-width:400px;">'
        f"<thead>"
        f'<tr style="border-bottom:2px solid #e4e4e7;">'
        f'<th style="text-align:left;padding:8px;font-size:12px;color:#71717a;font-weight:600;">TIER</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">COUNT</th>'
        f'<th style="text-align:center;padding:8px;font-size:12px;color:#71717a;font-weight:600;">PCT OF COHORT</th>'
        f"</tr>"
        f"</thead>"
        f"<tbody>{tier_rows}</tbody>"
        f"</table>"
        f"</div>"
        f"</div>"
    )


# ---------------------------------------------------------------------------
# Report rendering
# ---------------------------------------------------------------------------

def render_batch_report(aggregate: dict, *, generated_at: str) -> str:
    """Render the cohort HTML report.

    Args:
        aggregate:    Dict from aggregate_scorecards().
        generated_at: Caller-supplied ISO timestamp string (e.g. "20260615-143022").
                      This function does NOT call any clock.

    Returns:
        Self-contained HTML string using the report-base.html shell.
    """
    ranked = aggregate.get("ranked", [])
    attention = aggregate.get("attention", [])
    stats = aggregate.get("cohort_stats", {})
    total = stats.get("total_courses", 0)
    certifying_on_nothing = stats.get("certifying_on_nothing", 0)

    # -- Load the report base shell -------------------------------------------
    shell_path = Path(_REPO_ROOT) / "templates" / "report-html" / "report-base.html"
    if shell_path.is_file():
        shell = shell_path.read_text(encoding="utf-8")
    else:
        # Minimal fallback shell if template is absent (tests / CI without templates)
        shell = (
            "<!DOCTYPE html><html><head><meta charset='UTF-8'>"
            "<title>{{REPORT_TITLE}}</title></head><body>"
            "<div class='report'>"
            "{{REPORT_BODY}}"
            "<div class='report-footer'>Generated {{REPORT_DATE}}</div>"
            "</div></body></html>"
        )

    # -- Build report body ----------------------------------------------------
    report_body = (
        _render_attention_section(attention)
        + "\n"
        + _render_ranked_table(ranked)
        + "\n"
        + _render_cohort_stats_section(stats)
    )

    # -- Substitute placeholders in the shell ---------------------------------
    assured_count = stats.get("tier_counts", {}).get("Assured", 0)
    partially_count = stats.get("tier_counts", {}).get("Partially Assured", 0)
    unassured_count = stats.get("tier_counts", {}).get("Unassured", 0)
    counterfeit_count = stats.get("tier_counts", {}).get("Counterfeit", 0)

    html = shell
    html = html.replace("{{REPORT_TITLE}}", "Assessment Assurance Cohort Report")
    html = html.replace("{{REPORT_TYPE}}", "Assessment Assurance Audit")
    html = html.replace("{{COURSE_NAME}}", f"Cohort: {total} course{'s' if total != 1 else ''}")
    html = html.replace("{{REPORT_DATE}}", _esc(generated_at))
    html = html.replace("{{PAGES_SCANNED}}", str(total))
    html = html.replace("{{PASS_COUNT}}", str(assured_count))
    html = html.replace("{{WARN_COUNT}}", str(partially_count))
    html = html.replace("{{FAIL_COUNT}}", str(certifying_on_nothing))
    html = html.replace("{{REPORT_BODY}}", report_body)

    return html


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    import argparse
    from datetime import datetime, timezone

    parser = argparse.ArgumentParser(
        description=(
            "Aggregate per-course assurance scorecard JSON files and produce "
            "a cohort HTML report."
        )
    )
    parser.add_argument(
        "--scorecard-dir",
        required=True,
        help="Directory containing *.json scorecard files produced by assurance_audit.py.",
    )
    parser.add_argument(
        "--out",
        default=None,
        help=(
            "Output path for the HTML report. "
            "Defaults to courses/reports/{generated_at}-batch-assurance.html "
            "relative to the repo root."
        ),
    )
    args = parser.parse_args()

    scorecard_dir = Path(args.scorecard_dir)
    if not scorecard_dir.is_dir():
        print(f"ERROR: scorecard-dir does not exist: {scorecard_dir}", file=sys.stderr)
        sys.exit(1)

    # Load scorecard JSON files
    scorecards: list[dict] = []
    for json_file in sorted(scorecard_dir.glob("*.json")):
        try:
            data = json.loads(json_file.read_text(encoding="utf-8"))
            scorecards.append(data)
        except Exception as exc:
            print(f"WARNING: could not load {json_file}: {exc}", file=sys.stderr)

    if not scorecards:
        print("ERROR: no scorecard JSON files found in scorecard-dir.", file=sys.stderr)
        sys.exit(1)

    print(f"Loaded {len(scorecards)} scorecards from {scorecard_dir}")

    generated_at = datetime.now(tz=timezone.utc).strftime("%Y%m%d-%H%M%S")

    aggregate = aggregate_scorecards(scorecards)
    html = render_batch_report(aggregate, generated_at=generated_at)

    if args.out:
        out_path = Path(args.out)
    else:
        reports_dir = Path(_REPO_ROOT) / "courses" / "reports"
        reports_dir.mkdir(parents=True, exist_ok=True)
        out_path = reports_dir / f"{generated_at}-batch-assurance.html"

    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(html, encoding="utf-8")
    print(f"Cohort report written to: {out_path}")

    stats = aggregate["cohort_stats"]
    print(f"  Total courses: {stats['total_courses']}")
    print(f"  Certifying on nothing: {stats['certifying_on_nothing']}")
    print(f"  Mean coverage: {stats['mean_coverage_pct']:.1f}%")
    print(f"  Median coverage: {stats['median_coverage_pct']:.1f}%")
    for tier, count in sorted(stats["tier_counts"].items()):
        print(f"  {tier}: {count}")
