"""Tests for scripts/assurance_report_html.py -- two-axis six-section report.

The report renders in the brief's editorial design system over an authored
``narrative["report"]`` object, running SECURITY (green) and DESIGN (maroon) as
two parallel axes. Sections 01 (How to Read) and 06 (Method) are static; 02
(Bottom Line) always renders from the scorecard; 03/04/05 render only when the
narrative supplies their data.

Section ids: how-to-read, the-bottom-line, course-snapshot, where-it-needs-work,
recommendations, method.
"""
from __future__ import annotations

import json
import sys
from pathlib import Path

import pytest

_scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
if str(_scripts_dir) not in sys.path:
    sys.path.insert(0, str(_scripts_dir))

_fixtures_dir = Path(__file__).resolve().parent / "fixtures"

import assurance_report_html as arh  # noqa: E402


def _load_scorecard() -> dict:
    with (_fixtures_dir / "assurance_scorecard_sample.json").open(encoding="utf-8") as fh:
        return json.load(fh)


def _sample_report() -> dict:
    """A full narrative[report] object exercising every section."""
    return {
        "report": {
            "period_lines": ["ASUO", "FY24-26"],
            "verdict": {
                "proof": {"answer": "Yes, for most of the grade.",
                          "detail": "The final and unit quizzes are proctored."},
                "design": {"answer": "No, it mostly tests memory.",
                           "detail": "The secured work is auto-graded multiple choice."},
                "tension": "the course is well secured but shallow.",
                "stats": [
                    {"value": "64%", "label": "Secured", "kind": "sec"},
                    {"value": "Needs Redesign", "label": "Design", "kind": "warn"},
                    {"value": 24, "label": "Graded Items"},
                    {"value": 5, "label": "Assessment Types"},
                ],
            },
            "snapshot": {
                "security_types": [
                    {"label": "Final Exam", "count": 1, "weight_pct": 29,
                     "secured_share_pct": 100, "secured": True},
                    {"label": "Reading Quizzes", "count": 15, "weight_pct": 23,
                     "secured_share_pct": 0, "secured": False},
                ],
                "security_note": "64% secured. 3 unpublished items were excluded.",
                "measures": {"segments": [{"level": "Remember", "pct": 60},
                                          {"level": "Understand", "pct": 22},
                                          {"level": "Apply", "pct": 18}],
                             "note": "Remember / Understand 82%, Apply 18%."},
                "ai_use": "level 2 of 5. A course-wide syllabus policy exists.",
            },
            "gaps": {
                "lede": "Security gaps and design gaps barely overlap.",
                "security": {"items": [{"lead": "Reading Quizzes, 23%.",
                                        "text": "Auto-graded, open, no lock."}],
                             "note": "The other 63% is proctored."},
                "design": {"items": [{"lead": "Final Exam, 29% (secured).",
                                      "text": "Proctored, but recall."}],
                           "note": "The proctored core is the design problem."},
            },
            "recommendations": [
                {"type": "Final Exam", "weight_pct": 29,
                 "tags": [{"label": "Secured", "kind": "sec"},
                          {"label": "design gap", "kind": "vul"}],
                 "forward": ["Convert part to a short written argument."],
                 "resistant": ["Draw items from a larger pool."]},
                {"type": "Reading Quizzes", "weight_pct": 23,
                 "tags": [{"label": "security gap", "kind": "vul"}],
                 "forward": ["Add a verify-the-AI item with a model answer up front."],
                 "resistant": ["Ask for a one-line personal reaction."],
                 "last_resort": "proctoring or an oral check."},
            ],
        }
    }


_ALL_SECTION_IDS = [
    'id="how-to-read"', 'id="the-bottom-line"', 'id="course-snapshot"',
    'id="where-it-needs-work"', 'id="recommendations"', 'id="method"',
]


# ---------------------------------------------------------------------------
# 1. Deterministic-only render (no narrative)
# ---------------------------------------------------------------------------

class TestDeterministicOnly:
    def setup_method(self):
        self.sc = _load_scorecard()
        self.html = arh.render(self.sc)

    def test_doctype(self):
        assert self.html.startswith("<!DOCTYPE html")

    def test_has_style_block(self):
        assert "<style>" in self.html

    def test_no_script_tag(self):
        assert "<script" not in self.html

    def test_static_sections_always_present(self):
        # 01 How to Read, 02 Bottom Line, and 06 Method render without a narrative.
        assert 'id="how-to-read"' in self.html
        assert 'id="the-bottom-line"' in self.html
        assert 'id="method"' in self.html

    def test_narrative_only_sections_absent_without_narrative(self):
        # Where It Needs Work and Recommendations are fully narrative-authored,
        # so they stay absent without a narrative. Course Snapshot's SECURITY
        # half is now scorecard-driven (security_ledger) and renders regardless
        # -- see test_security_ledger_renders_without_narrative below.
        assert 'id="where-it-needs-work"' not in self.html
        assert 'id="recommendations"' not in self.html

    def test_security_ledger_renders_without_narrative(self):
        # security_ledger lives on the scorecard, not the narrative, so the
        # Course Snapshot SECURITY table renders deterministically even with
        # no narrative object at all (plan: assurance-binary-labels %6a).
        assert 'id="course-snapshot"' in self.html
        assert 'class="subhead sec"' in self.html
        assert '<span class="tag sec">Secured</span>' in self.html
        assert '<span class="tag vul">Unsecured</span>' in self.html

    def test_verdict_matrix_renders(self):
        assert "<svg" in self.html
        assert "PROVES A REAL STUDENT DID IT" in self.html

    def test_two_axis_frame_present(self):
        # Section 01 names both axes explicitly.
        assert "Security:" in self.html
        assert "Design:" in self.html

    def test_default_stat_leads_with_secured_count_not_percentage(self):
        # scorecard fixture: 1 of 4 graded ledger items secured -> "1/4" in the
        # fallback stat band. No coverage percentage renders (plan %6a).
        assert "1/4" in self.html
        assert "25%" not in self.html


# ---------------------------------------------------------------------------
# 2. Full narrative report: all six sections + order
# ---------------------------------------------------------------------------

class TestFullReport:
    def setup_method(self):
        self.sc = _load_scorecard()
        self.html = arh.render(self.sc, _sample_report())

    def test_all_sections_present(self):
        for sid in _ALL_SECTION_IDS:
            assert sid in self.html, f"missing {sid}"

    def test_section_order(self):
        positions = [self.html.index(sid) for sid in _ALL_SECTION_IDS]
        assert positions == sorted(positions), "sections out of order"

    def test_method_is_last(self):
        method_pos = self.html.index('id="method"')
        for sid in _ALL_SECTION_IDS[:-1]:
            assert self.html.index(sid) < method_pos

    def test_security_subhead_green(self):
        assert 'class="subhead sec"' in self.html

    def test_design_subhead_maroon(self):
        assert 'class="subhead des"' in self.html

    def test_security_table_secured_and_unsecured_tags(self):
        assert '<span class="tag sec">Secured</span>' in self.html
        assert '<span class="tag vul">Unsecured</span>' in self.html

    def test_security_table_has_basis_and_design_columns(self):
        # Columns are Assessment | Secured? | Basis | AI-Forward/Resistant --
        # plain labels, no share percentage (plan: assurance-binary-labels %6a).
        assert "<th>Basis</th>" in self.html
        assert "AI-Forward / Resistant" in self.html
        assert "Identity" in self.html or "Authorship" in self.html

    def test_needs_review_marker_on_unconfirmed_nominee(self):
        # security_ledger fixture id 5003 is unsecured with needs_ai_review=True.
        assert "needs review" in self.html

    def test_no_secured_share_bar_or_percentage_in_security_table(self):
        # The old per-type "Secured Share" progress bar (a percentage-width bar)
        # is gone; the new per-assessment ledger table carries no share percent.
        assert "Secured Share" not in self.html
        table_start = self.html.index('id="course-snapshot"')
        table_end = self.html.index("</table>", table_start)
        security_table_html = self.html[table_start:table_end]
        assert "%" not in security_table_html

    def test_gap_columns_present(self):
        assert "Security Gaps" in self.html
        assert "Design Gaps" in self.html

    def test_ai_forward_emphasized(self):
        assert 'class="move fwd"' in self.html
        assert "AI-Forward" in self.html
        assert "recommended" in self.html

    def test_last_resort_labeled(self):
        assert 'class="move last"' in self.html
        assert "Last resort:" in self.html

    def test_tension_callout(self):
        assert "The Tension:" in self.html

    def test_period_lines_in_masthead(self):
        assert "ASUO" in self.html

    def test_stat_band_kinds(self):
        assert '"fig sec"' in self.html or 'class="fig sec"' in self.html
        assert 'class="fig warn' in self.html


# ---------------------------------------------------------------------------
# 3. Missing / null resilience
# ---------------------------------------------------------------------------

class TestMissingFields:
    def test_empty_scorecard(self):
        assert "<!DOCTYPE html" in arh.render({})

    def test_empty_narrative_dict(self):
        assert "<!DOCTYPE html" in arh.render({}, {})

    def test_null_report_object(self):
        assert "<!DOCTYPE html" in arh.render({}, {"report": None})

    def test_partial_verdict_only(self):
        narr = {"report": {"verdict": {"proof": {"answer": "Yes."}}}}
        html = arh.render({}, narr)
        assert 'id="the-bottom-line"' in html
        assert 'id="course-snapshot"' not in html

    def test_snapshot_without_security_types(self):
        narr = {"report": {"snapshot": {"ai_use": "level 1 of 5."}}}
        html = arh.render({}, narr)
        assert 'id="course-snapshot"' in html
        assert "level 1 of 5." in html

    def test_bad_bloom_pct_is_dropped(self):
        narr = {"report": {"snapshot": {"measures": {"segments": [
            {"level": "Apply", "pct": "bad"}, {"level": "Create", "pct": 40}]}}}}
        html = arh.render({}, narr)
        assert "<!DOCTYPE html" in html
        assert "Create" in html and "40%" in html

    def test_zero_weight_bloom_level_omitted(self):
        narr = {"report": {"snapshot": {"measures": {"segments": [
            {"level": "Analyze", "pct": 0}, {"level": "Apply", "pct": 100}]}}}}
        html = arh.render({}, narr)
        assert "Analyze" not in html   # zero-weight level never gets a swatch

    def test_bad_secured_share_does_not_raise(self):
        narr = {"report": {"snapshot": {"security_types": [
            {"label": "X", "count": 1, "weight_pct": 10,
             "secured_share_pct": "bad", "secured": False}]}}}
        assert "<!DOCTYPE html" in arh.render({}, narr)

    def test_method_static_defaults_present(self):
        html = arh.render({})
        assert "omit-from-final-grade" in html
        assert "Within-course diagnostic" in html

    def test_method_sources_render(self):
        narr = {"report": {"method": {"sources": [
            {"label": "TILT", "cite": "Winkelmes 2016"},
            {"label": "Two-Lane", "cite": "University of Sydney"},
        ]}}}
        html = arh.render({}, narr)
        assert "<b>TILT:</b> Winkelmes 2016" in html
        assert "<b>Two-Lane:</b> University of Sydney" in html
        assert "Sources" in html

    def test_method_calibration_renders_with_bold_lead(self):
        narr = {"report": {"method": {"calibration": "House defaults, not findings."}}}
        html = arh.render({}, narr)
        assert "<b>Calibration:</b> House defaults, not findings." in html

    def test_method_calibration_and_sources_absent_when_not_supplied(self):
        html = arh.render({})
        assert "Calibration:" not in html
        assert "Sources" not in html


# ---------------------------------------------------------------------------
# 3b. Bloom scale: ordinal sequential ramp + legend
# ---------------------------------------------------------------------------

def _rgb(hexcolor: str) -> tuple[int, int, int]:
    return tuple(int(hexcolor[i:i + 2], 16) for i in (1, 3, 5))


def _dist(a: tuple, b: tuple) -> float:
    return sum((x - y) ** 2 for x, y in zip(a, b)) ** 0.5


class TestBloomScale:
    def _html(self, segments):
        return arh.render({}, {"report": {"snapshot": {"measures": {"segments": segments}}}})

    def test_segments_sorted_into_canonical_bloom_order(self):
        # Supplied out of order; must render Remember -> Apply -> Create.
        out = arh._bloom_segments([{"level": "Create", "pct": 10},
                                   {"level": "Remember", "pct": 50},
                                   {"level": "Apply", "pct": 40}])
        assert [lvl for lvl, _ in out] == ["Remember", "Apply", "Create"]

    def test_unknown_level_sorts_last_and_still_renders(self):
        out = arh._bloom_segments([{"level": "Frobnicate", "pct": 10},
                                   {"level": "Apply", "pct": 90}])
        assert [lvl for lvl, _ in out] == ["Apply", "Frobnicate"]
        html = self._html([{"level": "Frobnicate", "pct": 10}])
        assert "Frobnicate" in html

    def test_each_level_gets_a_distinct_color(self):
        colors = [arh._BLOOM_COLORS[l] for l in arh._BLOOM_ORDER]
        assert len(set(colors)) == len(colors), "Bloom colors must be distinct"

    def test_palette_is_the_canonical_bloom_scale(self):
        # The Vanderbilt CFT scale: blue at Remember rising through green and
        # yellow to red at Create. Readers already know it; do not reinvent it.
        assert [arh._BLOOM_COLORS[l] for l in arh._BLOOM_ORDER] == [
            "#4f81bd", "#63b0e0", "#34bf5a", "#8cc63f", "#fdc82f", "#ee5a3a"]

    def test_adjacent_levels_are_visually_separated(self):
        # Hue carries the level, so neighbors must not blur into one another.
        # Actual minimum is 61.9 (Remember/Understand); the bound leaves headroom.
        cols = [_rgb(arh._BLOOM_COLORS[l]) for l in arh._BLOOM_ORDER]
        for i in range(len(cols) - 1):
            d = _dist(cols[i], cols[i + 1])
            assert d >= 55, (
                f"{arh._BLOOM_ORDER[i]} and {arh._BLOOM_ORDER[i+1]} too close: {d:.1f}")

    def test_bloom_colors_stay_clear_of_the_axis_colors(self):
        # green = Security, maroon = Design. The Bloom scale borrows green, so an
        # exact-match check is not enough: hold every level well away from both.
        # The tightest pair is Apply vs the Security green at 77.3, so a bound of
        # 70 fails before a drifting palette can make the two greens confusable.
        for axis in ("#2f7d32", "#8c1d40"):
            for level, color in arh._BLOOM_COLORS.items():
                d = _dist(_rgb(color), _rgb(axis))
                assert d >= 70, f"{level} ({color}) too near axis color {axis}: {d:.1f}"

    def test_legend_names_each_level_with_its_share(self):
        html = self._html([{"level": "Remember", "pct": 14.3},
                           {"level": "Apply", "pct": 68.5}])
        assert 'class="legend"' in html
        assert "Remember" in html and "14.3%" in html
        assert "Apply" in html and "68.5%" in html

    def test_bar_has_one_segment_per_level(self):
        html = self._html([{"level": "Remember", "pct": 30},
                           {"level": "Apply", "pct": 70}])
        bar = html.split('class="bar bloom"')[1].split("</div>")[0]
        assert bar.count("<i ") == 2

    def test_empty_segments_render_no_bar(self):
        html = self._html([])
        assert 'class="bar bloom"' not in html


# ---------------------------------------------------------------------------
# 4. HTML escaping / injection safety
# ---------------------------------------------------------------------------

class TestHTMLEscaping:
    def test_script_in_course_name_escaped(self):
        html = arh.render({"course": {"name": "<script>xss</script>"}})
        assert "<script>" not in html
        assert "&lt;script&gt;" in html

    def test_script_in_verdict_detail_escaped(self):
        narr = {"report": {"verdict": {"proof": {"answer": "<script>a()</script>"}}}}
        html = arh.render({}, narr)
        assert "<script>" not in html
        assert "&lt;script&gt;" in html

    def test_script_in_security_type_label_escaped(self):
        narr = {"report": {"snapshot": {"security_types": [
            {"label": "<script>x</script>", "count": 1, "weight_pct": 5,
             "secured_share_pct": 0, "secured": False}]}}}
        html = arh.render({}, narr)
        assert "<script>" not in html

    def test_script_in_recommendation_forward_escaped(self):
        narr = {"report": {"recommendations": [
            {"type": "Q", "forward": ["<script>evil()</script>"]}]}}
        html = arh.render({}, narr)
        assert "<script>" not in html
        assert "&lt;script&gt;" in html

    def test_script_in_gap_item_escaped(self):
        narr = {"report": {"gaps": {"security": {"items": [
            {"lead": "<script>a</script>", "text": "b"}]}}}}
        html = arh.render({}, narr)
        assert "<script>" not in html

    def test_ampersand_escaped(self):
        html = arh.render({"course": {"name": "Ethics & AI"}})
        assert "Ethics &amp; AI" in html

    def test_script_in_method_sources_and_calibration_escaped(self):
        narr = {"report": {"method": {
            "calibration": "<script>a()</script>",
            "sources": [{"label": "<script>b()</script>", "cite": "<script>c()</script>"}],
        }}}
        html = arh.render({}, narr)
        assert "<script>" not in html
        assert "&lt;script&gt;" in html


# ---------------------------------------------------------------------------
# 5. No framework jargon leaks into user-facing text
# ---------------------------------------------------------------------------

class TestNoJargon:
    def test_no_internal_field_jargon(self):
        html = arh.render(_load_scorecard(), _sample_report())
        for token in ["Dimension", "5DF", "needs_ai_review", "lane_summary",
                      "ai_forward_rating"]:
            assert token not in html, f"jargon leaked: {token!r}"


# ---------------------------------------------------------------------------
# 6. Self-contained HTML structure
# ---------------------------------------------------------------------------

class TestHTMLStructure:
    def setup_method(self):
        self.html = arh.render(_load_scorecard(), _sample_report())

    def test_doctype(self):
        assert self.html.startswith("<!DOCTYPE html")

    def test_has_head_body(self):
        assert "<head>" in self.html and "<body>" in self.html

    def test_style_block(self):
        assert "<style>" in self.html and "</style>" in self.html

    def test_no_script(self):
        assert "<script" not in self.html

    def test_no_external_assets(self):
        assert '<link rel="stylesheet"' not in self.html
        assert "@import url" not in self.html
        assert "fonts.googleapis" not in self.html

    def test_print_rules(self):
        assert "@media print" in self.html
        assert "@page" in self.html
        assert "size:Letter" in self.html
        assert "print-color-adjust:exact" in self.html


# ---------------------------------------------------------------------------
# 7. Generated-date handling
# ---------------------------------------------------------------------------

class TestGeneratedDate:
    def test_date_appears_when_provided(self):
        html = arh.render(_load_scorecard(), generated_date="2026-06-29")
        assert "2026-06-29" in html

    def test_date_absent_when_not_provided(self):
        html = arh.render(_load_scorecard())
        assert "generated_date" not in html

    def test_empty_date_ok(self):
        assert "<!DOCTYPE html" in arh.render(_load_scorecard(), generated_date="")


# ---------------------------------------------------------------------------
# 8. APA title-case helper
# ---------------------------------------------------------------------------

class TestAPATitleCase:
    def test_minor_words_lowercased_internally(self):
        assert arh._apa_title_case("assessment and ai integration") == "Assessment and Ai Integration"

    def test_first_and_last_capped(self):
        assert arh._apa_title_case("the cost of quality") == "The Cost of Quality"

    def test_hyphenated_parts_capped(self):
        assert arh._apa_title_case("re-lane") == "Re-Lane"

    def test_acronyms_preserved(self):
        assert arh._apa_title_case("map to CLO 3 for WCAG") == "Map to CLO 3 for WCAG"

    def test_empty(self):
        assert arh._apa_title_case("") == ""

    def test_rating_default_stat_is_title_cased(self):
        # ai_forward_rating "Needs redesign" -> title-cased in the fallback stat band.
        sc = {"course": {"name": "C"}, "ai_forward_rating": "needs redesign",
              "assurance_coverage_pct": 10.0}
        html = arh.render(sc)
        assert "Needs Redesign" in html


# ---------------------------------------------------------------------------
# 9. CLI + PDF flag
# ---------------------------------------------------------------------------

class TestCLI:
    def test_parse_args(self, tmp_path):
        sc_file = tmp_path / "sc.json"
        sc_file.write_text("{}", encoding="utf-8")
        out_file = tmp_path / "out.html"
        args = arh._parse_args(["--scorecard", str(sc_file), "--output", str(out_file)])
        assert args.scorecard == str(sc_file)
        assert args.output == str(out_file)
        assert args.narrative is None
        assert args.pdf is None

    def test_main_writes_output(self, tmp_path):
        sc_file = tmp_path / "sc.json"
        sc_file.write_text(json.dumps(_load_scorecard()), encoding="utf-8")
        out_file = tmp_path / "report.html"
        arh.main(["--scorecard", str(sc_file), "--output", str(out_file),
                  "--generated-date", "2026-06-29"])
        content = out_file.read_text(encoding="utf-8")
        assert "<!DOCTYPE html" in content
        assert "2026-06-29" in content

    def test_main_with_narrative(self, tmp_path):
        sc_file = tmp_path / "sc.json"
        sc_file.write_text(json.dumps(_load_scorecard()), encoding="utf-8")
        narr_file = tmp_path / "narr.json"
        narr_file.write_text(json.dumps(_sample_report()), encoding="utf-8")
        out_file = tmp_path / "report.html"
        arh.main(["--scorecard", str(sc_file), "--narrative", str(narr_file),
                  "--output", str(out_file)])
        content = out_file.read_text(encoding="utf-8")
        assert 'id="course-snapshot"' in content
        assert 'id="recommendations"' in content

    def test_main_exits_on_missing_scorecard(self, tmp_path):
        with pytest.raises(SystemExit):
            arh.main(["--scorecard", str(tmp_path / "nope.json"),
                      "--output", str(tmp_path / "out.html")])


class _FakeExporterModule:
    class PDFExportUnavailable(RuntimeError):
        pass

    def __init__(self, *, raises=False, hint="install playwright hint"):
        self._raises = raises
        self._hint = hint
        self.calls = []

    def export_pdf(self, *, html_path, output_path):
        self.calls.append({"html_path": html_path, "output_path": output_path})
        if self._raises:
            raise self.PDFExportUnavailable(self._hint)
        return Path(output_path)


class TestPDFFlag:
    def _write_scorecard(self, tmp_path) -> Path:
        sc_file = tmp_path / "sc.json"
        sc_file.write_text(json.dumps(_load_scorecard()), encoding="utf-8")
        return sc_file

    def test_pdf_flag_calls_exporter(self, tmp_path, monkeypatch):
        fake = _FakeExporterModule()
        monkeypatch.setitem(sys.modules, "report_pdf_exporter", fake)
        sc_file = self._write_scorecard(tmp_path)
        out_file = tmp_path / "report.html"
        pdf_file = tmp_path / "report.pdf"
        arh.main(["--scorecard", str(sc_file), "--output", str(out_file),
                  "--pdf", str(pdf_file)])
        assert out_file.exists()
        assert len(fake.calls) == 1
        assert str(fake.calls[0]["output_path"]) == str(pdf_file)

    def test_pdf_unavailable_exits_nonzero(self, tmp_path, monkeypatch, capsys):
        fake = _FakeExporterModule(raises=True, hint="PDF export needs Playwright")
        monkeypatch.setitem(sys.modules, "report_pdf_exporter", fake)
        sc_file = self._write_scorecard(tmp_path)
        out_file = tmp_path / "report.html"
        pdf_file = tmp_path / "report.pdf"
        with pytest.raises(SystemExit) as exc_info:
            arh.main(["--scorecard", str(sc_file), "--output", str(out_file),
                      "--pdf", str(pdf_file)])
        assert exc_info.value.code != 0
        assert "Playwright" in capsys.readouterr().err
        assert out_file.exists()
        assert not pdf_file.exists()

    def test_no_pdf_flag_does_not_import_exporter(self, tmp_path, monkeypatch):
        fake = _FakeExporterModule()
        monkeypatch.setitem(sys.modules, "report_pdf_exporter", fake)
        sc_file = self._write_scorecard(tmp_path)
        out_file = tmp_path / "report.html"
        arh.main(["--scorecard", str(sc_file), "--output", str(out_file)])
        assert out_file.exists()
        assert fake.calls == []


# ---------------------------------------------------------------------------
# 10. Verdict-matrix colors (assurance accuracy v2, workstream 4a)
# ---------------------------------------------------------------------------

def _extract_svg(html: str) -> str:
    start = html.index("<svg")
    end = html.index("</svg>") + len("</svg>")
    return html[start:end]


class TestVerdictMatrixColors:
    """GOAL marker + label -> gold; course dot -> ink; no green/maroon on
    either marker (those are reserved for the Security/Design axes elsewhere
    in the report). The CSS <style> block legitimately keeps #2f7d32/#8c1d40
    as --good/--accent for the rest of the report, so these assertions scope
    to the <svg>...</svg> substring only, not the whole page."""

    def _svg(self, **scorecard_kwargs) -> str:
        sc = {"course": {"name": "PHI 101"}, "assurance_coverage_pct": 25.0,
              "ai_forward_rating": "Needs redesign"}
        sc.update(scorecard_kwargs)
        html = arh.render(sc)
        return _extract_svg(html)

    def test_no_green_goal_fill(self):
        assert "#2f7d32" not in self._svg()

    def test_no_maroon_anywhere_in_matrix(self):
        assert "#8c1d40" not in self._svg()

    def test_goal_marker_uses_gold(self):
        svg = self._svg()
        assert "#ffc627" in svg

    def test_course_dot_uses_ink(self):
        svg = self._svg()
        assert 'fill="#12100e"' in svg

    def test_proven_but_shallow_label_is_muted_not_maroon(self):
        svg = self._svg()
        # "Proven, but" / "Shallow" quadrant label must render in the same
        # muted gray as the other two quadrant labels, not maroon.
        assert svg.count("#8f877a") >= 3  # Not Proven + Neither + Proven-but-Shallow (x2 lines)

    def test_svg_matches_between_report_and_brief(self):
        """The report imports _verdict_svg from assurance_brief_html directly
        -- both matrices are the SAME function, so they can never drift."""
        import assurance_brief_html as brief_module
        assert arh._verdict_svg is brief_module._verdict_svg
        a = arh._verdict_svg("PHI 101", 120.0, 60.0)
        b = brief_module._verdict_svg("PHI 101", 120.0, 60.0)
        assert a == b


# ---------------------------------------------------------------------------
# 11. Secured definition (assurance accuracy v2, workstream 4b)
# ---------------------------------------------------------------------------

class TestSecuredDefinition:
    def setup_method(self):
        self.html = arh.render({})

    def test_peer_review_removed(self):
        assert "peer review" not in self.html.lower()
        assert "peer-review" not in self.html.lower()

    def test_proctoring_named_last_resort(self):
        assert "last resort" in self.html.lower()

    def test_two_kinds_of_securing_stated(self):
        lowered = self.html.lower()
        assert "identity" in lowered
        assert "authorship evidence" in lowered

    def test_lockdown_browser_still_named_as_a_control(self):
        assert "LockDown Browser" in self.html

    def test_process_evidence_named_as_securing_not_an_open_question(self):
        """docs/plan-assurance-ai-led.md: process evidence is resolved as a
        securing control; the report no longer carries an open-question note
        about it."""
        lowered = self.html.lower()
        assert "decision log" in lowered or "revision history" in lowered
        assert "open question" not in lowered

    def test_process_evidence_presented_as_securing_kind(self):
        # docs/plan-assurance-ai-led.md: resolved -- process evidence (drafts,
        # decision logs, revision history, reflection) IS a securing kind,
        # folded directly into the Secured definition, not held open.
        lowered = self.html.lower()
        assert "authorship evidence" in lowered
        assert "open question" not in lowered


# ---------------------------------------------------------------------------
# 12. Course-level badge (assurance accuracy v2, workstream 4c)
# ---------------------------------------------------------------------------

class TestCourseLevelBadge:
    def test_badge_renders_when_level_label_present(self):
        sc = {"course": {"name": "COM 100", "level_label": "100-level"}}
        html = arh.render(sc)
        assert "Assessment Assurance Report | 100-level" in html

    def test_badge_absent_when_level_label_missing(self):
        sc = {"course": {"name": "COM 100"}}
        html = arh.render(sc)
        assert '<div class="sub1">Assessment Assurance Report</div>' in html
        assert "Assessment Assurance Report |" not in html

    def test_badge_renders_graduate_label(self):
        sc = {"course": {"name": "LAW 524", "level_label": "graduate"}}
        html = arh.render(sc)
        assert "Assessment Assurance Report | graduate" in html

    def test_badge_value_escaped(self):
        sc = {"course": {"name": "X", "level_label": "<script>alert(1)</script>"}}
        html = arh.render(sc)
        assert "<script>alert(1)</script>" not in html
        assert "&lt;script&gt;" in html

    def test_badge_empty_string_treated_as_absent(self):
        sc = {"course": {"name": "COM 100", "level_label": ""}}
        html = arh.render(sc)
        assert '<div class="sub1">Assessment Assurance Report</div>' in html


class TestReportCourseLink:
    """The report masthead links the live Canvas course (scorecard.course.url)."""

    def _html(self, url):
        return arh.render({"course": {"name": "COM 100", "url": url}}, {"report": {}})

    def test_link_renders(self):
        html = self._html("https://canvas.asu.edu/courses/175993")
        assert 'href="https://canvas.asu.edu/courses/175993"' in html
        assert "canvas.asu.edu/courses/175993</a>" in html

    def test_absent_without_url(self):
        assert '<div class="canvaslink">' not in arh.render({"course": {"name": "X"}}, {"report": {}})

    def test_javascript_scheme_dropped(self):
        html = self._html("javascript:alert(1)")
        assert "javascript:" not in html
        assert '<div class="canvaslink">' not in html

    def test_non_http_schemes_dropped(self):
        for bad in ("data:text/html,x", "file:///etc/passwd", "  javascript:x"):
            assert '<div class="canvaslink">' not in self._html(bad)


# ---------------------------------------------------------------------------
# 13. Renderer smoke test: per-assessment labels, no percentage or tier words
# in the featured security view (docs/plan-assurance-binary-labels.md %8,
# acceptance criteria 9)
# ---------------------------------------------------------------------------

def _ledger_scorecard() -> dict:
    """A realistic scorecard (shape matches assurance_audit.score_course's
    output) with no narrative-authored stats/tier text anywhere -- exercises
    the fully deterministic default path."""
    return {
        "course": {"name": "PHI 201: Ethics"},
        "tier": "Partially Assured",
        "assurance_coverage_pct": 42.0,
        "ai_forward_rating": "Use with safeguards",
        "security_ledger": [
            {"id": 1, "title": "Proctored Midterm", "grade_weight_pct": 30.0, "graded": True,
             "security": "secured", "security_basis": "identity",
             "security_evidence": "proctoring_signal (API fact)",
             "needs_ai_review": False, "design": "n/a", "design_basis": ""},
            {"id": 2, "title": "Weekly Reading Response", "grade_weight_pct": 20.0, "graded": True,
             "security": "unsecured", "security_basis": "none", "security_evidence": "",
             "needs_ai_review": False, "design": "ai_resistant",
             "design_basis": "low-confidence deterministic read; defaults to AI-resistant"},
            {"id": 3, "title": "AI-Use Appendix Reflection", "grade_weight_pct": 25.0, "graded": True,
             "security": "unsecured", "security_basis": "none", "security_evidence": "",
             "needs_ai_review": True, "design": "ai_forward",
             "design_basis": "documents AI use for grading, e.g. an AI-use appendix or prompt log"},
        ],
        "dimensions": {"D": {"lane_summary": {
            "lane1_count": 1, "lane2_count": 2, "mismatch_count": 0, "unclassified_count": 0,
        }}},
    }


class TestNoPercentageOrTierInFeaturedSecurityView:
    """Criterion 9: the report renders the per-assessment ledger table and
    shows no percentage and no Assured/Partially/Unassured tier word in the
    featured security view (the section-02 stat band and the section-03
    security table). No narrative is supplied, so this exercises the fully
    deterministic default path -- the one real generated reports fall back to
    whenever Claude has not authored a narrative override."""

    def setup_method(self):
        self.html = arh.render(_ledger_scorecard())

    def test_per_assessment_labels_present(self):
        assert "Proctored Midterm" in self.html
        assert "Weekly Reading Response" in self.html
        assert "AI-Use Appendix Reflection" in self.html
        assert '<span class="tag sec">Secured</span>' in self.html
        assert '<span class="tag vul">Unsecured</span>' in self.html
        assert "AI-Forward" in self.html
        assert "AI-Resistant" in self.html

    def test_needs_review_marker_present(self):
        assert "needs review" in self.html

    def test_no_tier_words_in_featured_view(self):
        for tier_word in ("Assured", "Partially Assured", "Unassured"):
            assert tier_word not in self.html, f"tier word {tier_word!r} leaked into the report"

    def test_no_percentage_in_stat_band_or_security_table(self):
        # Isolate section 02's stat band (the verdict SVG's own fixed
        # "width:100%;height:auto" CSS is unrelated layout markup, not a
        # rendered data value, so it is excluded) and section 03's security
        # table -- the two featured security surfaces -- and assert no "%"
        # appears in either, even though the scorecard carries a 42.0%
        # coverage figure and 30/20/25 percent per-item weights.
        bottom_line_start = self.html.index('id="the-bottom-line"')
        bottom_line_end = self.html.index('</section>', bottom_line_start)
        stats_html = self.html[bottom_line_start:bottom_line_end]
        svg_end = stats_html.index("</svg>") + len("</svg>")
        assert "%" not in stats_html[svg_end:]

        snapshot_start = self.html.index('id="course-snapshot"')
        table_end = self.html.index("</table>", snapshot_start)
        assert "%" not in self.html[snapshot_start:table_end]

    def test_default_stat_shows_secured_count_fraction(self):
        # 1 of 3 graded items secured -> "1/3", not "42%".
        assert "1/3" in self.html
        assert "42%" not in self.html
