"""Tests for scripts/assurance_batch.py -- Component 6.

Covers:
- Ranking across all four tiers (Assured best, Counterfeit worst).
- Tie-break within tier by assurance_coverage_pct descending.
- Attention list contains exactly Counterfeit + Unassured courses.
- cohort_stats: per-tier counts, certifying-on-nothing, mean/median coverage.
- render_batch_report: returns valid HTML with attention list + ranked table;
  generated_at is injected, no internal clock call.
- Determinism: ranking uses only tier + assurance_coverage_pct; never accesses
  needs_ai_review or AI-annotation fields. (#219) aias_transparency_pct is gone;
  ai_policy_in_syllabus / ai_declared_assessments are reported but never rank.
- No import of course-audit / audit_report / criterion_evaluator.
- No imscc / zip handling.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

import pytest

# ---------------------------------------------------------------------------
# Import guard: assurance_batch must NOT import forbidden modules
# ---------------------------------------------------------------------------

FORBIDDEN_IMPORTS = {
    "criterion_evaluator",
    "audit_report",
    "course_audit",
}

# assurance_batch imports only stdlib (json/os/sys/pathlib/statistics/typing),
# so no module stubbing is needed. Do NOT inject stub modules into sys.modules
# here: a permanent sys.modules['canvas_api'] stub leaks into every later test
# module and breaks `from canvas_api import ...` across the suite.
_scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
if str(_scripts_dir) not in sys.path:
    sys.path.insert(0, str(_scripts_dir))

import assurance_batch  # noqa: E402  (after path manipulation)


def _code_lines(src: str) -> str:
    """Return only non-comment, non-docstring lines from Python source.

    Strips single-line comments (#...) and triple-quoted docstring blocks.
    Used so that mentions in docstrings or comments do not trigger import
    guards (we care about actual import statements and function calls).
    """
    import ast
    import tokenize
    import io
    result_lines = []
    try:
        tokens = list(tokenize.generate_tokens(io.StringIO(src).readline))
        # Collect byte ranges of all string constants that appear as the
        # first statement in a function/class/module (docstrings).
        tree = ast.parse(src)
        docstring_nodes: set[int] = set()
        for node in ast.walk(tree):
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Module)):
                body = node.body
                if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant):
                    docstring_nodes.add(body[0].lineno)

        for tok_type, tok_string, (srow, _), _, _ in tokens:
            if tok_type == tokenize.COMMENT:
                continue
            if tok_type == tokenize.STRING and srow in docstring_nodes:
                continue
            if tok_type not in (tokenize.NEWLINE, tokenize.NL, tokenize.INDENT,
                                 tokenize.DEDENT, tokenize.ENCODING):
                result_lines.append(tok_string)
    except Exception:
        # Fallback: strip # comment lines only
        result_lines = [
            ln for ln in src.splitlines()
            if not ln.lstrip().startswith("#")
        ]
    return " ".join(result_lines)


def test_no_forbidden_imports():
    """assurance_batch must not import criterion_evaluator, audit_report, or
    course_audit via actual import statements (docstring mentions are allowed)."""
    module_src = (_scripts_dir / "assurance_batch.py").read_text(encoding="utf-8")
    code_only = _code_lines(module_src)
    for forbidden in FORBIDDEN_IMPORTS:
        assert f"import {forbidden}" not in code_only, (
            f"assurance_batch.py imports '{forbidden}'. "
            "The assurance batch path must be independent of QA/course-audit modules."
        )


def test_no_imscc_zip():
    """assurance_batch must not contain imscc or zip handling."""
    module_src = (_scripts_dir / "assurance_batch.py").read_text(encoding="utf-8")
    for token in ("imscc", "zipfile", "ZipFile"):
        assert token not in module_src, (
            f"assurance_batch.py contains '{token}'. No IMSCC/zip code allowed."
        )


# ---------------------------------------------------------------------------
# Scorecard factory helpers
# ---------------------------------------------------------------------------

def _make_scorecard(
    course_id: str,
    course_name: str,
    tier: str,
    coverage: float,
    ai_policy_in_syllabus: bool = False,
    ai_declared_assessments: dict | None = None,
    counterfeit_claim: bool = False,
    has_graded_assessments: bool = True,
    flags: dict | None = None,
    security_ledger: list | None = None,
) -> dict:
    """Build a minimal scorecard dict matching the assurance_audit schema.

    (#219) aias_transparency_pct is gone; replaced by ai_policy_in_syllabus /
    ai_declared_assessments. counterfeit_claim and has_graded_assessments added.
    security_ledger (plan: assurance-binary-labels) is omitted by default, so
    existing callers that do not pass it exercise the lane_balance fallback in
    _security_counts unchanged; pass it explicitly to exercise the ledger path.
    """
    declared_assessments = ai_declared_assessments or {"count": 0, "total": 0}
    sc = {
        "course": {"id": course_id, "name": course_name, "tier": "introductory"},
        "tier": tier,
        "assurance_coverage_pct": coverage,
        "ai_policy_in_syllabus": ai_policy_in_syllabus,
        "ai_declared_assessments": declared_assessments,
        "counterfeit_claim": counterfeit_claim,
        "has_graded_assessments": has_graded_assessments,
        "lane_balance": {"lane1": 0, "lane2": 0, "mismatch": 0, "unclassified": 0},
        "dimensions": {
            "A": {"top_k_assessed": 0, "rubric_present_pct": 0.0, "rubric_present_count": 0},
            "B": {"scored": False, "deltas": []},
            "C": {"weighted_distribution": {}, "arc": "flat", "counterfeit_objective": []},
            "D": {
                "tier": tier,
                "assurance_coverage_pct": coverage,
                "anchor_count": 1 if coverage > 0 else 0,
                "anchor_items": [],
                "mismatch_items": [],
                "lane_summary": {},
                "aias": {"declared_presence": ai_policy_in_syllabus, "found_in": []},
            },
        },
        "flags": flags or {
            "counterfeit_assurance": counterfeit_claim,
            "transparency_gap": not ai_policy_in_syllabus,
            "no_anchor": coverage == 0.0,
            "over_surveillance": False,
        },
        "durable_skills": [],
        "needs_ai_review": [],  # never accessed by batch runner
        "assumptions": {"K": 0, "top_k_weight_cap_pct": 0.0},
    }
    if security_ledger is not None:
        sc["security_ledger"] = security_ledger
    return sc


def _ledger(secured_n: int, unsecured_n: int) -> list:
    """Build a minimal security_ledger with the given secured/unsecured counts."""
    items = []
    for i in range(secured_n):
        items.append({
            "id": i, "title": f"Secured {i}", "grade_weight_pct": 10.0, "graded": True,
            "security": "secured", "security_basis": "identity",
            "security_evidence": "proctoring_signal (API fact)",
            "needs_ai_review": False, "design": "n/a", "design_basis": "",
        })
    for i in range(unsecured_n):
        items.append({
            "id": 1000 + i, "title": f"Unsecured {i}", "grade_weight_pct": 10.0, "graded": True,
            "security": "unsecured", "security_basis": "none", "security_evidence": "",
            "needs_ai_review": False, "design": "ai_resistant", "design_basis": "",
        })
    return items


# ---------------------------------------------------------------------------
# Sample cohort fixture
# ---------------------------------------------------------------------------

@pytest.fixture()
def sample_scorecards():
    """Six courses spanning all four tiers with varied coverage."""
    return [
        _make_scorecard("1", "BIO 101", "Assured", 65.0, ai_policy_in_syllabus=True,
                        ai_declared_assessments={"count": 4, "total": 5}),
        _make_scorecard("2", "CHM 201", "Assured", 45.0, ai_policy_in_syllabus=True,
                        ai_declared_assessments={"count": 2, "total": 4}),
        _make_scorecard("3", "ENG 300", "Partially Assured", 30.0),
        _make_scorecard("4", "PSY 200", "Partially Assured", 10.0, ai_policy_in_syllabus=True),
        _make_scorecard("5", "LAW 510", "Unassured", 0.0),
        _make_scorecard("6", "MGT 400", "Counterfeit", 0.0),
    ]


# ---------------------------------------------------------------------------
# Ranking tests
# ---------------------------------------------------------------------------

class TestRanking:
    def test_tier_order(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        ranked = result["ranked"]
        tiers = [sc["tier"] for sc in ranked]
        # Assured comes before Partially Assured, which comes before Unassured/Counterfeit
        assert tiers.index("Assured") < tiers.index("Partially Assured")
        # All Assured items should appear before any Partially Assured
        assured_indices = [i for i, sc in enumerate(ranked) if sc["tier"] == "Assured"]
        partial_indices = [i for i, sc in enumerate(ranked) if sc["tier"] == "Partially Assured"]
        assert max(assured_indices) < min(partial_indices)

    def test_attention_tiers_last(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        ranked = result["ranked"]
        attention_tiers = {"Unassured", "Counterfeit"}
        safe_indices = [i for i, sc in enumerate(ranked) if sc["tier"] not in attention_tiers]
        attention_indices = [i for i, sc in enumerate(ranked) if sc["tier"] in attention_tiers]
        if safe_indices and attention_indices:
            assert max(safe_indices) < min(attention_indices)

    def test_counterfeit_claim_does_not_demote_ranking(self):
        # (#219) A 60%-secured course carrying a counterfeit_claim flag must
        # still outrank a clean 5%-secured course. counterfeit_claim/flags are
        # a displayed chip, never part of tier or sort order.
        high_with_claim = _make_scorecard(
            "1", "High Coverage, One Mismatch", "Assured", 60.0, counterfeit_claim=True,
        )
        low_clean = _make_scorecard(
            "2", "Low Coverage, Clean", "Partially Assured", 5.0, counterfeit_claim=False,
        )
        result = assurance_batch.aggregate_scorecards([low_clean, high_with_claim])
        ranked_ids = [sc["course"]["id"] for sc in result["ranked"]]
        assert ranked_ids == ["1", "2"]

    def test_coverage_tiebreak_within_tier(self):
        """Within the same tier, higher coverage ranks first."""
        sc_low = _make_scorecard("a", "Course A", "Assured", 41.0)
        sc_high = _make_scorecard("b", "Course B", "Assured", 60.0)
        sc_mid = _make_scorecard("c", "Course C", "Assured", 50.0)
        result = assurance_batch.aggregate_scorecards([sc_low, sc_high, sc_mid])
        ranked = result["ranked"]
        coverages = [sc["assurance_coverage_pct"] for sc in ranked]
        assert coverages == sorted(coverages, reverse=True)

    def test_full_four_tier_order(self, sample_scorecards):
        """Exact tier sequence: Assured, Partially Assured, Unassured, Counterfeit."""
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        ranked = result["ranked"]
        seen_tiers = []
        for sc in ranked:
            t = sc["tier"]
            if not seen_tiers or seen_tiers[-1] != t:
                seen_tiers.append(t)
        # Assured must appear before Partially Assured; attention tiers last
        tier_rank = assurance_batch._TIER_RANK
        for i in range(len(seen_tiers) - 1):
            assert tier_rank[seen_tiers[i]] <= tier_rank[seen_tiers[i + 1]], (
                f"Tier order violated: {seen_tiers[i]} before {seen_tiers[i+1]}"
            )

    def test_all_courses_appear_in_ranked(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        assert len(result["ranked"]) == len(sample_scorecards)

    def test_empty_input(self):
        result = assurance_batch.aggregate_scorecards([])
        assert result["ranked"] == []
        assert result["attention"] == []
        assert result["cohort_stats"]["total_courses"] == 0


# ---------------------------------------------------------------------------
# Criterion 11 (docs/plan-assurance-binary-labels.md %6c/%8): primary sort key
# is unsecured graded-assessment count, descending; coverage is a tiebreak only.
# ---------------------------------------------------------------------------

class TestUnsecuredCountSort:
    def test_more_unsecured_ranks_first_even_with_higher_coverage(self):
        # Three synthetic scorecards (plan %8). A high-coverage course that is
        # MOSTLY unsecured must rank ABOVE a low-coverage, fully-secured course
        # -- the deprecated coverage percentage no longer drives the order.
        risky = _make_scorecard("1", "Risky High Coverage", "Assured", 90.0,
                                security_ledger=_ledger(secured_n=1, unsecured_n=5))
        safe = _make_scorecard("2", "Safe Low Coverage", "Unassured", 10.0,
                               security_ledger=_ledger(secured_n=4, unsecured_n=0))
        mixed = _make_scorecard("3", "Mixed", "Partially Assured", 50.0,
                                security_ledger=_ledger(secured_n=2, unsecured_n=2))
        result = assurance_batch.aggregate_scorecards([safe, mixed, risky])
        ranked_ids = [sc["course"]["id"] for sc in result["ranked"]]
        # unsecured counts: risky=5, mixed=2, safe=0 -- descending order.
        assert ranked_ids == ["1", "3", "2"]

    def test_coverage_is_only_a_tiebreak_for_equal_unsecured_counts(self):
        low_cov = _make_scorecard("a", "Low Coverage", "Partially Assured", 20.0,
                                  security_ledger=_ledger(secured_n=1, unsecured_n=2))
        high_cov = _make_scorecard("b", "High Coverage", "Partially Assured", 45.0,
                                   security_ledger=_ledger(secured_n=1, unsecured_n=2))
        result = assurance_batch.aggregate_scorecards([low_cov, high_cov])
        ranked_ids = [sc["course"]["id"] for sc in result["ranked"]]
        # Same unsecured count (2) -> higher coverage tiebreaks first.
        assert ranked_ids == ["b", "a"]

    def test_zero_unsecured_ranks_last(self):
        fully_secured = _make_scorecard("1", "Fully Secured", "Assured", 100.0,
                                        security_ledger=_ledger(secured_n=3, unsecured_n=0))
        one_unsecured = _make_scorecard("2", "One Unsecured", "Partially Assured", 30.0,
                                        security_ledger=_ledger(secured_n=2, unsecured_n=1))
        result = assurance_batch.aggregate_scorecards([fully_secured, one_unsecured])
        ranked_ids = [sc["course"]["id"] for sc in result["ranked"]]
        assert ranked_ids == ["2", "1"]

    def test_legacy_scorecard_without_ledger_falls_back_to_lane_balance(self):
        # No security_ledger at all: falls back to lane_balance
        # (lane2 + mismatch + unclassified vs lane1), the pre-existing proxy.
        legacy_risky = _make_scorecard("1", "Legacy Risky", "Unassured", 0.0)
        legacy_risky["lane_balance"] = {"lane1": 0, "lane2": 3, "mismatch": 1, "unclassified": 0}
        legacy_safe = _make_scorecard("2", "Legacy Safe", "Assured", 80.0)
        legacy_safe["lane_balance"] = {"lane1": 4, "lane2": 0, "mismatch": 0, "unclassified": 0}
        result = assurance_batch.aggregate_scorecards([legacy_safe, legacy_risky])
        ranked_ids = [sc["course"]["id"] for sc in result["ranked"]]
        # 4 unsecured (lane2+mismatch+unclassified=3+1+0) for "1" ranks before
        # 0 unsecured for "2".
        assert ranked_ids == ["1", "2"]

    def test_security_counts_prefers_ledger_over_lane_balance_when_both_present(self):
        from assurance_batch import _security_counts
        sc = _make_scorecard("1", "Both Present", "Assured", 90.0,
                             security_ledger=_ledger(secured_n=2, unsecured_n=1))
        sc["lane_balance"] = {"lane1": 0, "lane2": 99, "mismatch": 0, "unclassified": 0}
        secured, unsecured = _security_counts(sc)
        assert (secured, unsecured) == (2, 1)

    def test_security_counts_ignores_ungraded_ledger_items(self):
        from assurance_batch import _security_counts
        ledger = _ledger(secured_n=1, unsecured_n=1)
        ledger.append({
            "id": 999, "title": "Ungraded Survey", "grade_weight_pct": 0.0, "graded": False,
            "security": "unsecured", "security_basis": "none", "security_evidence": "",
            "needs_ai_review": False, "design": "n/a", "design_basis": "",
        })
        sc = _make_scorecard("1", "X", "Assured", 50.0, security_ledger=ledger)
        secured, unsecured = _security_counts(sc)
        assert (secured, unsecured) == (1, 1)


# ---------------------------------------------------------------------------
# Attention list tests
# ---------------------------------------------------------------------------

class TestAttentionList:
    def test_attention_contains_exactly_counterfeit_and_unassured(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        attention = result["attention"]
        attention_tiers = {sc["tier"] for sc in attention}
        assert attention_tiers <= {"Unassured", "Counterfeit"}, (
            f"Attention list contains non-attention tier(s): {attention_tiers}"
        )
        # All Unassured + Counterfeit from the cohort must appear
        expected_ids = {
            sc["course"]["id"]
            for sc in sample_scorecards
            if sc["tier"] in {"Unassured", "Counterfeit"}
        }
        actual_ids = {sc["course"]["id"] for sc in attention}
        assert actual_ids == expected_ids

    def test_attention_is_subset_of_ranked_preserving_order(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        ranked_ids = [sc["course"]["id"] for sc in result["ranked"]]
        attention_ids = [sc["course"]["id"] for sc in result["attention"]]
        # Every attention item must appear in ranked
        for aid in attention_ids:
            assert aid in ranked_ids
        # Relative order in attention must match ranked
        ranked_positions = {cid: i for i, cid in enumerate(ranked_ids)}
        attention_positions = [ranked_positions[cid] for cid in attention_ids]
        assert attention_positions == sorted(attention_positions)

    def test_attention_empty_when_no_bad_tiers(self):
        scorecards = [
            _make_scorecard("1", "Course A", "Assured", 55.0),
            _make_scorecard("2", "Course B", "Partially Assured", 20.0),
        ]
        result = assurance_batch.aggregate_scorecards(scorecards)
        assert result["attention"] == []

    def test_attention_contains_all_when_all_bad(self):
        scorecards = [
            _make_scorecard("1", "Course A", "Unassured", 0.0),
            _make_scorecard("2", "Course B", "Counterfeit", 0.0),
            _make_scorecard("3", "Course C", "Unassured", 0.0),
        ]
        result = assurance_batch.aggregate_scorecards(scorecards)
        assert len(result["attention"]) == 3


# ---------------------------------------------------------------------------
# Cohort stats tests
# ---------------------------------------------------------------------------

class TestCohortStats:
    def test_tier_counts(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        stats = result["cohort_stats"]
        assert stats["tier_counts"]["Assured"] == 2
        assert stats["tier_counts"]["Partially Assured"] == 2
        assert stats["tier_counts"]["Unassured"] == 1
        assert stats["tier_counts"]["Counterfeit"] == 1

    def test_certifying_on_nothing(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        stats = result["cohort_stats"]
        # Unassured(1) + Counterfeit(1) = 2
        assert stats["certifying_on_nothing"] == 2

    def test_empty_course_excluded_from_certifying_on_nothing(self):
        # (#219) a truly empty course (zero graded assessments) is Unassured by
        # coverage math but must NOT be counted as certifying on nothing -- it
        # has nothing to certify. It shows up in no_graded_assessments instead.
        scorecards = [
            _make_scorecard("1", "Empty Course", "Unassured", 0.0, has_graded_assessments=False),
            _make_scorecard("2", "Real Unassured Course", "Unassured", 0.0, has_graded_assessments=True),
        ]
        result = assurance_batch.aggregate_scorecards(scorecards)
        stats = result["cohort_stats"]
        assert stats["certifying_on_nothing"] == 1
        assert stats["no_graded_assessments"] == 1
        attention_ids = {sc["course"]["id"] for sc in result["attention"]}
        assert attention_ids == {"2"}

    def test_has_graded_assessments_with_no_field_defaults_true(self):
        # Backward compatibility: scorecards written before this field existed
        # must still be counted as certifying_on_nothing when Unassured.
        sc = _make_scorecard("1", "Legacy", "Unassured", 0.0)
        del sc["has_graded_assessments"]
        result = assurance_batch.aggregate_scorecards([sc])
        stats = result["cohort_stats"]
        assert stats["certifying_on_nothing"] == 1
        assert stats["no_graded_assessments"] == 0

    def test_mean_coverage(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        stats = result["cohort_stats"]
        expected_mean = (65.0 + 45.0 + 30.0 + 10.0 + 0.0 + 0.0) / 6
        assert abs(stats["mean_coverage_pct"] - expected_mean) < 0.01

    def test_median_coverage(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        stats = result["cohort_stats"]
        # Sorted: 0, 0, 10, 30, 45, 65 -> median = (10 + 30) / 2 = 20.0
        assert abs(stats["median_coverage_pct"] - 20.0) < 0.01

    def test_total_courses(self, sample_scorecards):
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        assert result["cohort_stats"]["total_courses"] == len(sample_scorecards)

    def test_ai_policy_signals_aggregated_not_blended(self, sample_scorecards):
        # (#219) replaces test_aias_transparency_keyed_by_tier: the two-signal
        # replacement is reported as cohort-wide counts/sums, not a single
        # gameable per-tier ratio list.
        result = assurance_batch.aggregate_scorecards(sample_scorecards)
        stats = result["cohort_stats"]
        assert "aias_transparency" not in stats
        # BIO 101, CHM 201, PSY 200 declared a syllabus policy -> 3 of 6 courses.
        assert stats["ai_policy_syllabus_count"] == 3
        totals = stats["ai_declared_assessments_totals"]
        assert totals["declared"] == 4 + 2  # BIO 101 (4) + CHM 201 (2)
        assert totals["total"] == 5 + 4      # BIO 101 (5) + CHM 201 (4)

    def test_single_course(self):
        sc = _make_scorecard("1", "Single", "Assured", 55.0, ai_policy_in_syllabus=True,
                             ai_declared_assessments={"count": 3, "total": 3})
        result = assurance_batch.aggregate_scorecards([sc])
        stats = result["cohort_stats"]
        assert stats["total_courses"] == 1
        assert stats["certifying_on_nothing"] == 0
        assert stats["mean_coverage_pct"] == 55.0
        assert stats["median_coverage_pct"] == 55.0
        assert stats["ai_policy_syllabus_count"] == 1


# ---------------------------------------------------------------------------
# render_batch_report tests
# ---------------------------------------------------------------------------

class TestRenderBatchReport:
    def _run(self, scorecards=None, generated_at="20260615-120000"):
        if scorecards is None:
            scorecards = [
                _make_scorecard("1", "Assured Course", "Assured", 55.0),
                _make_scorecard("2", "Counterfeit Course", "Counterfeit", 0.0),
            ]
        agg = assurance_batch.aggregate_scorecards(scorecards)
        return assurance_batch.render_batch_report(agg, generated_at=generated_at)

    def test_returns_string(self):
        html = self._run()
        assert isinstance(html, str)

    def test_html_structure(self):
        html = self._run()
        assert "<html" in html.lower() or "<!doctype" in html.lower() or "<div" in html

    def test_generated_at_injected(self):
        stamp = "20260615-143022"
        html = self._run(generated_at=stamp)
        assert stamp in html

    def test_attention_section_present(self):
        html = self._run()
        assert "Certifying on Nothing" in html or "Attention" in html

    def test_counterfeit_course_appears_in_attention(self):
        html = self._run()
        assert "Counterfeit Course" in html

    def test_assured_course_in_ranked_table(self):
        html = self._run()
        assert "Assured Course" in html

    def test_no_attention_section_shows_none_when_clean(self):
        scorecards = [_make_scorecard("1", "Clean", "Assured", 60.0)]
        html = self._run(scorecards=scorecards)
        assert "NONE" in html or "No Counterfeit" in html or "no Counterfeit" in html

    def test_no_internal_clock_call(self):
        """render_batch_report must not import or call datetime/time internally."""
        import inspect
        src = inspect.getsource(assurance_batch.render_batch_report)
        assert "datetime.now" not in src
        assert "time.time" not in src

    def test_empty_cohort(self):
        html = self._run(scorecards=[])
        assert isinstance(html, str)

    def test_all_four_tiers_in_html(self):
        scorecards = [
            _make_scorecard("1", "A", "Assured", 50.0),
            _make_scorecard("2", "B", "Partially Assured", 25.0),
            _make_scorecard("3", "C", "Unassured", 0.0),
            _make_scorecard("4", "D", "Counterfeit", 0.0),
        ]
        html = self._run(scorecards=scorecards)
        for tier in ["Assured", "Partially Assured", "Unassured", "Counterfeit"]:
            assert tier in html, f"Tier '{tier}' missing from HTML output"

    # -- Criterion 11: cohort table shows secured/unsecured counts, no coverage % --

    def test_secured_unsecured_columns_replace_coverage_column(self):
        scorecards = [_make_scorecard("1", "A", "Assured", 90.0, security_ledger=_ledger(2, 1))]
        html = self._run(scorecards=scorecards)
        assert ">SECURED<" in html
        assert ">UNSECURED<" in html
        assert ">COVERAGE<" not in html

    def test_ranked_and_attention_rows_show_no_coverage_percentage(self):
        # Both tables previously rendered a "{cov:.1f}%" cell per course, e.g.
        # "55.0%". That exact shape must no longer appear anywhere in the
        # cohort report; the mean/median cohort-wide stat cards below are a
        # separate, still-allowed aggregate and are excluded from this check.
        scorecards = [
            _make_scorecard("1", "Assured Course", "Assured", 55.0, security_ledger=_ledger(3, 0)),
            _make_scorecard("2", "Counterfeit Course", "Counterfeit", 0.0, security_ledger=_ledger(0, 2)),
        ]
        html = self._run(scorecards=scorecards)
        ranking_section = html[html.index("Full Cohort Ranking"):html.index("Cohort Statistics")]
        assert not re.search(r"\d+\.\d%", ranking_section)

    def test_ranked_row_shows_ledger_derived_secured_unsecured_counts(self):
        scorecards = [_make_scorecard("1", "A", "Assured", 90.0, security_ledger=_ledger(2, 1))]
        html = self._run(scorecards=scorecards)
        ranking_section = html[html.index("Full Cohort Ranking"):html.index("Cohort Statistics")]
        assert ">2<" in ranking_section
        assert ">1<" in ranking_section


# ---------------------------------------------------------------------------
# Determinism firewall tests
# ---------------------------------------------------------------------------

class TestDeterminismFirewall:
    def test_needs_ai_review_never_accessed(self):
        """Batch runner must not read needs_ai_review from any scorecard in
        actual code (docstring mentions are acceptable documentation)."""
        import inspect
        src = inspect.getsource(assurance_batch.aggregate_scorecards)
        code_only = _code_lines(src)
        # The field must not appear in executable code paths
        assert "needs_ai_review" not in code_only, (
            "aggregate_scorecards accesses needs_ai_review in executable code -- "
            "violates determinism firewall"
        )

    def test_ranking_ignores_needs_ai_review_field(self):
        """Modifying needs_ai_review must not change ranking."""
        sc1 = _make_scorecard("1", "A", "Assured", 50.0)
        sc2 = _make_scorecard("2", "B", "Assured", 60.0)
        # Add fake AI annotation items to sc1 to simulate a richer scorecard
        sc1["needs_ai_review"] = [{"judgment_type": "authenticity_5df", "score": 5}] * 100
        sc2["needs_ai_review"] = []
        result = assurance_batch.aggregate_scorecards([sc1, sc2])
        # sc2 should still rank first (higher coverage = 60%)
        assert result["ranked"][0]["course"]["id"] == "2"

    def test_ranking_uses_only_deterministic_fields(self):
        """Two scorecards differing ONLY in needs_ai_review and AI annotation
        fields but identical in tier + coverage must have stable sort order."""
        sc_a = _make_scorecard("x", "X", "Partially Assured", 30.0)
        sc_b = _make_scorecard("y", "Y", "Partially Assured", 30.0)
        sc_a["needs_ai_review"] = [{"aias_level": 5}]
        sc_b["needs_ai_review"] = []
        result_ab = assurance_batch.aggregate_scorecards([sc_a, sc_b])
        result_ba = assurance_batch.aggregate_scorecards([sc_b, sc_a])
        # Same tier + same coverage: either order is valid but must be consistent
        ids_ab = [sc["course"]["id"] for sc in result_ab["ranked"]]
        ids_ba = [sc["course"]["id"] for sc in result_ba["ranked"]]
        # Regardless of input order, the relative output order must be the same
        assert set(ids_ab) == set(ids_ba) == {"x", "y"}

    def test_cohort_stats_ignores_ai_annotation_fields(self):
        """cohort_stats computation must not access dimensions.A or needs_ai_review."""
        src_stats_section = Path(_scripts_dir / "assurance_batch.py").read_text(encoding="utf-8")
        # aggregate_scorecards only reads tier, assurance_coverage_pct,
        # ai_policy_in_syllabus, ai_declared_assessments, has_graded_assessments
        # from each scorecard -- verify it does not pull from dimensions.A
        import inspect
        src = inspect.getsource(assurance_batch.aggregate_scorecards)
        assert '"A"' not in src or "dimensions" not in src.split('"A"')[0].split("\n")[-1], (
            "aggregate_scorecards appears to read dimensions.A -- AI annotation must not "
            "feed cohort stats"
        )
