"""Tests for assurance_audit.py -- Components 3, 4, and 5.

Covers:
- Lane heuristics (proctored -> lane1, take-home essay -> lane2, high-weight no-security -> mismatch)
- Assurance Coverage math + tier enum bands (each tier reachable; Counterfeit on mismatch)
- Bloom distribution + surface flags; counterfeit deterministic for quiz-claiming-Analyze+
- Free-response claiming high Bloom is packaged as needs_ai_review, not auto-flagged
- AIAS presence detection (policy text present -> declared True; absent -> Undeclared + transparency flag)
- Top-K cap honored; each needs_ai_review item carries id/points/weight/question/prompt_version/content_hash
- Determinism firewall: tier + assurance_coverage_pct derive only from deterministic fields
- JSON scorecard schema golden test
- No criterion_evaluator import
- No imscc/zip handling
"""

import hashlib
import importlib
import os
import sys
import types
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))


# ---------------------------------------------------------------------------
# Module import safety guards (must check BEFORE importing the module under test)
# ---------------------------------------------------------------------------

def test_no_criterion_evaluator_import():
    """assurance_audit must not import criterion_evaluator (architectural constraint)."""
    import assurance_audit
    # Check that criterion_evaluator is not in the actual import list
    # (it may appear in docstring comments referencing the pattern -- that is fine)
    import ast, inspect
    source = inspect.getsource(assurance_audit)
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            if isinstance(node, ast.Import):
                names = [alias.name for alias in node.names]
            else:
                names = [node.module or ""]
            for name in names:
                assert "criterion_evaluator" not in (name or ""), (
                    f"assurance_audit.py must not import criterion_evaluator; found: {name}"
                )


def test_no_imscc_or_zip_handling():
    """assurance_audit must not contain any functional imscc or zipfile imports."""
    import assurance_audit
    import ast, inspect
    source = inspect.getsource(assurance_audit)
    tree = ast.parse(source)
    for node in ast.walk(tree):
        if isinstance(node, (ast.Import, ast.ImportFrom)):
            if isinstance(node, ast.Import):
                names = [alias.name for alias in node.names]
            else:
                names = [node.module or ""]
            for name in names:
                assert "zipfile" not in (name or ""), (
                    f"assurance_audit.py must not import zipfile; found: {name}"
                )
                assert "imscc" not in (name or "").lower(), (
                    f"assurance_audit.py must not import imscc; found: {name}"
                )


# ---------------------------------------------------------------------------
# Fixture helpers
# ---------------------------------------------------------------------------

def _mock_config(course_id="99999"):
    return {
        "token": "fake",
        "domain": "canvas.test.edu",
        "course_id": str(course_id),
        "base_url": "https://canvas.test.edu/api/v1",
        "course_url": f"https://canvas.test.edu/api/v1/courses/{course_id}",
        "headers": {"Authorization": "Bearer fake"},
        "course_name": "Test Course",
        "course": {"tier": "advanced"},
    }


def _make_record(
    id=1,
    title="Assignment",
    canvas_type="assignment",
    engine="assignment",
    points=100.0,
    grade_weight_pct=10.0,
    submission_types=None,
    has_rubric=False,
    peer_reviews=False,
    omit_from_final_grade=False,
    published=True,
    module=None,
    group="Assignments",
    is_external_tool=False,
    proctoring_signal=False,
):
    return {
        "id": id,
        "title": title,
        "canvas_type": canvas_type,
        "engine": engine,
        "points": points,
        "grade_weight_pct": grade_weight_pct,
        "submission_types": submission_types or ["online_upload"],
        "has_rubric": has_rubric,
        "peer_reviews": peer_reviews,
        "omit_from_final_grade": omit_from_final_grade,
        "published": published,
        "module": module,
        "group": group,
        "is_external_tool": is_external_tool,
        "proctoring_signal": proctoring_signal,
    }


def _make_inventory(records, weighting_mode="points_only", assumptions=None):
    total_weight = sum(r["grade_weight_pct"] for r in records)
    return {
        "assessments": records,
        "course_weighting_mode": weighting_mode,
        "totals": {
            "counted_points": sum(r["points"] for r in records if r["published"] and not r["omit_from_final_grade"]),
            "grade_weight_pct_sum": round(total_weight, 4),
        },
        "assumptions": assumptions or ["per_course_api_calls=5", "total_records=" + str(len(records))],
    }


def _call_score(records, syllabus_text="", ai_securing=None, **kwargs):
    """Helper: call score_course with a mocked inventory and no live Canvas calls."""
    import assurance_audit

    cfg = _mock_config()
    inventory = _make_inventory(records)

    with patch.object(assurance_audit, "_fetch_syllabus_text", return_value=syllabus_text):
        with patch("assurance_audit.canvas_api") as mock_api:
            mock_api.require_course_id = MagicMock()
            scorecard = assurance_audit.score_course(cfg, inventory=inventory, ai_securing=ai_securing)

    return scorecard


# ---------------------------------------------------------------------------
# Lane classification tests
# ---------------------------------------------------------------------------

class TestLaneClassification:
    """Tests for _classify_lane deterministic-only fallback (no ai_securing).

    Deterministic-only fallback: Lane 1 is ONLY assigned when
    proctoring_signal is True. peer_reviews is recorded as a fact but never
    auto-secures. Oral/process-trail keywords produce Lane 2 + a lane_review
    advisory item -- they do NOT auto-promote to Lane 1 in this path. See
    TestAiSecuring below for the AI-judged authorship-evidence path.
    """

    def test_proctored_assignment_is_lane1(self):
        from assurance_audit import _classify_lane
        rec = _make_record(title="Midterm", proctoring_signal=True, submission_types=["external_tool"])
        assert _classify_lane(rec) == "lane1"

    def test_oral_keyword_is_lane2_not_lane1(self):
        """Criterion A regression: oral keyword alone must NOT produce lane1.

        Before fix: 'oral' in title returned 'lane1' deterministically.
        After fix: returns 'lane2'; score_course emits a lane_review advisory item.
        """
        from assurance_audit import _classify_lane
        rec = _make_record(title="Final Oral Defense", proctoring_signal=False, submission_types=["online_upload"])
        assert _classify_lane(rec) == "lane2"

    def test_presentation_keyword_is_lane2_not_lane1(self):
        """Criterion A regression: 'presentation' alone must NOT produce lane1.

        Before fix: 'presentation' in title returned 'lane1'.
        After fix: returns 'lane2'; score_course emits a lane_review advisory item.
        """
        from assurance_audit import _classify_lane
        rec = _make_record(title="Client Presentation", proctoring_signal=False, submission_types=["online_upload"])
        assert _classify_lane(rec) == "lane2"

    def test_peer_review_alone_is_lane2_not_lane1(self):
        """peer_reviews=True never auto-secures; peer review does not verify
        authorship. It is recorded as a fact only (the AI pass may weigh it),
        never a deterministic Lane 1 promotion."""
        from assurance_audit import _classify_lane
        rec = _make_record(title="Peer Reviewed Paper", peer_reviews=True,
                            proctoring_signal=False, submission_types=["online_upload"])
        assert _classify_lane(rec) == "lane2"

    def test_process_trail_journal_is_lane2_not_lane1(self):
        """Criterion A: 'journal' process-trail keyword alone does not produce lane1."""
        from assurance_audit import _classify_lane
        rec = _make_record(title="Reflective Journal", proctoring_signal=False)
        assert _classify_lane(rec) == "lane2"

    def test_portfolio_keyword_is_lane2_not_lane1(self):
        """Criterion A: 'portfolio' keyword alone does not produce lane1."""
        from assurance_audit import _classify_lane
        rec = _make_record(title="E-Portfolio Submission", proctoring_signal=False, submission_types=["online_upload"])
        assert _classify_lane(rec) == "lane2"

    def test_take_home_essay_is_lane2(self):
        from assurance_audit import _classify_lane
        rec = _make_record(
            title="Take Home Essay",
            proctoring_signal=False,
            peer_reviews=False,
            grade_weight_pct=10.0,  # below mismatch threshold
            submission_types=["online_upload"],
        )
        assert _classify_lane(rec) == "lane2"

    def test_unproctored_quiz_is_lane2(self):
        from assurance_audit import _classify_lane
        rec = _make_record(
            title="Module 2 Quiz",
            canvas_type="quiz",
            engine="classic_quiz",
            proctoring_signal=False,
            peer_reviews=False,
            grade_weight_pct=10.0,
            submission_types=["online_quiz"],
        )
        assert _classify_lane(rec) == "lane2"

    def test_high_weight_no_security_is_mismatch(self):
        from assurance_audit import _classify_lane, HIGH_WEIGHT_THRESHOLD
        rec = _make_record(
            title="Final Paper Upload",
            proctoring_signal=False,
            peer_reviews=False,
            grade_weight_pct=HIGH_WEIGHT_THRESHOLD + 1.0,  # 21% -- above threshold
            submission_types=["online_upload"],
        )
        assert _classify_lane(rec) == "mismatch"

    def test_high_weight_oral_keyword_is_lane2_not_mismatch(self):
        """Criterion A: high-weight item with oral keyword is lane2 (not mismatch,
        not lane1). It gets a lane_review item so AI can confirm the lane.
        """
        from assurance_audit import _classify_lane
        rec = _make_record(
            title="Final Oral Defense",
            proctoring_signal=False,
            peer_reviews=False,
            grade_weight_pct=50.0,
            submission_types=["online_upload"],
        )
        # Must be lane2 (keyword-nominated candidate), NOT mismatch, NOT lane1.
        assert _classify_lane(rec) == "lane2"

    def test_low_weight_no_security_no_keywords_unclassified(self):
        from assurance_audit import _classify_lane
        rec = _make_record(
            title="Extra Credit Task",
            proctoring_signal=False,
            peer_reviews=False,
            grade_weight_pct=2.0,
            submission_types=["none"],
        )
        result = _classify_lane(rec)
        # Could be lane2 (external_tool) or unclassified; must not be lane1 or mismatch
        assert result not in ("lane1", "mismatch")

    def test_behavioral_not_matched_as_oral(self):
        """Criterion A regression: 'behavioral' must NOT match the 'oral' keyword.

        Before fix: substring 'oral' inside 'behavioral' triggered lane1.
        After fix: word-boundary matching rejects 'oral' inside 'behavioral'.
        """
        from assurance_audit import _classify_lane
        rec = _make_record(
            title="Discussion: Behavioral Communication Styles",
            proctoring_signal=False,
            peer_reviews=False,
            grade_weight_pct=1.71,
            submission_types=["discussion_topic"],
        )
        rec["content_excerpt"] = "Share your perspective on behavioral patterns in communication."
        # 'behavioral' does not word-boundary-match 'oral', so no keyword hit.
        # Item is below mismatch threshold at 1.71% → unclassified (not lane1).
        result = _classify_lane(rec)
        assert result != "lane1", (
            "'behavioral' must not word-boundary-match 'oral'; must not auto-promote to lane1"
        )

    def test_physical_presentation_not_matched_as_presentation(self):
        """Criterion A regression: 'physical presentation' context must not auto-secure.

        'presentation' as a whole word does match (it IS in the title), but that
        should produce lane2 + lane_review, NOT lane1. This test confirms the
        keyword still triggers the advisory path but not auto-promotion.
        """
        from assurance_audit import _classify_lane
        rec = _make_record(
            title="Physical Presentation of Communication Norms",
            proctoring_signal=False,
            peer_reviews=False,
            grade_weight_pct=1.71,
            submission_types=["discussion_topic"],
        )
        # 'presentation' IS a word-boundary match here -- but criterion A says that
        # still produces lane2 (advisory), not lane1 (secured).
        assert _classify_lane(rec) == "lane2"


# ---------------------------------------------------------------------------
# Assurance Coverage + Tier band tests
# ---------------------------------------------------------------------------

class TestAssuranceCoverage:
    """Tests for _compute_assurance_coverage and tier enum bands."""

    def test_assured_tier_with_enough_lane1_weight(self):
        from assurance_audit import _compute_assurance_coverage, _classify_lane, ASSURED_COVERAGE_THRESHOLD
        # Deterministic-only fallback: only proctoring_signal=True produces lane1.
        # Use two items with confirmed structural metadata.
        records = [
            _make_record(id=1, title="Midterm Exam", grade_weight_pct=50.0, proctoring_signal=True,
                         submission_types=["external_tool"]),
            _make_record(id=2, title="Research Paper", grade_weight_pct=50.0,
                         submission_types=["online_upload"], proctoring_signal=True),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_assurance_coverage(records, lanes)
        assert result["tier"] == "Assured"
        assert result["coverage_pct"] >= ASSURED_COVERAGE_THRESHOLD
        assert result["anchor_count"] >= 1

    def test_partially_assured_tier(self):
        from assurance_audit import _compute_assurance_coverage, _classify_lane
        # Post criterion-A: use proctoring_signal=True at 20% for the lone anchor.
        # 20% < 40% threshold -> Partially Assured.
        records = [
            _make_record(id=1, title="Midterm Exam", grade_weight_pct=20.0,
                         proctoring_signal=True, submission_types=["external_tool"]),
            _make_record(id=2, title="Take Home Essay", grade_weight_pct=10.0, submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz A", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=4, title="Module Quiz B", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=5, title="Module Quiz C", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=6, title="Module Quiz D", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=7, title="Module Quiz E", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=8, title="Module Quiz F", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=9, title="Module Quiz G", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_assurance_coverage(records, lanes)
        assert result["tier"] == "Partially Assured"
        assert 0.0 < result["coverage_pct"] < 40.0

    def test_unassured_tier_no_lane1(self):
        from assurance_audit import _compute_assurance_coverage, _classify_lane
        records = [
            _make_record(id=1, title="Module 1 Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=15.0, submission_types=["online_quiz"]),
            _make_record(id=2, title="Take Home Essay", grade_weight_pct=15.0, submission_types=["online_upload"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_assurance_coverage(records, lanes)
        assert result["tier"] == "Unassured"
        assert result["coverage_pct"] == 0.0
        assert result["anchor_count"] == 0

    def test_mismatch_is_flag_not_tier_demotion(self):
        # (#219) mismatch no longer forces tier=Counterfeit; tier is coverage-
        # derived only. Zero secured anchors here -> Unassured, with
        # has_mismatch=True surfaced as a flag alongside it.
        from assurance_audit import _compute_assurance_coverage, _classify_lane, HIGH_WEIGHT_THRESHOLD
        records = [
            # This high-weight item with no security property should be 'mismatch'
            _make_record(
                id=1, title="Final Paper Upload",
                grade_weight_pct=HIGH_WEIGHT_THRESHOLD + 5.0,
                proctoring_signal=False, peer_reviews=False,
                submission_types=["online_upload"],
            ),
            _make_record(id=2, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=5.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_assurance_coverage(records, lanes)
        assert result["tier"] == "Unassured"
        assert result["has_mismatch"] is True
        assert len(result["mismatch_items"]) >= 1

    def test_mismatch_flag_does_not_demote_high_coverage_course(self):
        # (#219) A course with a 60%-weight proctored anchor plus one unrelated
        # mismatch item must still land in the Assured tier (coverage-derived),
        # not be dragged down by the mismatch item.
        from assurance_audit import _compute_assurance_coverage, _classify_lane, HIGH_WEIGHT_THRESHOLD
        records = [
            _make_record(id=1, title="Proctored Final", grade_weight_pct=60.0,
                         proctoring_signal=True, submission_types=["external_tool"]),
            _make_record(id=2, title="Unsecured Paper", grade_weight_pct=HIGH_WEIGHT_THRESHOLD + 5.0,
                         proctoring_signal=False, peer_reviews=False,
                         submission_types=["online_upload"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_assurance_coverage(records, lanes)
        assert result["tier"] == "Assured"
        assert result["has_mismatch"] is True

    def test_coverage_math_sums_lane1_weights(self):
        from assurance_audit import _compute_assurance_coverage, _classify_lane
        # Deterministic-only fallback: use proctored items as Lane 1 anchors.
        records = [
            _make_record(id=1, title="Midterm Exam", grade_weight_pct=25.0,
                         proctoring_signal=True, submission_types=["external_tool"]),
            _make_record(id=2, title="Proctored Paper Defense", grade_weight_pct=20.0,
                         proctoring_signal=True, submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz A", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=4, title="Module Quiz B", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=5, title="Module Quiz C", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=6, title="Module Quiz D", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=7, title="Module Quiz E", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=8, title="Module Quiz F", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=5.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_assurance_coverage(records, lanes)
        # Proctored item (25%) + proctored item (20%) = 45% -> Assured
        assert result["tier"] == "Assured"
        assert abs(result["coverage_pct"] - 45.0) < 0.1

    def test_three_tiers_reachable_counterfeit_deprecated(self):
        """(#219) Tier is coverage-derived only now: Assured/Partially Assured/
        Unassured are reachable from _compute_assurance_coverage; Counterfeit is
        deprecated-not-assigned (the constant is retained for historical
        scorecards but no live computation produces it)."""
        from assurance_audit import AssuranceTier, _compute_assurance_coverage, _classify_lane
        # The enum constant still exists with all four values for schema compat.
        assert set(AssuranceTier.all_values()) == {
            "Assured", "Partially Assured", "Unassured", "Counterfeit"
        }
        # But even a mismatch-heavy, zero-anchor course never comes back tagged
        # Counterfeit from the live deterministic path.
        from assurance_audit import HIGH_WEIGHT_THRESHOLD
        records = [
            _make_record(id=1, title="Unsecured Final", grade_weight_pct=HIGH_WEIGHT_THRESHOLD + 10.0,
                         proctoring_signal=False, peer_reviews=False,
                         submission_types=["online_upload"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_assurance_coverage(records, lanes)
        assert result["tier"] != "Counterfeit"


# ---------------------------------------------------------------------------
# AI-led securing tests (docs/plan-assurance-ai-led.md)
# ---------------------------------------------------------------------------

class TestAiSecuring:
    """Tests for the AI-judged securing path: _classify_lane(record, ai_securing_entry)
    and score_course(..., ai_securing=...).

    Secured = a real basis to trust the student did the work: identity control
    (proctoring, deterministic) OR authorship evidence (AI-judged from content,
    cited). peer_reviews is a recorded fact only and never auto-secures.
    """

    def test_deterministic_only_proctoring_secures_peer_does_not(self):
        """No ai_securing supplied: proctoring_signal=True is lane1; peer_reviews=True
        alone stays lane2 (the conservative deterministic-only floor)."""
        from assurance_audit import _classify_lane
        proctored = _make_record(id=1, title="Proctored Final", proctoring_signal=True,
                                  submission_types=["external_tool"])
        peer_only = _make_record(id=2, title="Peer Reviewed Paper", peer_reviews=True,
                                  proctoring_signal=False, submission_types=["online_upload"])
        assert _classify_lane(proctored) == "lane1"
        assert _classify_lane(peer_only) == "lane2"

    def test_ai_securing_process_entry_promotes_to_lane1(self):
        """ai_securing_entry with secured=True (kind='process') promotes an
        otherwise-unsecured take-home item to lane1."""
        from assurance_audit import _classify_lane
        rec = _make_record(id=1, title="Take Home Essay", proctoring_signal=False,
                            submission_types=["online_upload"])
        secured_entry = {
            "secured": True,
            "kind": "process",
            "evidence": "Milestoned drafts with a decision log across three checkpoints.",
        }
        assert _classify_lane(rec, secured_entry) == "lane1"

    def test_ai_securing_unsecured_entry_stays_lane2(self):
        """ai_securing_entry with secured=False is authoritative; the item stays
        lane2 even if it carries a lane1_candidate keyword."""
        from assurance_audit import _classify_lane
        rec = _make_record(id=1, title="Final Oral Defense", proctoring_signal=False,
                            submission_types=["online_upload"])
        unsecured_entry = {
            "secured": False,
            "kind": "unsecured",
            "evidence": "A bare take-home upload with no process trail or identity control.",
        }
        assert _classify_lane(rec, unsecured_entry) == "lane2"

    def test_score_course_ai_securing_process_item_counts_toward_coverage(self):
        """score_course(..., ai_securing=...): a process-evidence item secured=True
        counts toward assurance_coverage_pct and can move the tier, while an
        unsecured item (no ai_securing entry, no proctoring) does not."""
        records = [
            _make_record(id=1, title="Milestoned Research Paper", grade_weight_pct=50.0,
                          proctoring_signal=False, submission_types=["online_upload"]),
            _make_record(id=2, title="Bare Take Home Essay", grade_weight_pct=50.0,
                          proctoring_signal=False, submission_types=["online_upload"]),
        ]
        ai_securing = {
            1: {
                "secured": True,
                "kind": "process",
                "evidence": "Drafts + revision history + a graded reflection memo.",
            },
            # id=2 has no ai_securing entry -> stays unsecured.
        }
        # Without ai_securing: both items lane2 -> Unassured, 0% coverage.
        sc_without = _call_score(records)
        assert sc_without["tier"] == "Unassured"
        assert sc_without["assurance_coverage_pct"] == 0.0

        # With ai_securing: item 1 secures -> 50% coverage -> Assured.
        sc_with = _call_score(records, ai_securing=ai_securing)
        assert sc_with["tier"] == "Assured"
        assert sc_with["assurance_coverage_pct"] == pytest.approx(50.0, abs=0.1)
        dim_d = sc_with["dimensions"]["D"]
        assert dim_d["anchor_count"] == 1
        assert dim_d["anchor_items"][0]["id"] == 1

    def test_score_course_ai_securing_evidence_field_carried_through(self):
        """The AI-cited evidence string for a secured anchor is carried through
        to the scorecard's anchor_items, along with its securing kind."""
        records = [
            _make_record(id=1, title="Process-Trail Portfolio", grade_weight_pct=60.0,
                          proctoring_signal=False, submission_types=["online_upload"]),
        ]
        ai_securing = {
            1: {
                "secured": True,
                "kind": "process",
                "evidence": "Weekly decision log entries citing specific design choices.",
            },
        }
        sc = _call_score(records, ai_securing=ai_securing)
        anchor = sc["dimensions"]["D"]["anchor_items"][0]
        assert anchor["id"] == 1
        assert anchor.get("securing_kind") == "process"
        assert anchor.get("securing_evidence") == (
            "Weekly decision log entries citing specific design choices."
        )

    def test_string_keyed_ai_securing_still_applies(self):
        """ai_securing survives JSON round-trips that stringify keys. String keys
        must be coerced to int, else every lookup silently misses and the item
        falls to the deterministic floor (a silent tier misclassification)."""
        records = [
            _make_record(id=1, title="Milestoned Paper", grade_weight_pct=50.0,
                          proctoring_signal=False, submission_types=["online_upload"]),
            _make_record(id=2, title="Bare Essay", grade_weight_pct=50.0,
                          proctoring_signal=False, submission_types=["online_upload"]),
        ]
        ai_securing_str = {"1": {"secured": True, "kind": "process", "evidence": "Drafts."}}
        sc = _call_score(records, ai_securing=ai_securing_str)
        assert sc["tier"] == "Assured"
        assert sc["assurance_coverage_pct"] == pytest.approx(50.0, abs=0.1)

    def test_malformed_ai_securing_entry_does_not_crash(self):
        """A non-dict ai_securing value must degrade to unsecured, not raise."""
        from assurance_audit import _classify_lane
        rec = _make_record(id=1, title="Essay", proctoring_signal=False,
                            submission_types=["online_upload"])
        assert _classify_lane(rec, "garbage") == "lane2"      # non-dict -> not lane1
        assert _classify_lane(rec, {"kind": "process"}) == "lane2"  # missing "secured"
        assert _classify_lane(rec, None) == "lane2"


# ---------------------------------------------------------------------------
# Bloom distribution + cognitive demand tests
# ---------------------------------------------------------------------------

class TestCognitiveDemand:
    """Tests for _compute_cognitive_demand."""

    def test_bloom_distribution_weighted_by_grade_weight(self):
        from assurance_audit import _compute_cognitive_demand
        records = [
            _make_record(id=1, title="Analyze Case Study", grade_weight_pct=60.0),
            _make_record(id=2, title="Recall Key Terms Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=40.0, submission_types=["online_quiz"]),
        ]
        result = _compute_cognitive_demand(records)
        dist = result["weighted_distribution"]
        # "Analyze" should have weight from item 1; "Remember" from item 2
        assert dist.get("Analyze", 0.0) == pytest.approx(60.0, abs=1.0)
        assert dist.get("Remember", 0.0) == pytest.approx(40.0, abs=1.0)

    def test_bloom_arc_increasing(self):
        from assurance_audit import _compute_cognitive_demand
        records = [
            _make_record(id=1, title="Evaluate Evidence Essay", grade_weight_pct=70.0, submission_types=["online_upload"]),
            _make_record(id=2, title="List Definitions Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=30.0, submission_types=["online_quiz"]),
        ]
        result = _compute_cognitive_demand(records)
        assert result["arc"] == "increasing"

    def test_bloom_arc_decreasing(self):
        from assurance_audit import _compute_cognitive_demand
        records = [
            _make_record(id=1, title="List Definitions Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=70.0, submission_types=["online_quiz"]),
            _make_record(id=2, title="Evaluate Argument Essay", grade_weight_pct=30.0),
        ]
        result = _compute_cognitive_demand(records)
        assert result["arc"] == "decreasing"

    def test_surface_flag_above_50pct(self):
        from assurance_audit import _compute_cognitive_demand
        # Titles with no recognizable Bloom verb -> Unknown
        records = [
            _make_record(id=1, title="Participation", grade_weight_pct=60.0),
            _make_record(id=2, title="Portfolio", grade_weight_pct=40.0),
        ]
        result = _compute_cognitive_demand(records)
        assert result["surface_above_50pct_flag"] is True

    def test_surface_flag_above_70pct(self):
        from assurance_audit import _compute_cognitive_demand
        records = [
            _make_record(id=1, title="Unknown Task A", grade_weight_pct=80.0),
            _make_record(id=2, title="Analyze Case", grade_weight_pct=20.0),
        ]
        result = _compute_cognitive_demand(records)
        assert result["surface_above_70pct_flag"] is True

    def test_counterfeit_objective_quiz_claiming_analyze(self):
        from assurance_audit import _compute_cognitive_demand
        records = [
            _make_record(
                id=1, title="Analyze Circuit Quiz",
                canvas_type="quiz", engine="classic_quiz",
                grade_weight_pct=30.0, submission_types=["online_quiz"],
            ),
        ]
        result = _compute_cognitive_demand(records)
        assert len(result["counterfeit_objective"]) == 1
        entry = result["counterfeit_objective"][0]
        assert entry["id"] == 1
        assert entry["level"] == "Analyze"

    def test_counterfeit_only_quiz_not_essay(self):
        """Free-response essay claiming Analyze must NOT be in counterfeit_objective.

        It is packaged in needs_ai_review with judgment_type=counterfeit_freeresponse instead.
        """
        from assurance_audit import _compute_cognitive_demand
        records = [
            _make_record(
                id=2, title="Analyze the Argument Essay",
                canvas_type="assignment", engine="assignment",
                grade_weight_pct=40.0, submission_types=["online_upload"],
            ),
        ]
        result = _compute_cognitive_demand(records)
        # Should NOT be in counterfeit_objective (only objective quiz types trigger this)
        assert len(result["counterfeit_objective"]) == 0

    def test_remember_level_quiz_not_counterfeit(self):
        from assurance_audit import _compute_cognitive_demand
        records = [
            _make_record(
                id=1, title="Identify Key Terms Quiz",
                canvas_type="quiz", engine="classic_quiz",
                grade_weight_pct=20.0, submission_types=["online_quiz"],
            ),
        ]
        result = _compute_cognitive_demand(records)
        # "Identify" -> Remember (level 1), below the Analyze threshold
        assert len(result["counterfeit_objective"]) == 0


# ---------------------------------------------------------------------------
# AIAS presence detection tests
# ---------------------------------------------------------------------------

class TestAIASPresence:
    """Tests for _detect_aias_presence."""

    def test_aias_declared_when_policy_in_syllabus(self):
        from assurance_audit import _detect_aias_presence
        syllabus = "This course has an AI use policy. ChatGPT and other generative AI tools..."
        result = _detect_aias_presence(syllabus, [])
        assert result["declared_presence"] is True
        assert "syllabus" in result["found_in"]

    def test_aias_declared_when_policy_in_assignment(self):
        from assurance_audit import _detect_aias_presence
        result = _detect_aias_presence("", [("assignment:Final Paper", "AI-generated content is not permitted.")])
        assert result["declared_presence"] is True

    def test_aias_undeclared_when_absent(self):
        from assurance_audit import _detect_aias_presence
        result = _detect_aias_presence(
            "Welcome to this course. Please submit your work on time.",
            [("assignment:Quiz", "Complete all 10 questions.")],
        )
        assert result["declared_presence"] is False
        assert result["found_in"] == []

    def test_aias_transparency_flag_set_when_undeclared(self):
        """transparency_gap flag must be True when AIAS is absent."""
        records = [
            _make_record(id=1, title="Final Oral Defense", grade_weight_pct=50.0),
            _make_record(id=2, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=50.0, submission_types=["online_quiz"]),
        ]
        # Use a syllabus text with no recognizable policy keywords
        scorecard = _call_score(records, syllabus_text="Welcome to the course. Submit work on time.")
        assert scorecard["flags"]["transparency_gap"] is True

    def test_aias_transparency_flag_cleared_when_declared(self):
        records = [
            _make_record(id=1, title="Final Oral Defense", grade_weight_pct=50.0),
        ]
        scorecard = _call_score(records, syllabus_text="This course enforces an AI use policy regarding chatgpt.")
        assert scorecard["flags"]["transparency_gap"] is False

    def test_ai_policy_split_signals_replace_single_ratio(self):
        # (#219) A policy stated once in the syllabus, with no per-assignment
        # declarations, must show up as ai_policy_in_syllabus=True with an
        # honest (possibly 0/N) ai_declared_assessments count -- not blended
        # into a single misleading ratio (the old aias_transparency_pct field
        # is gone entirely).
        records = [
            _make_record(id=1, title="Oral Defense", grade_weight_pct=100.0),
        ]
        scorecard = _call_score(records, syllabus_text="AI use policy: chatgpt is not allowed.")
        assert "aias_transparency_pct" not in scorecard
        assert scorecard["ai_policy_in_syllabus"] is True
        declared = scorecard["ai_declared_assessments"]
        assert isinstance(declared, dict)
        assert declared["total"] >= 1
        assert declared["count"] == 0  # no policy keyword in the assignment title itself


# ---------------------------------------------------------------------------
# Top-K cap + AI annotation packaging tests
# ---------------------------------------------------------------------------

class TestTopKAndAIPackaging:
    """Tests for top-K selection and needs_ai_review item packaging."""

    def test_top_k_cap_covers_target_weight(self):
        from assurance_audit import _select_top_k, TOP_K_WEIGHT_TARGET
        records = [
            _make_record(id=i, title=f"Assignment {i}", grade_weight_pct=w)
            for i, w in enumerate([40.0, 25.0, 20.0, 10.0, 5.0], start=1)
        ]
        top_k, k, covered = _select_top_k(records)
        total = sum(r["grade_weight_pct"] for r in records)
        covered_fraction = sum(r["grade_weight_pct"] for r in top_k) / total
        assert covered_fraction >= TOP_K_WEIGHT_TARGET - 0.01  # allow tiny rounding

    def test_needs_ai_review_items_carry_required_fields(self):
        """Each needs_ai_review item must carry id/points/weight/question/prompt_version/content_hash."""
        records = [
            _make_record(id=1, title="Oral Defense Final", grade_weight_pct=60.0, points=100.0),
            _make_record(id=2, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=40.0, points=50.0, submission_types=["online_quiz"]),
        ]
        scorecard = _call_score(records)
        items = scorecard["needs_ai_review"]
        assert len(items) > 0
        for item in items:
            assert "id" in item, f"Missing 'id' in item: {item}"
            assert "name" in item, f"Missing 'name' in item: {item}"
            assert "points" in item, f"Missing 'points' in item: {item}"
            assert "grade_weight_pct" in item, f"Missing 'grade_weight_pct' in item: {item}"
            assert "question" in item, f"Missing 'question' in item: {item}"
            assert "prompt_version" in item, f"Missing 'prompt_version' in item: {item}"
            assert "content_hash" in item, f"Missing 'content_hash' in item: {item}"
            assert "label" in item, f"Missing 'label' in item: {item}"
            assert "judgment_type" in item, f"Missing 'judgment_type' in item: {item}"

    def test_content_hash_is_sha256(self):
        """content_hash must be sha256 hex of the question text."""
        records = [_make_record(id=1, title="Oral Presentation", grade_weight_pct=100.0)]
        scorecard = _call_score(records)
        items = [i for i in scorecard["needs_ai_review"] if i["id"] == 1]
        assert len(items) > 0
        item = items[0]
        expected = hashlib.sha256(item["question"].encode("utf-8")).hexdigest()
        assert item["content_hash"] == expected

    def test_needs_ai_review_label_is_within_course_diagnostic(self):
        """All AI items must be labeled 'within-course diagnostic, not cross-course comparable'."""
        records = [_make_record(id=1, title="Final Essay", grade_weight_pct=100.0, submission_types=["online_upload"])]
        scorecard = _call_score(records)
        for item in scorecard["needs_ai_review"]:
            assert "within-course diagnostic" in item["label"].lower(), (
                f"Unexpected label: {item['label']}"
            )

    def test_top_k_items_only_in_ai_review(self):
        """Top-K-bounded AI annotation must not include items outside top-K.

        The top-K cap governs the *authenticity/substitutability* annotation
        (_package_ai_items). The whole-course judgment types -- bloom_content
        (deep-pass cognitive demand, one per weighted assessment) and lane_review --
        are deliberately NOT top-K bounded, so they are excluded from this check.
        """
        records = [
            _make_record(id=1, title="Final Oral Defense", grade_weight_pct=65.0),
            _make_record(id=2, title="Research Paper", grade_weight_pct=20.0, submission_types=["online_upload"]),
            _make_record(id=3, title="Quiz A", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=5.0, submission_types=["online_quiz"]),
            _make_record(id=4, title="Quiz B", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=5.0, submission_types=["online_quiz"]),
            _make_record(id=5, title="Participation", grade_weight_pct=5.0, submission_types=["none"]),
        ]
        scorecard = _call_score(records)
        whole_course_types = {"bloom_content", "lane_review"}
        ai_ids = {
            item["id"]
            for item in scorecard["needs_ai_review"]
            if item.get("judgment_type") not in whole_course_types
        }
        # The top-K-bounded annotation should only cover the top-weight items (1, 2).
        non_top_ids = {3, 4, 5}
        assert not ai_ids.intersection(non_top_ids), (
            f"Items outside top-K appeared in top-K-bounded AI annotation: {ai_ids.intersection(non_top_ids)}"
        )

    def test_counterfeit_freeresponse_packaged_not_auto_flagged(self):
        """Free-response essay claiming Analyze must appear in needs_ai_review, not in counterfeit_objective."""
        records = [
            _make_record(
                id=1, title="Analyze the Policy Essay",
                canvas_type="assignment", engine="assignment",
                grade_weight_pct=80.0, submission_types=["online_upload"],
            ),
        ]
        scorecard = _call_score(records)
        # Must NOT be in counterfeit_objective
        assert len(scorecard["dimensions"]["C"]["counterfeit_objective"]) == 0
        # MUST appear in needs_ai_review with judgment_type=counterfeit_freeresponse
        cf_items = [i for i in scorecard["needs_ai_review"] if i["judgment_type"] == "counterfeit_freeresponse"]
        assert len(cf_items) >= 1
        assert cf_items[0]["id"] == 1

    def test_k_recorded_in_assumptions(self):
        records = [_make_record(id=i, grade_weight_pct=20.0) for i in range(1, 6)]
        scorecard = _call_score(records)
        assert "K" in scorecard["assumptions"]
        assert isinstance(scorecard["assumptions"]["K"], int)
        assert scorecard["assumptions"]["K"] >= 1

    def test_prompt_version_in_assumptions(self):
        from assurance_audit import PROMPT_VERSION
        records = [_make_record(id=1, grade_weight_pct=100.0)]
        scorecard = _call_score(records)
        assert scorecard["assumptions"]["prompt_version"] == PROMPT_VERSION


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

class TestDeterminismFirewall:
    """Assert that tier and assurance_coverage_pct do not depend on AI fields."""

    def test_tier_is_deterministic_not_from_ai_fields(self):
        """Modify needs_ai_review items and verify tier is unchanged."""
        # Deterministic-only fallback: use proctoring_signal for lane1
        records = [
            _make_record(id=1, title="Peer Review Assignment", grade_weight_pct=50.0, proctoring_signal=True),
            _make_record(id=2, title="Essay Paper", grade_weight_pct=50.0, submission_types=["online_upload"]),
        ]
        scorecard = _call_score(records)
        original_tier = scorecard["tier"]
        original_coverage = scorecard["assurance_coverage_pct"]

        # Mutate needs_ai_review items (simulating different AI judgment outcomes)
        for item in scorecard["needs_ai_review"]:
            item["ai_judgment_result"] = "COMPLETELY DIFFERENT"
            item["authenticity_score"] = 0.0  # worst possible

        # Re-score without AI influence -- the deterministic fields must not change
        # (They derive from the same inventory, not from needs_ai_review)
        scorecard2 = _call_score(records)
        assert scorecard2["tier"] == original_tier, (
            "Tier changed between identical deterministic runs -- something non-deterministic is feeding it"
        )
        assert scorecard2["assurance_coverage_pct"] == pytest.approx(original_coverage, abs=0.01)

    def test_tier_does_not_contain_ai_review_results(self):
        """The tier must derive only from: coverage_pct, anchor_count, mismatch presence."""
        import assurance_audit
        # Post criterion-A: oral keyword is lane2, essay is lane2 -> Unassured.
        records = [
            _make_record(id=1, title="Oral Presentation", grade_weight_pct=45.0,
                         submission_types=["online_upload"]),
            _make_record(id=2, title="Take Home Essay", grade_weight_pct=55.0, submission_types=["online_upload"]),
        ]
        cfg = _mock_config()
        inventory = _make_inventory(records)

        with patch.object(assurance_audit, "_fetch_syllabus_text", return_value=""):
            with patch("assurance_audit.canvas_api") as mock_api:
                mock_api.require_course_id = MagicMock()
                scorecard = assurance_audit.score_course(cfg, inventory=inventory)

        # tier must be one of the four named tiers
        assert scorecard["tier"] in assurance_audit.AssuranceTier.all_values()
        # assurance_coverage_pct must be a number, not derived from AI results
        assert isinstance(scorecard["assurance_coverage_pct"], float)


# ---------------------------------------------------------------------------
# Flags tests
# ---------------------------------------------------------------------------

class TestFlags:
    """Tests for boolean flags."""

    def test_no_anchor_flag_when_no_lane1(self):
        records = [
            _make_record(id=1, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=50.0, submission_types=["online_quiz"]),
            _make_record(id=2, title="Essay", grade_weight_pct=50.0, submission_types=["online_upload"]),
        ]
        scorecard = _call_score(records)
        assert scorecard["flags"]["no_anchor"] is True

    def test_no_anchor_flag_false_when_lane1_present(self):
        # Post criterion-A: use proctoring_signal=True for confirmed Lane 1
        records = [_make_record(id=1, title="Proctored Exam", grade_weight_pct=100.0,
                                proctoring_signal=True, submission_types=["external_tool"])]
        scorecard = _call_score(records)
        assert scorecard["flags"]["no_anchor"] is False

    def test_over_surveillance_flag_when_lane1_exceeds_80pct(self):
        # Post criterion-A: use proctored items for structurally-secured lane1
        records = [
            _make_record(id=1, title="Midterm Exam", grade_weight_pct=50.0,
                         proctoring_signal=True, submission_types=["external_tool"]),
            _make_record(id=2, title="Final Exam", grade_weight_pct=35.0,
                         proctoring_signal=True, submission_types=["external_tool"]),
            _make_record(id=3, title="Essay", grade_weight_pct=15.0, submission_types=["online_upload"]),
        ]
        scorecard = _call_score(records)
        # Lane1 = 85% > 80% threshold
        assert scorecard["flags"]["over_surveillance"] is True

    def test_over_surveillance_flag_false_at_low_lane1(self):
        # Deterministic-only fallback: oral-keyword item is lane2, not lane1; use proctoring_signal for lane1
        records = [
            _make_record(id=1, title="Peer Review Assignment", grade_weight_pct=40.0,
                         proctoring_signal=True, submission_types=["online_upload"]),
            _make_record(id=2, title="Essay", grade_weight_pct=60.0, submission_types=["online_upload"]),
        ]
        scorecard = _call_score(records)
        assert scorecard["flags"]["over_surveillance"] is False

    def test_counterfeit_assurance_flag_matches_has_mismatch(self):
        # (#219) counterfeit_assurance / counterfeit_claim now track has_mismatch
        # directly, not tier == Counterfeit (tier is coverage-derived only and
        # never becomes Counterfeit from a live computation).
        from assurance_audit import HIGH_WEIGHT_THRESHOLD
        records = [
            _make_record(
                id=1, title="Final Upload",
                grade_weight_pct=HIGH_WEIGHT_THRESHOLD + 5.0,
                submission_types=["online_upload"],
                proctoring_signal=False, peer_reviews=False,
            ),
        ]
        scorecard = _call_score(records)
        assert scorecard["tier"] != "Counterfeit"
        assert scorecard["flags"]["counterfeit_assurance"] is True
        assert scorecard["counterfeit_claim"] is True


# ---------------------------------------------------------------------------
# JSON scorecard golden schema test
# ---------------------------------------------------------------------------

class TestScorecardSchema:
    """Verify the stable golden-tested JSON schema contract."""

    # (#219) aias_transparency_pct removed; replaced by ai_policy_in_syllabus /
    # ai_declared_assessments. counterfeit_claim and has_graded_assessments added.
    REQUIRED_TOP_LEVEL = {
        "course", "tier", "assurance_coverage_pct",
        "ai_policy_in_syllabus", "ai_declared_assessments",
        "counterfeit_claim", "has_graded_assessments",
        "lane_balance", "dimensions", "flags", "durable_skills", "needs_ai_review",
        "assumptions",
    }

    REQUIRED_DIMENSIONS = {"A", "B", "C", "D", "E"}

    REQUIRED_FLAGS = {
        "counterfeit_assurance", "transparency_gap", "no_anchor", "over_surveillance"
    }

    REQUIRED_ASSUMPTIONS = {
        "K", "top_k_weight_cap_pct", "model_capability", "modality",
        "prompt_version", "deterministic_vs_ai", "inventory_assumptions",
    }

    REQUIRED_LANE_BALANCE = {"lane1", "lane2", "mismatch", "unclassified"}

    REQUIRED_COURSE = {"id", "name", "tier"}

    def _get_scorecard(self):
        # Post criterion-A: use proctored exam for confirmed Lane 1 anchor.
        records = [
            _make_record(id=1, title="Proctored Final Exam", grade_weight_pct=45.0, points=200.0,
                         proctoring_signal=True, submission_types=["external_tool"]),
            _make_record(id=2, title="Take Home Essay", grade_weight_pct=35.0, points=100.0,
                         submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=20.0, points=50.0, submission_types=["online_quiz"]),
        ]
        return _call_score(records, syllabus_text="AI use policy: no chatgpt allowed.")

    def test_top_level_keys_present(self):
        sc = self._get_scorecard()
        for key in self.REQUIRED_TOP_LEVEL:
            assert key in sc, f"Missing top-level key: {key}"

    def test_tier_is_valid_enum(self):
        from assurance_audit import AssuranceTier
        sc = self._get_scorecard()
        assert sc["tier"] in AssuranceTier.all_values()

    def test_assurance_coverage_pct_is_float(self):
        sc = self._get_scorecard()
        assert isinstance(sc["assurance_coverage_pct"], (int, float))

    def test_ai_policy_fields_have_expected_shapes(self):
        # (#219) replaces test_aias_transparency_pct_is_float.
        sc = self._get_scorecard()
        assert isinstance(sc["ai_policy_in_syllabus"], bool)
        assert isinstance(sc["ai_declared_assessments"], dict)
        assert {"count", "total"} <= set(sc["ai_declared_assessments"].keys())
        assert isinstance(sc["counterfeit_claim"], bool)
        assert isinstance(sc["has_graded_assessments"], bool)

    def test_lane_balance_keys(self):
        sc = self._get_scorecard()
        for key in self.REQUIRED_LANE_BALANCE:
            assert key in sc["lane_balance"], f"Missing lane_balance key: {key}"

    def test_dimensions_keys(self):
        sc = self._get_scorecard()
        for key in self.REQUIRED_DIMENSIONS:
            assert key in sc["dimensions"], f"Missing dimension: {key}"

    def test_dimension_b_not_scored(self):
        """Dimension B must be marked as not scored."""
        sc = self._get_scorecard()
        assert sc["dimensions"]["B"].get("scored") is False

    def test_dimension_b_has_pointer(self):
        sc = self._get_scorecard()
        assert "pointer" in sc["dimensions"]["B"]
        assert len(sc["dimensions"]["B"]["pointer"]) > 10

    def test_flags_keys(self):
        sc = self._get_scorecard()
        for key in self.REQUIRED_FLAGS:
            assert key in sc["flags"], f"Missing flag: {key}"
            assert isinstance(sc["flags"][key], bool), f"Flag {key} must be bool"

    def test_assumptions_keys(self):
        sc = self._get_scorecard()
        for key in self.REQUIRED_ASSUMPTIONS:
            assert key in sc["assumptions"], f"Missing assumption: {key}"

    def test_course_keys(self):
        sc = self._get_scorecard()
        for key in self.REQUIRED_COURSE:
            assert key in sc["course"], f"Missing course key: {key}"

    def test_needs_ai_review_is_list(self):
        sc = self._get_scorecard()
        assert isinstance(sc["needs_ai_review"], list)

    def test_durable_skills_is_list(self):
        sc = self._get_scorecard()
        assert isinstance(sc["durable_skills"], list)

    def test_deterministic_vs_ai_label_in_assumptions(self):
        sc = self._get_scorecard()
        label = sc["assumptions"]["deterministic_vs_ai"]
        assert "deterministic" in label.lower()
        assert "cross-course" in label.lower()

    def test_scorecard_is_json_serializable(self):
        import json
        sc = self._get_scorecard()
        # Must not raise
        serialized = json.dumps(sc)
        assert len(serialized) > 100

    def test_dimension_d_has_tier_and_coverage(self):
        sc = self._get_scorecard()
        dim_d = sc["dimensions"]["D"]
        assert "tier" in dim_d
        assert "assurance_coverage_pct" in dim_d
        assert "lane_summary" in dim_d
        assert "aias" in dim_d

    def test_dimension_c_has_required_fields(self):
        sc = self._get_scorecard()
        dim_c = sc["dimensions"]["C"]
        assert "weighted_distribution" in dim_c
        assert "arc" in dim_c
        assert "counterfeit_objective" in dim_c


# ---------------------------------------------------------------------------
# Lane balance integration test
# ---------------------------------------------------------------------------

class TestLaneBalanceIntegration:
    """Verify lane_balance counts match actual records in end-to-end scorecard."""

    def test_lane_balance_counts_match_records(self):
        # Post criterion-A: use proctoring_signal=True for the single lane1 item.
        records = [
            _make_record(id=1, title="Peer Review Paper", grade_weight_pct=30.0,
                         proctoring_signal=True, submission_types=["online_upload"]),  # lane1
            _make_record(id=2, title="Take Home Essay", grade_weight_pct=30.0,  # lane2
                         submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),  # lane2
            _make_record(id=4, title="Final Paper", grade_weight_pct=30.0,  # mismatch (>20% no security)
                         submission_types=["online_upload"], peer_reviews=False),
        ]
        scorecard = _call_score(records)
        lb = scorecard["lane_balance"]
        total = lb["lane1"] + lb["lane2"] + lb["mismatch"] + lb["unclassified"]
        assert total == len(records), f"lane_balance total {total} != record count {len(records)}"
        assert lb["lane1"] == 1  # only the peer-reviewed paper
        assert lb["mismatch"] >= 1  # the high-weight final paper


# ---------------------------------------------------------------------------
# Regression: syllabus html-key fix (false-negative AIAS detection, course 256162)
# Before fix: _fetch_syllabus_text read .get("syllabus_body") which canvas_api
# never returns — it returns {"html": ..., "page_url": ..., "updated_at": ...}.
# Result was always "" → declared_presence always False even with a gen-AI policy.
# ---------------------------------------------------------------------------

class TestFetchSyllabusHtmlKey:
    """_fetch_syllabus_text must read the 'html' key returned by canvas_api.get_syllabus."""

    def test_fetch_syllabus_text_returns_genai_content(self):
        """When get_syllabus returns html with gen-AI policy text, _fetch_syllabus_text
        must return that text (HTML tags stripped) so keyword matching works."""
        import assurance_audit
        import assurance_audit as aa_mod

        mock_config = {
            "token": "fake",
            "domain": "canvas.test.edu",
            "course_id": "256162",
            "base_url": "https://canvas.test.edu/api/v1",
            "course_url": "https://canvas.test.edu/api/v1/courses/256162",
            "headers": {"Authorization": "Bearer fake"},
        }

        # Simulate what canvas_api.get_syllabus actually returns (html key, not syllabus_body)
        mock_syllabus = {
            "html": "<p>Use of generative AI tools is permitted with disclosure.</p>",
            "page_url": "",
            "updated_at": None,
        }

        with patch("assurance_audit.canvas_api.get_syllabus", return_value=mock_syllabus):
            text = aa_mod._fetch_syllabus_text(mock_config)

        assert "generative ai" in text.lower(), (
            f"Expected 'generative ai' in syllabus text, got: {text!r}"
        )

    def test_aias_declared_presence_true_via_html_key(self):
        """End-to-end: html key → _fetch_syllabus_text → _detect_aias_presence → declared True.
        Regression guard: before fix, declared_presence was always False for any live course
        because get_syllabus returns 'html', not 'syllabus_body'."""
        import assurance_audit as aa_mod

        mock_config = {
            "token": "fake",
            "domain": "canvas.test.edu",
            "course_id": "256162",
            "base_url": "https://canvas.test.edu/api/v1",
            "course_url": "https://canvas.test.edu/api/v1/courses/256162",
            "headers": {"Authorization": "Bearer fake"},
        }

        mock_syllabus = {
            "html": "<p>Use of generative AI tools is permitted with disclosure.</p>",
            "page_url": "",
            "updated_at": None,
        }

        with patch("assurance_audit.canvas_api.get_syllabus", return_value=mock_syllabus):
            text = aa_mod._fetch_syllabus_text(mock_config)

        result = aa_mod._detect_aias_presence(text, [])
        assert result["declared_presence"] is True, (
            f"declared_presence should be True when syllabus contains gen-AI policy; got {result}"
        )
        assert "syllabus" in result["found_in"]

    def test_old_syllabus_body_key_returns_empty(self):
        """Confirm the pre-fix key 'syllabus_body' is absent from get_syllabus output,
        meaning the bug produced silent empty string (not an exception)."""
        import assurance_audit as aa_mod

        mock_config = {
            "token": "fake",
            "domain": "canvas.test.edu",
            "course_id": "256162",
            "base_url": "https://canvas.test.edu/api/v1",
            "course_url": "https://canvas.test.edu/api/v1/courses/256162",
            "headers": {"Authorization": "Bearer fake"},
        }

        # Simulate the actual get_syllabus return shape — note: NO 'syllabus_body' key
        mock_syllabus = {
            "html": "<p>Use of generative AI tools is permitted.</p>",
            "page_url": "",
            "updated_at": None,
        }

        assert mock_syllabus.get("syllabus_body") is None, (
            "get_syllabus must NOT return a 'syllabus_body' key -- the old code silently got None"
        )
        # After the fix, reading 'html' gives the correct content
        assert mock_syllabus.get("html"), "get_syllabus 'html' key must be non-empty"


# ---------------------------------------------------------------------------
# Equity and Feasibility (Dimension E) tests
# ---------------------------------------------------------------------------

class TestEquityFeasibility:
    """Tests for _compute_equity_feasibility and dimensions.E contract."""

    # -- Test 1: schema golden test --

    def test_dimension_e_present_with_all_keys(self):
        """dimensions.E must be present with all documented keys."""
        records = [
            _make_record(id=1, title="Final Oral Defense", grade_weight_pct=45.0),
            _make_record(id=2, title="Take Home Essay", grade_weight_pct=35.0,
                         submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=20.0, submission_types=["online_quiz"]),
        ]
        sc = _call_score(records)
        assert "E" in sc["dimensions"], "dimensions.E must be present"
        dim_e = sc["dimensions"]["E"]
        required_keys = {
            "feasibility_band", "surveillance_weight_pct", "synchronous_anchor_count",
            "synchronous_weight_pct", "handgraded_anchor_count",
            "high_stakes_concentration_pct", "burdens", "label", "description",
        }
        for key in required_keys:
            assert key in dim_e, f"dimensions.E missing key: {key}"
        assert dim_e["feasibility_band"] in ("low_burden", "moderate_burden", "high_burden"), (
            f"feasibility_band must be one of the 3-value enum, got: {dim_e['feasibility_band']}"
        )

    # -- Test 2: surveillance burden -- proctoring-only --

    def test_surveillance_burden_on_proctored_items(self):
        """Two proctored items summing >=50% grade weight triggers surveillance burden."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        records = [
            _make_record(id=1, title="Midterm Exam", proctoring_signal=True,
                         grade_weight_pct=30.0, canvas_type="quiz", engine="classic_quiz",
                         submission_types=["external_tool"]),
            _make_record(id=2, title="Final Exam", proctoring_signal=True,
                         grade_weight_pct=30.0, canvas_type="quiz", engine="classic_quiz",
                         submission_types=["external_tool"]),
            _make_record(id=3, title="Portfolio", grade_weight_pct=40.0,
                         submission_types=["online_upload"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        assert result["surveillance_weight_pct"] == pytest.approx(60.0, abs=0.1)
        burden_types = [b["type"] for b in result["burdens"]]
        assert "surveillance" in burden_types, "surveillance burden must be present"

    def test_surveillance_weight_pct_is_proctoring_only_control(self):
        """90% oral-defense weight + 0% proctoring -> surveillance_weight_pct==0, no surveillance burden.

        Proves E does not inherit the lane1-weight definition of the legacy over_surveillance flag.
        """
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        # 90% oral defenses (lane2 after criterion-A: keyword alone never promotes to lane1), 10% quiz
        records = [
            _make_record(id=1, title="Oral Defense 1", proctoring_signal=False,
                         grade_weight_pct=45.0, submission_types=["online_upload"]),
            _make_record(id=2, title="Oral Defense 2", proctoring_signal=False,
                         grade_weight_pct=45.0, submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         proctoring_signal=False, grade_weight_pct=10.0,
                         submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        assert result["surveillance_weight_pct"] == 0.0, (
            "surveillance_weight_pct must be 0 when no items have proctoring_signal=True"
        )
        burden_types = [b["type"] for b in result["burdens"]]
        assert "surveillance" not in burden_types, (
            "surveillance burden must NOT appear when proctoring weight is 0, "
            "even when 90% weight uses oral-keyword titles (those are lane2)"
        )

    # -- Test 3: synchronous burden --

    def test_synchronous_burden_on_oral_anchors(self):
        """2+ oral-titled Lane 1 anchors trigger synchronous burden."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        records = [
            _make_record(id=1, title="Oral Defense Week 4", grade_weight_pct=20.0,
                         submission_types=["online_upload"]),
            _make_record(id=2, title="Oral Presentation Final", grade_weight_pct=20.0,
                         submission_types=["online_upload"]),
            _make_record(id=3, title="Take Home Essay", grade_weight_pct=60.0,
                         submission_types=["online_upload"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        assert result["synchronous_anchor_count"] >= 2
        burden_types = [b["type"] for b in result["burdens"]]
        assert "synchronous" in burden_types, "synchronous burden must be present"

    # -- Test 4: hand-graded count is informational --

    def test_handgraded_excludes_auto_graded_quiz(self):
        """A proctored classic quiz must NOT increment handgraded_anchor_count."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        records = [
            # Proctored quiz: lane1 but is_quiz=True so excluded from handgraded
            _make_record(id=1, title="Proctored Midterm", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=30.0, submission_types=["external_tool"]),
            # Graded discussion: lane1 (proctored) and is_quiz=False so counts
            _make_record(id=2, title="Peer Reviewed Discussion", proctoring_signal=True,
                         canvas_type="discussion", engine="graded_discussion",
                         grade_weight_pct=30.0, submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=40.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        # Only the discussion (id=2) is lane1 and not a quiz
        assert result["handgraded_anchor_count"] == 1, (
            f"Expected handgraded_anchor_count=1, got {result['handgraded_anchor_count']}"
        )
        # handgraded never appears in burdens
        burden_types = [b["type"] for b in result["burdens"]]
        assert "instructor_labor" not in burden_types, (
            "handgraded count must NEVER drive a burden (instructor_labor dropped from spec)"
        )

    def test_handgraded_never_changes_feasibility_band(self):
        """A course with many hand-graded items but no other burdens stays low_burden."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        # 3 peer-reviewed assignments (lane1 = structured metadata, not proctored)
        # + 3 plain portfolio uploads (lane2 after criterion-A: keyword alone not lane1)
        # Neither group drives surveillance/sync/concentration burdens.
        records = [
            _make_record(id=i, title="Peer Portfolio Review " + str(i),
                         canvas_type="assignment", engine="assignment",
                         proctoring_signal=True,
                         grade_weight_pct=10.0, submission_types=["online_upload"])
            for i in range(1, 4)
        ] + [
            _make_record(id=3 + i, title="Portfolio Reflection " + str(i),
                         canvas_type="assignment", engine="assignment",
                         grade_weight_pct=10.0, submission_types=["online_upload"])
            for i in range(1, 4)
        ]
        # 4 quizzes to round out to 100%
        records += [
            _make_record(id=10 + i, title="Module Quiz " + str(i),
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"])
            for i in range(1, 5)
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        # peer-reviewed assignments (lane1, not quiz) are counted as hand-graded
        assert result["handgraded_anchor_count"] >= 1
        assert result["feasibility_band"] == "low_burden", (
            "Many hand-graded items with no proctoring/sync/concentration must be low_burden"
        )

    # -- Test 5: high-stakes concentration --

    def test_concentration_burden_present(self):
        """Weight >=70% in items each >=20% triggers high_stakes_concentration burden."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        records = [
            _make_record(id=1, title="Oral Defense", grade_weight_pct=40.0,
                         submission_types=["online_upload"]),
            _make_record(id=2, title="Final Paper", proctoring_signal=True,
                         grade_weight_pct=35.0, submission_types=["online_upload"]),
            _make_record(id=3, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=25.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        # All 3 items are >=20%; total = 100% >= 70%
        assert result["high_stakes_concentration_pct"] >= 70.0
        burden_types = [b["type"] for b in result["burdens"]]
        assert "high_stakes_concentration" in burden_types, (
            "high_stakes_concentration burden must be present"
        )

    # -- Test 6: clean course is low_burden --

    def test_clean_course_is_low_burden(self):
        """One 40% process-portfolio anchor + distributed low-stakes -> low_burden, burdens==[]."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        records = [
            # 40% process-portfolio (lane1, no proctoring, not a quiz)
            _make_record(id=1, title="Portfolio Reflection", grade_weight_pct=40.0,
                         submission_types=["online_upload"]),
            # 6 x ~10% quizzes (distributed, each below HIGH_WEIGHT_THRESHOLD for concentration)
            _make_record(id=2, title="Quiz 1", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=3, title="Quiz 2", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=4, title="Quiz 3", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=5, title="Quiz 4", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=6, title="Quiz 5", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=7, title="Quiz 6", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        assert result["feasibility_band"] == "low_burden"
        assert result["burdens"] == []

    # -- Test 6a: band thresholds --

    def test_two_burdens_gives_high_burden(self):
        """Surveillance + concentration -> high_burden."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        records = [
            # Proctored at 55% (surveillance burden)
            _make_record(id=1, title="Proctored Midterm", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=55.0, submission_types=["external_tool"]),
            # 25% high-stakes item; total high-stakes = 80% (concentration burden)
            _make_record(id=2, title="Final Essay", proctoring_signal=True,
                         grade_weight_pct=25.0, submission_types=["online_upload"]),
            _make_record(id=3, title="Small Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=20.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        assert len(result["burdens"]) >= 2
        assert result["feasibility_band"] == "high_burden"

    def test_one_burden_gives_moderate_burden(self):
        """Exactly one burden -> moderate_burden."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane
        records = [
            # Proctored at 60% (surveillance burden only)
            _make_record(id=1, title="Proctored Midterm", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=30.0, submission_types=["external_tool"]),
            _make_record(id=2, title="Proctored Final", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=30.0, submission_types=["external_tool"]),
            # Distributed low-stakes (no concentration burden: each below HIGH_WEIGHT_THRESHOLD)
            _make_record(id=3, title="Quiz A", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=4, title="Quiz B", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=5, title="Quiz C", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=6, title="Quiz D", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        result = _compute_equity_feasibility(records, lanes)
        assert len(result["burdens"]) == 1
        assert result["feasibility_band"] == "moderate_burden"

    # -- Test 7: invariance of tier and coverage --

    def test_tier_invariant_with_and_without_equity_context(self):
        """tier and assurance_coverage_pct must be the same regardless of equity burdens."""
        # High-proctoring fixture (surveillance burden) -- tier = Assured.
        # Use small-weight quizzes for the non-lane1 items (each well below HIGH_WEIGHT_THRESHOLD=20%)
        # so they land in lane2 without triggering mismatch.
        records = [
            _make_record(id=1, title="Proctored Final", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=55.0, submission_types=["external_tool"]),
            _make_record(id=2, title="Quiz A", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=3, title="Quiz B", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=4, title="Quiz C", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=5, title="Quiz D", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
            _make_record(id=6, title="Quiz E", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=5.0, submission_types=["online_quiz"]),
        ]
        sc = _call_score(records)
        # Tier must be Assured (55% proctored = lane1 >= 40% threshold)
        assert sc["tier"] == "Assured"
        assert sc["assurance_coverage_pct"] == pytest.approx(55.0, abs=0.1)
        # Dimension E must be present and not have changed tier
        assert "E" in sc["dimensions"]

    def test_assurance_coverage_pct_invariant_high_surveillance(self):
        """assurance_coverage_pct must remain the same even with 90% proctored weight."""
        from assurance_audit import _compute_assurance_coverage, _classify_lane
        records = [
            _make_record(id=1, title="Proctored Midterm", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=50.0, submission_types=["external_tool"]),
            _make_record(id=2, title="Proctored Final", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=40.0, submission_types=["external_tool"]),
            _make_record(id=3, title="Participation Quiz", canvas_type="quiz",
                         engine="classic_quiz", grade_weight_pct=10.0,
                         submission_types=["online_quiz"]),
        ]
        lanes = {r["id"]: _classify_lane(r) for r in records}
        coverage = _compute_assurance_coverage(records, lanes)
        assert coverage["coverage_pct"] == pytest.approx(90.0, abs=0.1)
        assert coverage["tier"] == "Assured"

    # -- Test 8: recommendation modulation --

    def test_high_surveillance_rec_avoids_additional_proctoring(self):
        """UNASSURED course with high surveillance -> rec uses process-trail/asynchronous, not additional proctoring."""
        from assurance_audit import _compute_equity_feasibility, _classify_lane, _build_recommendations
        from assurance_audit import _compute_assurance_coverage
        # Build an Unassured course with high proctoring weight on items that still end up unclassified
        # (proctoring but no lane1 keywords and no mismatch)
        # Use a small weight so no mismatch triggers; proctoring_signal=True -> lane1
        # To get UNASSURED with high proctoring: we need proctored items but no lane1 weight counted.
        # Actually proctored -> lane1 so coverage would be >0. Use a different route:
        # Simulate via direct function call with constructed inputs.
        from assurance_audit import AssuranceTier
        coverage_unassured = {
            "tier": AssuranceTier.UNASSURED,
            "coverage_pct": 0.0,
            "anchor_count": 0,
            "anchor_items": [],
            "mismatch_items": [],
        }
        aias_no = {"declared_presence": True, "found_in": ["syllabus"]}
        equity_high_surv = {
            "surveillance_weight_pct": 60.0,
            "synchronous_anchor_count": 0,
            "synchronous_weight_pct": 0.0,
            "handgraded_anchor_count": 0,
            "high_stakes_concentration_pct": 0.0,
            "burdens": [{"type": "surveillance", "detail": "60% proctored", "items": []}],
            "feasibility_band": "moderate_burden",
        }
        from assurance_audit import _build_recommendations
        recs = _build_recommendations(coverage_unassured, aias_no, equity_high_surv)
        combined = " ".join(recs).lower()
        assert "process-trail" in combined or "asynchronous" in combined, (
            "High-surveillance rec must mention process-trail or asynchronous: " + str(recs)
        )
        # The rec must steer toward lower-burden mechanisms, not advocate for more proctored components.
        # "add ... proctored" or "introduce ... proctored" should not appear as a positive suggestion.
        # The phrase "lower-burden" or "preferred" confirms the feasibility-aware framing is present.
        assert "lower-burden" in combined or "preferred" in combined or "process-trail" in combined, (
            "High-surveillance rec must include feasibility-aware framing: " + str(recs)
        )

    def test_high_sync_rec_includes_sample(self):
        """High synchronous anchor count -> rec includes sample phrasing."""
        from assurance_audit import AssuranceTier, _build_recommendations
        coverage_partial = {
            "tier": AssuranceTier.PARTIALLY_ASSURED,
            "coverage_pct": 20.0,
            "anchor_count": 2,
            "anchor_items": [],
            "mismatch_items": [],
        }
        aias_yes = {"declared_presence": True, "found_in": ["syllabus"]}
        equity_high_sync = {
            "surveillance_weight_pct": 0.0,
            "synchronous_anchor_count": 3,
            "synchronous_weight_pct": 30.0,
            "handgraded_anchor_count": 2,
            "high_stakes_concentration_pct": 0.0,
            "burdens": [{"type": "synchronous", "detail": "3 live anchors", "items": []}],
            "feasibility_band": "moderate_burden",
        }
        recs = _build_recommendations(coverage_partial, aias_yes, equity_high_sync)
        combined = " ".join(recs).lower()
        assert "sample" in combined, (
            "High-sync rec must contain 'sample': " + str(recs)
        )

    def test_high_burden_rec_standalone_present(self):
        """high_burden feasibility_band -> a standalone rec is added naming the burdens."""
        from assurance_audit import AssuranceTier, _build_recommendations
        coverage_assured = {
            "tier": AssuranceTier.ASSURED,
            "coverage_pct": 50.0,
            "anchor_count": 2,
            "anchor_items": [],
            "mismatch_items": [],
        }
        aias_yes = {"declared_presence": True, "found_in": ["syllabus"]}
        equity_high = {
            "surveillance_weight_pct": 60.0,
            "synchronous_anchor_count": 3,
            "synchronous_weight_pct": 30.0,
            "handgraded_anchor_count": 0,
            "high_stakes_concentration_pct": 80.0,
            "burdens": [
                {"type": "surveillance", "detail": "60% proctored", "items": []},
                {"type": "synchronous", "detail": "3 live anchors", "items": []},
            ],
            "feasibility_band": "high_burden",
        }
        recs = _build_recommendations(coverage_assured, aias_yes, equity_high)
        combined = " ".join(recs).lower()
        assert "high_burden" in combined or "process-trail" in combined or "asynchronous" in combined, (
            "high_burden rec must mention high_burden or lower-burden mechanisms: " + str(recs)
        )


# ---------------------------------------------------------------------------
# AI-Forward axis (Dimension F)
# ---------------------------------------------------------------------------

class TestAIForwardAxis:
    """The AI-Forward rating is deterministic and orthogonal to the assurance tier."""

    AI_POLICY = "AI use policy: generative AI permitted with disclosure and verification."

    def test_top_level_keys_present(self):
        sc = _call_score([_make_record(id=1, title="Quiz", grade_weight_pct=100.0)])
        assert "ai_forward_rating" in sc
        assert "ai_forward_dimensions" in sc
        assert "bloom_calibration_findings" in sc
        assert "F" in sc["dimensions"]

    def test_rating_is_valid_enum(self):
        from assurance_audit import AI_FORWARD_ANCHORS
        sc = _call_score([_make_record(id=1, title="Final Project", grade_weight_pct=100.0)])
        assert sc["ai_forward_rating"] in AI_FORWARD_ANCHORS

    def test_six_dimensions_with_valid_anchors(self):
        sc = _call_score([_make_record(id=1, title="Paper", grade_weight_pct=100.0)])
        dims = sc["ai_forward_dimensions"]
        for key in ("D1_transparency", "D2_bloom_alignment", "D3_assurance_anchor",
                    "D4_human_judgment", "D5_process_authorship", "D6_equity_feasibility"):
            assert key in dims, "missing AI-forward dimension " + key
            assert dims[key]["anchor"] in (0, 1, 2, 3)
            assert dims[key]["label"] in AI_FORWARD_ANCHORS_LIST

    def test_orthogonal_assured_but_not_forward(self):
        # Proctored final = Assured tier, but no AI policy -> D1=0 -> Needs redesign forward.
        sc = _call_score([
            _make_record(id=1, title="Proctored Final Exam", grade_weight_pct=85.0, proctoring_signal=True),
            _make_record(id=2, title="Participation", grade_weight_pct=15.0, submission_types=["online_upload"]),
        ])
        assert sc["tier"] == "Assured"
        assert sc["ai_forward_rating"] == "Needs redesign"

    def test_strong_design_reaches_ai_forward(self):
        sc = _call_score([
            _make_record(id=1, title="Critique AI Output: Justify Decisions with AI Appendix and Revision Log",
                         grade_weight_pct=60.0, has_rubric=True, submission_types=["online_upload"]),
            _make_record(id=2, title="Defend Recommendation: Portfolio with Prompt Log",
                         grade_weight_pct=40.0, has_rubric=True, proctoring_signal=True),
        ], syllabus_text=self.AI_POLICY)
        assert sc["ai_forward_rating"] == "AI-forward"
        dims = sc["ai_forward_dimensions"]
        assert dims["D1_transparency"]["anchor"] == 3
        assert dims["D4_human_judgment"]["anchor"] == 3
        assert dims["D5_process_authorship"]["anchor"] == 3

    def test_d1_caps_at_2_without_ai_integration(self):
        # Declared policy but no AI appendix/verification signal -> D1 = 2, not 3.
        sc = _call_score([_make_record(id=1, title="Reflection Journal", grade_weight_pct=100.0)],
                         syllabus_text=self.AI_POLICY)
        assert sc["ai_forward_dimensions"]["D1_transparency"]["anchor"] == 2

    def test_auto_floor_on_counterfeit(self):
        # High-stakes unanchored upload -> Counterfeit tier -> forward auto-floored.
        sc = _call_score([_make_record(id=1, title="Take Home Paper", grade_weight_pct=70.0,
                                       submission_types=["online_upload"])])
        assert sc["tier"] in ("Counterfeit", "Unassured")
        assert sc["ai_forward_rating"] == "Needs redesign"
        assert sc["dimensions"]["F"]["auto_flagged"] is True

    def test_rating_independent_of_ai_review(self):
        # The deterministic rating must not depend on needs_ai_review content.
        recs = [_make_record(id=1, title="Defend Decision with AI Appendix", grade_weight_pct=100.0,
                             has_rubric=True)]
        sc1 = _call_score(recs, syllabus_text=self.AI_POLICY)
        sc2 = _call_score(recs, syllabus_text=self.AI_POLICY)
        assert sc1["ai_forward_rating"] == sc2["ai_forward_rating"]
        # Rating derives only from deterministic anchors, not the AI item list.
        assert isinstance(sc1["needs_ai_review"], list)

    def test_rating_structurally_independent_of_ai_items(self, monkeypatch):
        # Lock the contract: even if the AI-item packager returned wildly
        # different (or empty) judgments, the deterministic rating is unchanged.
        import assurance_audit as A
        recs = [_make_record(id=1, title="Defend Decision with AI Appendix", grade_weight_pct=100.0,
                             has_rubric=True)]
        baseline = _call_score(recs, syllabus_text=self.AI_POLICY)["ai_forward_rating"]
        # Patch all three item sources so needs_ai_review is truly empty.
        monkeypatch.setattr(A, "_package_ai_items", lambda *a, **k: [])
        monkeypatch.setattr(A, "_package_bloom_content_items", lambda *a, **k: [])
        monkeypatch.setattr(A, "_package_lane_review_items", lambda *a, **k: [])
        empty_ai = _call_score(recs, syllabus_text=self.AI_POLICY)
        assert empty_ai["needs_ai_review"] == []
        assert empty_ai["ai_forward_rating"] == baseline

    def test_new_judgment_types_present(self):
        sc = _call_score([_make_record(id=1, title="Project", grade_weight_pct=100.0)],
                         syllabus_text=self.AI_POLICY)
        types = {i["judgment_type"] for i in sc["needs_ai_review"]}
        assert "judgment_of_ai" in types
        assert "authorship_evidence_depth" in types


class TestBloomCalibrationFindings:
    """Deterministic Bloom Guide 'Avoid' rules, one finding per high-weight item."""

    def test_create_oneshot(self):
        sc = _call_score([_make_record(id=1, title="Design Final Project", grade_weight_pct=70.0,
                                       submission_types=["online_upload"]),
                          _make_record(id=2, title="Reflection", grade_weight_pct=30.0)])
        rules = {f["rule"] for f in sc["bloom_calibration_findings"]}
        assert "create_oneshot" in rules

    def test_recall_unprotected(self):
        sc = _call_score([_make_record(id=1, title="Define Terminology Quiz", grade_weight_pct=50.0,
                                       canvas_type="quiz", engine="classic_quiz",
                                       submission_types=["online_quiz"]),
                          _make_record(id=2, title="Oral Defense", grade_weight_pct=50.0)])
        rules = {f["rule"] for f in sc["bloom_calibration_findings"]}
        assert "recall_unprotected" in rules

    def test_recall_unprotected_fires_when_proctoring_unknown(self):
        """proctoring_signal is tri-state; "unknown" must not read as protected.

        Regression: bool("unknown") is True, so a plain bool() coercion marked an
        unconfirmable quiz as proctored and dropped its recall_unprotected finding.
        """
        sc = _call_score([_make_record(id=1, title="Define Terminology Quiz", grade_weight_pct=50.0,
                                       canvas_type="quiz", engine="classic_quiz",
                                       submission_types=["online_quiz"],
                                       proctoring_signal="unknown"),
                          _make_record(id=2, title="Oral Defense", grade_weight_pct=50.0)])
        rules = {f["rule"] for f in sc["bloom_calibration_findings"]}
        assert "recall_unprotected" in rules

    def test_apply_reusable_fires_when_proctoring_unknown(self):
        """The same tri-state line gates apply_reusable; pin it too so a future
        refactor that splits the two gates can't regress one silently."""
        sc = _call_score([_make_record(id=1, title="Solve Practice Problems Quiz", grade_weight_pct=50.0,
                                       canvas_type="quiz", engine="classic_quiz",
                                       submission_types=["online_quiz"],
                                       proctoring_signal="unknown"),
                          _make_record(id=2, title="Oral Defense", grade_weight_pct=50.0)])
        rules = {f["rule"] for f in sc["bloom_calibration_findings"]}
        assert "apply_reusable" in rules

    def test_recall_unprotected_suppressed_only_when_proctoring_confirmed(self):
        """A confirmed-proctored quiz is the one case that suppresses the finding."""
        sc = _call_score([_make_record(id=1, title="Define Terminology Quiz", grade_weight_pct=50.0,
                                       canvas_type="quiz", engine="classic_quiz",
                                       submission_types=["online_quiz"],
                                       proctoring_signal=True),
                          _make_record(id=2, title="Oral Defense", grade_weight_pct=50.0)])
        rules = {f["rule"] for f in sc["bloom_calibration_findings"]}
        assert "recall_unprotected" not in rules

    def test_highorder_no_criteria(self):
        sc = _call_score([_make_record(id=1, title="Evaluate Policy Options Memo", grade_weight_pct=60.0,
                                       has_rubric=False, submission_types=["online_upload"]),
                          _make_record(id=2, title="Oral Defense", grade_weight_pct=40.0)])
        rules = {f["rule"] for f in sc["bloom_calibration_findings"]}
        assert "highorder_no_criteria" in rules

    def test_low_weight_items_skipped(self):
        sc = _call_score([_make_record(id=1, title="Design Project", grade_weight_pct=5.0,
                                       submission_types=["online_upload"]),
                          _make_record(id=2, title="Oral Defense", grade_weight_pct=95.0)])
        ids = {f["id"] for f in sc["bloom_calibration_findings"]}
        assert 1 not in ids  # below HIGH_WEIGHT_THRESHOLD

    def test_findings_cite_points_and_weight(self):
        sc = _call_score([_make_record(id=1, title="Create Capstone", grade_weight_pct=80.0,
                                       points=250.0, submission_types=["online_upload"])])
        for f in sc["bloom_calibration_findings"]:
            assert "points" in f and "grade_weight_pct" in f and "assessment" in f


# AI_FORWARD_ANCHORS imported for label assertion above.
from assurance_audit import AI_FORWARD_ANCHORS as AI_FORWARD_ANCHORS_LIST  # noqa: E402


def _grounded(record, bloom_level, bloom_source):
    """Attach inventory-style bloom grounding to a record."""
    record = dict(record)
    record["bloom_level"] = bloom_level
    record["bloom_index"] = {"Remember": 1, "Understand": 2, "Apply": 3,
                             "Analyze": 4, "Evaluate": 5, "Create": 6}.get(bloom_level, 0)
    record["bloom_source"] = bloom_source
    return record


class TestBloomGroundingConsumption:
    """The scorer consumes inventory-attached bloom_level (fill-Unknown)."""

    def test_rec_bloom_prefers_attached_level(self):
        from assurance_audit import _rec_bloom
        rec = _grounded(_make_record(id=1, title="Module 1 Homework"), "Create", "mlo")
        _, level, source = _rec_bloom(rec)
        assert level == "Create" and source == "mlo"

    def test_rec_bloom_falls_back_to_title(self):
        from assurance_audit import _rec_bloom
        _, level, source = _rec_bloom(_make_record(id=1, title="Analyze the Case Study"))
        assert level == "Analyze" and source == "title"

    def test_rec_bloom_unknown_when_neither(self):
        from assurance_audit import _rec_bloom
        _, level, source = _rec_bloom(_make_record(id=1, title="Module 3 Homework"))
        assert source == "unknown"

    def test_mlo_grounded_distribution_not_unknown(self):
        sc = _call_score([_grounded(_make_record(id=1, title="Module 1 Homework",
                                                 grade_weight_pct=100.0), "Create", "mlo")])
        dist = sc["dimensions"]["C"]["weighted_distribution"]
        assert dist["Create"] == 100.0
        assert dist["Unknown"] == 0.0

    def test_bloom_source_breakdown_counts(self):
        sc = _call_score([
            _grounded(_make_record(id=1, title="Module 1 Homework", grade_weight_pct=50.0), "Create", "mlo"),
            _make_record(id=2, title="Analyze the Case", grade_weight_pct=50.0),
        ])
        bsb = sc["dimensions"]["C"]["bloom_source_breakdown"]
        assert bsb["mlo"] == 1 and bsb["title"] == 1


class TestMLOCounterfeitHonestyGuard:
    """MLO-grounded objective quizzes go to AI review, not deterministic counterfeit."""

    def test_mlo_grounded_quiz_not_deterministic_counterfeit(self):
        rec = _grounded(_make_record(id=1, title="Module 1 Quiz", canvas_type="quiz",
                                     engine="classic_quiz", grade_weight_pct=40.0,
                                     submission_types=["online_quiz"]), "Create", "mlo")
        sc = _call_score([rec])
        det = sc["dimensions"]["C"]["counterfeit_objective"]
        mlo = sc["dimensions"]["C"]["counterfeit_objective_mlo"]
        assert det == []
        assert any(c["id"] == 1 for c in mlo)

    def test_mlo_grounded_quiz_emits_ai_review_item(self):
        rec = _grounded(_make_record(id=1, title="Module 3 Quiz", canvas_type="quiz",
                                     engine="classic_quiz", grade_weight_pct=40.0,
                                     submission_types=["online_quiz"]), "Create", "mlo")
        sc = _call_score([rec])
        types = {i["judgment_type"] for i in sc["needs_ai_review"] if i["id"] == 1}
        assert "counterfeit_objective_mlo" in types

    def test_title_claimed_quiz_still_deterministic_counterfeit(self):
        rec = _make_record(id=1, title="Analyze Regression Quiz", canvas_type="quiz",
                           engine="classic_quiz", grade_weight_pct=40.0,
                           submission_types=["online_quiz"])
        sc = _call_score([rec])
        det = sc["dimensions"]["C"]["counterfeit_objective"]
        assert any(c["id"] == 1 for c in det)

    def test_apply_grounded_quiz_below_threshold_not_flagged(self):
        rec = _grounded(_make_record(id=1, title="Module 2 Quiz", canvas_type="quiz",
                                     engine="classic_quiz", grade_weight_pct=40.0,
                                     submission_types=["online_quiz"]), "Apply", "mlo")
        sc = _call_score([rec])
        assert sc["dimensions"]["C"]["counterfeit_objective"] == []
        assert sc["dimensions"]["C"]["counterfeit_objective_mlo"] == []

    def test_mlo_grounded_create_homework_fires_bloom_finding(self):
        rec = _grounded(_make_record(id=1, title="Module 1 Homework", grade_weight_pct=80.0,
                                     submission_types=["online_upload"]), "Create", "mlo")
        sc = _call_score([rec])
        rules = {f["rule"] for f in sc["bloom_calibration_findings"]}
        assert "create_oneshot" in rules


class TestObjectiveGroundingScorecard:
    """objective_grounding block surfaces CLOs/MLOs and the bloom split."""

    def test_objective_grounding_present(self):
        sc = _call_score([_make_record(id=1, title="HW", grade_weight_pct=100.0)])
        og = sc["objective_grounding"]
        assert "course_clos" in og and "module_mlos" in og and "bloom_source_breakdown" in og

    def test_clos_passthrough_from_inventory(self):
        import assurance_audit
        cfg = _mock_config()
        inv = _make_inventory([_make_record(id=1, title="HW", grade_weight_pct=100.0)])
        inv["course_clos"] = {"clos": ["Build a model"], "bloom_index": 6,
                              "bloom_level": "Create", "source": "syllabus"}
        with patch.object(assurance_audit, "_fetch_syllabus_text", return_value=""), \
             patch.object(assurance_audit.canvas_api, "require_course_id", return_value=None):
            sc = assurance_audit.score_course(cfg, inventory=inv)
        assert sc["objective_grounding"]["course_clos"]["clos"] == ["Build a model"]


# ---------------------------------------------------------------------------
# Criterion A / B / C regression tests (plan-assurance-classification-boundary)
# ---------------------------------------------------------------------------

class TestClassificationBoundaryRegression:
    """
    Acceptance-criterion regression tests for plan-assurance-classification-boundary.md.
    Each test has a comment stating which criterion and true/false-positive case it covers.
    """

    # -- Criterion A true-positive: structured metadata → Lane 1 --

    def test_criterion_a_proctored_quiz_is_lane1(self):
        """Criterion A TP: access_code/proctoring_signal → deterministic Lane 1."""
        from assurance_audit import _classify_lane
        rec = _make_record(
            id=1, title="Midterm Exam", proctoring_signal=True,
            canvas_type="quiz", engine="classic_quiz",
            grade_weight_pct=40.0, submission_types=["external_tool"],
        )
        assert _classify_lane(rec) == "lane1"

    def test_criterion_a_peer_reviewed_assignment_alone_is_not_lane1(self):
        """Criterion A: peer_reviews=True alone never produces deterministic Lane 1
        (peer review does not verify authorship; removed as an auto-securing signal)."""
        from assurance_audit import _classify_lane
        rec = _make_record(
            id=2, title="Peer Review Assignment", peer_reviews=True, proctoring_signal=False,
            canvas_type="assignment", engine="assignment",
            grade_weight_pct=25.0, submission_types=["online_upload"],
        )
        assert _classify_lane(rec) == "lane2"

    # -- Criterion A false-positive: keyword title → Lane 2 + lane_review emitted --

    def test_criterion_a_oral_presentation_title_is_lane2(self):
        """Criterion A FP guard: 'Oral Presentation' title alone must be Lane 2, NOT Lane 1."""
        from assurance_audit import _classify_lane
        rec = _make_record(
            id=3, title="Oral Presentation", proctoring_signal=False, peer_reviews=False,
            grade_weight_pct=30.0, submission_types=["online_upload"],
        )
        assert _classify_lane(rec) == "lane2", "keyword title must not auto-promote to lane1"

    def test_criterion_a_oral_keyword_emits_lane_review_item(self):
        """Criterion A FP guard: 'Oral Presentation' emits a lane_review needs_ai_review item."""
        records = [
            _make_record(id=1, title="Oral Presentation", proctoring_signal=False,
                         peer_reviews=False, grade_weight_pct=30.0,
                         submission_types=["online_upload"]),
            _make_record(id=2, title="Module Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=70.0, submission_types=["online_quiz"]),
        ]
        sc = _call_score(records)
        lane_review_items = [i for i in sc["needs_ai_review"] if i.get("judgment_type") == "lane_review"]
        assert any(i["id"] == 1 for i in lane_review_items), (
            "oral-keyword item must emit a lane_review item regardless of grade weight"
        )

    def test_criterion_a_behavioral_not_matched_as_oral(self):
        """Criterion A FP guard: 'behavioral' contains 'oral' as substring, must NOT match."""
        from assurance_audit import _classify_lane
        rec = _make_record(
            id=4,
            title="Behavioral Communication Discussion",
            proctoring_signal=False, peer_reviews=False,
            grade_weight_pct=1.71, submission_types=["online_upload"],
        )
        # Inject content_excerpt with 'behavioral' and 'physical presentation' (COM 100 case)
        rec["content_excerpt"] = "This discussion covers behavioral dynamics and physical presentation skills."
        lane = _classify_lane(rec)
        assert lane == "lane2", (
            "'behavioral' must not word-boundary-match 'oral'; item must be lane2"
        )

    # -- Criterion B regression: Bloom cap on objective items --

    def test_criterion_b_objective_quiz_high_mlo_caps_at_understand_in_scorecard(self):
        """Criterion B TP: MCQ quiz with Create-MLO module must not show Create in deterministic Bloom."""
        # Simulate a quiz whose bloom level was capped (bloom_mlo_capped=True)
        # The scorecard's cognitive_demand must reflect Understand, not Create.
        records = [
            _make_record(id=1, title="Chapter Quiz",
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=10.0, submission_types=["online_quiz"]),
        ]
        # Inject bloom cap fields as inventory would
        records[0]["bloom_level"] = "Understand"
        records[0]["bloom_index"] = 2
        records[0]["bloom_source"] = "mlo"
        records[0]["bloom_mlo_capped"] = True
        records[0]["bloom_mlo_original_level"] = "Create"
        records[0]["bloom_mlo_original_index"] = 6

        sc = _call_score(records)
        bloom_dist = sc["dimensions"]["C"]["weighted_distribution"]  # dict: level → pct
        create_pct = bloom_dist.get("Create", 0.0)
        assert create_pct == 0.0, "Create must not appear in deterministic Bloom distribution for a capped objective quiz"
        understand_pct = bloom_dist.get("Understand", 0.0)
        assert understand_pct > 0.0, "Understand must appear after cap"

    def test_criterion_b_capped_quiz_emits_counterfeit_objective_mlo_review_item(self):
        """Criterion B TP: capped objective quiz emits a counterfeit_objective_mlo needs_ai_review item."""
        records = [
            _make_record(id=1, title="Chapter Quiz",
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=50.0, submission_types=["online_quiz"]),
        ]
        records[0]["bloom_level"] = "Understand"
        records[0]["bloom_index"] = 2
        records[0]["bloom_source"] = "mlo"
        records[0]["bloom_mlo_capped"] = True
        records[0]["bloom_mlo_original_level"] = "Analyze"
        records[0]["bloom_mlo_original_index"] = 4

        sc = _call_score(records)
        item_types = [i.get("judgment_type", "") for i in sc["needs_ai_review"]]
        assert "counterfeit_objective_mlo" in item_types, (
            "capped objective quiz must emit counterfeit_objective_mlo review item"
        )

    def test_criterion_b_rubric_scored_upload_unaffected(self):
        """Criterion B FP guard: rubric-scored upload with Create-MLO is NOT capped."""
        records = [
            _make_record(id=1, title="Final Portfolio",
                         canvas_type="assignment", engine="assignment",
                         grade_weight_pct=50.0, has_rubric=True,
                         submission_types=["online_upload"]),
        ]
        # As inventory would produce for a non-objective item
        records[0]["bloom_level"] = "Create"
        records[0]["bloom_index"] = 6
        records[0]["bloom_source"] = "mlo"
        records[0]["bloom_mlo_capped"] = False

        sc = _call_score(records)
        bloom_dist = sc["dimensions"]["C"]["weighted_distribution"]  # dict: level → pct
        create_pct = bloom_dist.get("Create", 0.0)
        assert create_pct > 0.0, "rubric-scored upload must retain Create Bloom level"

    # -- Criterion C regression: AIAS word-boundary --

    def test_criterion_c_keyword_inside_word_does_not_declare_aias(self):
        """Criterion C FP guard: keyword embedded inside a longer word must not set declared_presence."""
        from assurance_audit import _detect_aias_presence
        # 'prompt' inside 'promptly' — word-boundary must prevent match
        text = "Students should respond promptly to instructor feedback and complete work diligently."
        result = _detect_aias_presence(text, [])
        assert result["declared_presence"] is False, (
            "'promptly' must not trigger AIAS declared_presence via 'prompt' substring match"
        )

    def test_criterion_c_real_policy_sentence_declares_aias(self):
        """Criterion C TP: a real 'Use of AI' policy sentence sets declared_presence=True with excerpt."""
        from assurance_audit import _detect_aias_presence
        text = "Use of AI: the use of generative AI tools such as ChatGPT is not permitted in this course without instructor approval."
        result = _detect_aias_presence(text, [])
        assert result["declared_presence"] is True, "real policy sentence must set declared_presence"
        excerpts = result.get("policy_excerpts", [])
        assert len(excerpts) > 0, "policy_excerpts must be non-empty for a declared policy"


# ---------------------------------------------------------------------------
# Security ledger: per-assessment binary labels (docs/plan-assurance-binary-labels.md)
# ---------------------------------------------------------------------------

class TestSecurityLedgerSchema:
    """Criterion 1: score_course emits security_ledger, one entry per assessment."""

    def test_security_ledger_present_one_entry_per_assessment(self):
        records = [
            _make_record(id=1, title="Midterm Exam", proctoring_signal=True,
                         canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=40.0, submission_types=["external_tool"]),
            _make_record(id=2, title="Take Home Essay", grade_weight_pct=30.0,
                         submission_types=["online_upload"]),
            _make_record(id=3, title="Reading Quiz", canvas_type="quiz", engine="classic_quiz",
                         grade_weight_pct=30.0, submission_types=["online_quiz"]),
        ]
        sc = _call_score(records)
        assert "security_ledger" in sc
        ledger = sc["security_ledger"]
        assert isinstance(ledger, list)
        assert len(ledger) == len(records)
        assert {item["id"] for item in ledger} == {1, 2, 3}

    def test_security_ledger_entry_field_shape(self):
        records = [_make_record(id=1, title="Essay", grade_weight_pct=50.0)]
        sc = _call_score(records)
        entry = sc["security_ledger"][0]
        expected_keys = {
            "id", "title", "grade_weight_pct", "graded", "security",
            "security_basis", "security_evidence", "needs_ai_review",
            "design", "design_basis",
        }
        assert expected_keys <= set(entry.keys())

    def test_tier_and_coverage_still_present_deprecated(self):
        """Blast-radius guardrail (plan %3): tier / assurance_coverage_pct must
        still be present in the scorecard JSON alongside the new ledger."""
        records = [_make_record(id=1, title="Essay", grade_weight_pct=50.0)]
        sc = _call_score(records)
        assert "tier" in sc
        assert "assurance_coverage_pct" in sc
        assert "security_ledger" in sc


class TestSecurityLedgerSecurityLabel:
    """Criteria 2-6: hybrid security/security_basis/needs_ai_review detection."""

    def test_criterion_2_proctored_item_secured_identity_deterministic(self):
        """Proctored/LockDown item -> secured, basis identity, no AI input needed."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Proctored Final", proctoring_signal=True,
                            canvas_type="quiz", engine="classic_quiz",
                            grade_weight_pct=40.0, submission_types=["external_tool"])
        ledger = _compute_security_ledger([rec])  # no ai_securing passed at all
        entry = ledger[0]
        assert entry["security"] == "secured"
        assert entry["security_basis"] == "identity"
        assert entry["security_evidence"] == "proctoring_signal (API fact)"
        assert entry["needs_ai_review"] is False

    def test_criterion_2_proctoring_wins_even_with_unsecured_ai_verdict(self):
        """House rule: an AI 'unsecured' judgment cannot downgrade a proctored item."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Proctored Final", proctoring_signal=True,
                            canvas_type="quiz", engine="classic_quiz",
                            grade_weight_pct=40.0, submission_types=["external_tool"])
        ai_securing = {1: {"secured": False, "kind": "unsecured", "evidence": "no process trail"}}
        entry = _compute_security_ledger([rec], ai_securing)[0]
        assert entry["security"] == "secured"
        assert entry["security_basis"] == "identity"

    def test_criterion_3_ai_securing_cited_entry_promotes_to_secured_authorship(self):
        """ai_securing entry {secured: True, evidence: '<cite>'} -> secured, basis authorship."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Milestoned Research Paper", grade_weight_pct=50.0,
                            submission_types=["online_upload"])
        ai_securing = {1: {"secured": True, "kind": "process",
                           "evidence": "Weekly decision log entries citing specific choices."}}
        entry = _compute_security_ledger([rec], ai_securing)[0]
        assert entry["security"] == "secured"
        assert entry["security_basis"] == "authorship"
        assert entry["security_evidence"] == "Weekly decision log entries citing specific choices."
        assert entry["needs_ai_review"] is False

    def test_criterion_4_cite_or_fail_secured_true_no_evidence_is_not_secured(self):
        """ai_securing entry {secured: True, evidence: ''} -> NOT secured (cite-or-fail)."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Take Home Essay", grade_weight_pct=50.0,
                            submission_types=["online_upload"])
        ai_securing = {1: {"secured": True, "kind": "process", "evidence": ""}}
        entry = _compute_security_ledger([rec], ai_securing)[0]
        assert entry["security"] == "unsecured"
        assert entry["security_basis"] == "none"

    def test_criterion_4_cite_or_fail_whitespace_only_evidence_is_not_secured(self):
        """Whitespace-only evidence must not count as a citation either."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Take Home Essay", grade_weight_pct=50.0,
                            submission_types=["online_upload"])
        ai_securing = {1: {"secured": True, "kind": "process", "evidence": "   "}}
        entry = _compute_security_ledger([rec], ai_securing)[0]
        assert entry["security"] == "unsecured"

    def test_criterion_5_nominated_item_no_ai_securing_is_unsecured_needs_review(self):
        """Keyword-nominated authorship anchor with no ai_securing entry -> unsecured,
        needs_ai_review True (never auto-secured on a keyword alone)."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Portfolio with Decision Log", grade_weight_pct=30.0,
                            submission_types=["online_upload"])
        entry = _compute_security_ledger([rec])[0]
        assert entry["security"] == "unsecured"
        assert entry["security_basis"] == "none"
        assert entry["needs_ai_review"] is True

    def test_criterion_5_structural_nomination_peer_review_no_ai_securing(self):
        """Structural nomination (peer_reviews=True) also flags needs_ai_review,
        never auto-secures."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Group Project", grade_weight_pct=20.0,
                            submission_types=["online_upload"], peer_reviews=True)
        entry = _compute_security_ledger([rec])[0]
        assert entry["security"] == "unsecured"
        assert entry["needs_ai_review"] is True

    def test_criterion_5_structural_nomination_media_recording(self):
        """submission_types containing media_recording nominates (oral/authorship signal)."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Video Response", grade_weight_pct=20.0,
                            submission_types=["media_recording"])
        entry = _compute_security_ledger([rec])[0]
        assert entry["needs_ai_review"] is True

    def test_criterion_5_rubric_excerpt_process_signal_nominates(self):
        """rubric_excerpt referencing drafts/process/revision nominates the item."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Case Study", grade_weight_pct=20.0,
                            submission_types=["online_upload"])
        rec["rubric_excerpt"] = "Criterion 1: submits full draft and revision history."
        entry = _compute_security_ledger([rec])[0]
        assert entry["needs_ai_review"] is True

    def test_criterion_5_expanded_nominator_keywords(self):
        """Expanded nominator keywords (plan %5a) each independently nominate."""
        from assurance_audit import _compute_security_ledger
        titles = [
            "Weekly Decision Log", "Graded AI-Use Appendix", "Milestone Check-In",
            "Version History Submission", "Commit History Review", "Process Memo",
            "Personal Reflection on Local Practice",
        ]
        for i, title in enumerate(titles, start=1):
            rec = _make_record(id=i, title=title, grade_weight_pct=10.0,
                                submission_types=["online_upload"])
            entry = _compute_security_ledger([rec])[0]
            assert entry["needs_ai_review"] is True, f"{title!r} should nominate"

    def test_criterion_5_needs_review_false_when_ai_securing_entry_exists_unconfirmed(self):
        """When an ai_securing entry already exists (even secured=False), that IS
        the AI's review -- needs_ai_review must not ALSO fire (mirrors
        _package_lane_review_items's 'authoritative once reviewed' convention)."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Portfolio with Decision Log", grade_weight_pct=30.0,
                            submission_types=["online_upload"])
        ai_securing = {1: {"secured": False, "kind": "unsecured", "evidence": "no real trail found"}}
        entry = _compute_security_ledger([rec], ai_securing)[0]
        assert entry["security"] == "unsecured"
        assert entry["needs_ai_review"] is False

    def test_criterion_6_plain_take_home_no_signal_unsecured_no_review(self):
        """Plain take-home item with no signal -> unsecured, basis none,
        needs_ai_review False."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Weekly Assignment", grade_weight_pct=10.0,
                            submission_types=["online_upload"])
        entry = _compute_security_ledger([rec])[0]
        assert entry["security"] == "unsecured"
        assert entry["security_basis"] == "none"
        assert entry["security_evidence"] == ""
        assert entry["needs_ai_review"] is False

    def test_string_keyed_ai_securing_still_applies_to_ledger(self):
        """ai_securing survives JSON round-trips that stringify keys."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Milestoned Paper", grade_weight_pct=50.0,
                            submission_types=["online_upload"])
        ai_securing_str = {"1": {"secured": True, "kind": "process", "evidence": "Drafts."}}
        entry = _compute_security_ledger([rec], ai_securing_str)[0]
        assert entry["security"] == "secured"
        assert entry["security_basis"] == "authorship"

    def test_graded_field_true_when_points_positive(self):
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Essay", points=50.0, grade_weight_pct=10.0)
        entry = _compute_security_ledger([rec])[0]
        assert entry["graded"] is True

    def test_graded_field_false_when_zero_points(self):
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Ungraded Survey", points=0.0, grade_weight_pct=0.0)
        entry = _compute_security_ledger([rec])[0]
        assert entry["graded"] is False


class TestSecurityLedgerDesignLabel:
    """Criteria 7-8: design/design_basis (ai_forward / ai_resistant / n/a)."""

    def test_criterion_7_auto_graded_objective_quiz_is_na(self):
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Chapter Quiz", canvas_type="quiz", engine="classic_quiz",
                            grade_weight_pct=10.0, submission_types=["online_quiz"])
        entry = _compute_security_ledger([rec])[0]
        assert entry["design"] == "n/a"
        assert entry["design_basis"] == ""

    def test_criterion_8_ai_use_appendix_critique_signal_is_ai_forward(self):
        """Free-response item with an AI-use-appendix / critique-the-AI signal -> ai_forward."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Critique the AI Output: AI Appendix Required",
                            grade_weight_pct=20.0, submission_types=["online_upload"])
        entry = _compute_security_ledger([rec])[0]
        assert entry["design"] == "ai_forward"
        assert entry["design_basis"]

    def test_criterion_8_authorship_local_binding_item_is_ai_resistant(self):
        """An authorship/local-binding item (no AI-use signal) -> ai_resistant."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Portfolio Reflection Journal", grade_weight_pct=20.0,
                            submission_types=["online_upload"])
        entry = _compute_security_ledger([rec])[0]
        assert entry["design"] == "ai_resistant"

    def test_low_confidence_plain_item_defaults_ai_resistant(self):
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=1, title="Weekly Assignment", grade_weight_pct=10.0,
                            submission_types=["online_upload"])
        entry = _compute_security_ledger([rec])[0]
        assert entry["design"] == "ai_resistant"
        assert "low-confidence" in entry["design_basis"]

    def test_criterion_8_ai_forward_signal_in_content_excerpt_not_title(self):
        """Regression (PR #269 review): the AI-forward instruction normally
        lives in the prompt body, not the title. A judgment + AI-use-appendix
        signal in content_excerpt with a plain title must read ai_forward, not
        the title-only ai_resistant misread."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=6, title="AI Critique Assignment",
                            grade_weight_pct=25.0, submission_types=["online_upload"])
        rec["content_excerpt"] = (
            "submit an AI-use appendix documenting what you accepted and rejected from the AI"
        )
        entry = _compute_security_ledger([rec])[0]
        assert entry["design"] == "ai_forward"
        assert "prompt body" in entry["design_basis"]

    def test_criterion_8_ai_forward_signal_in_rubric_excerpt_not_title(self):
        """Regression (PR #269 review): the AI-forward signal in the rubric
        criteria (not the title) must also read ai_forward."""
        from assurance_audit import _compute_security_ledger
        rec = _make_record(id=7, title="Weekly Analysis",
                            grade_weight_pct=20.0, submission_types=["online_upload"])
        rec["rubric_excerpt"] = (
            "Criterion 3: student must critique and verify the AI output and submit a prompt log"
        )
        entry = _compute_security_ledger([rec])[0]
        assert entry["design"] == "ai_forward"
        assert "rubric" in entry["design_basis"]

    def test_design_uses_same_d4_d5_signals_as_course_level_axis(self):
        """design must never disagree with the course-level AI-Forward axis it
        shares signals with: an item scoring D4>=3 in the (title-only)
        course-level axis must also read ai_forward at the per-item ledger
        level. The per-item read is a SUPERSET of the title-only anchor (it also
        scans body + rubric), so this implication holds in the title-signal
        direction."""
        from assurance_audit import (
            _aiforward_item_anchor_d4, _aiforward_item_anchor_d5, _classify_design,
        )
        rec = _make_record(id=1, title="Justify and Critique the AI-Generated Draft",
                            grade_weight_pct=20.0, submission_types=["online_upload"])
        title_lower = rec["title"].lower()
        d4 = _aiforward_item_anchor_d4(title_lower)
        d5 = _aiforward_item_anchor_d5(title_lower)
        design, _basis = _classify_design(rec)
        if d4 >= 3 or d5 >= 3:
            assert design == "ai_forward"
