# SPDX-License-Identifier: Apache-2.0
"""Fail-closed offline receipt for Foundation HASS Term 1 Weeks 1-2."""

from __future__ import annotations

import argparse
import ast
import contextlib
import copy
import hashlib
import io
import json
import re
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
from urllib.parse import unquote
from zipfile import ZipFile

from PIL import ImageFont

ROOT = Path(__file__).resolve().parent
STUDIO = ROOT.parents[4]
SOURCE_SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
QCAA_URL = (
    "https://www.qcaa.qld.edu.au/p-10/aciq/version-9/"
    "learning-areas/p-10-humanities-and-social-sciences/hass"
)
QCAA_ALIGNMENT_URL = (
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/"
    "humanities-and-social-sciences/curriculum/ac9_hass_prep_as_cd_alignment.pdf"
)
ROWS = {
    "AC9HSFK01": 1213,
    "AC9HSFK02": 1217,
    "AC9HSFK03": 1221,
    "AC9HSFK04": 1226,
    "AC9HSFS01": 1233,
    "AC9HSFS02": 1237,
    "AC9HSFS03": 1241,
    "AC9HSFS04": 1245,
    "AC9HSFS05": 1251,
}
HELD_CODES = {"AC9HSFK01", "AC9HSFK02", "AC9HSFK04"}
TAUGHT_CODES = set(ROWS) - HELD_CODES
WEEK_SPLIT_DAY = 5
LESSON_MINUTES = 25
MIN_PDF_WORDS = 18
STEMS = (
    "paper-learning-place-map",
    "fictional-views-and-care-cards",
    "ask-map-source-mat",
    "picture-record-mat",
    "source-and-conclusion-mat",
    "original-sign-bank",
    "sign-making-timeline",
    "make-a-sign-mat",
    "check-a-fresh-place",
    "check-b-fresh-signs",
    "place-play-card",
)
EXPECTED_CODES = {
    1: {"AC9HSFK03", "AC9HSFS01"},
    2: {"AC9HSFK03", "AC9HSFS02"},
    3: {"AC9HSFK03", "AC9HSFS03"},
    4: {"AC9HSFK03", "AC9HSFS04", "AC9HSFS05"},
    5: {"AC9HSFK03", "AC9HSFS02", "AC9HSFS03", "AC9HSFS04", "AC9HSFS05"},
    6: {"AC9HSFK03", "AC9HSFS01", "AC9HSFS05"},
    7: {"AC9HSFS02", "AC9HSFS05"},
    8: {"AC9HSFS03", "AC9HSFS04", "AC9HSFS05"},
    9: {"AC9HSFK03", "AC9HSFS01", "AC9HSFS05"},
    10: {
        "AC9HSFK03",
        "AC9HSFS01",
        "AC9HSFS02",
        "AC9HSFS03",
        "AC9HSFS04",
        "AC9HSFS05",
    },
}
MAIN_MAP = {
    ("ENTRY", "1", "1"),
    ("STORY MAT", "1", "3"),
    ("MAKER TABLE", "2", "2"),
    ("REST SEAT", "3", "1"),
    ("WATER POINT", "3", "3"),
}
CHECK_A_MAP = {
    ("ENTRY", "1", "1"),
    ("CARD RACK", "1", "2"),
    ("NEST SEAT", "1", "3"),
    ("MODEL TABLE", "2", "1"),
    ("WINDOW PAD", "2", "3"),
}
CHECK_B_MAP = {
    ("ENTRY", "1", "1"),
    ("DRAW WALL", "1", "2"),
    ("CARD DESK", "2", "1"),
    ("REST COVE", "2", "2"),
}
PRINTED_SOURCE_PHRASES = {
    "fictional-views-and-care-cards": (
        (
            "The REST SEAT is special to me in this paper place because I can sit "
            "and look at a book quietly."
        ),
        (
            "The MAKER TABLE is special to me in this paper place because I can try "
            "a new paper design there."
        ),
        (
            "A paper direction sign lies across a drawn walkway. An adult checks "
            "the walkway and puts the sign back where it is visible beside STORY MAT."
        ),
    ),
    "original-sign-bank": ("I am looking for the STORY MAT on this paper map.",),
    "check-a-fresh-place": (
        "NEST SEAT matters: I can look at picture cards quietly.",
        "MODEL TABLE matters: I can build paper shapes.",
        (
            "A paper name label slipped away from CARD RACK. An adult put it back "
            "there so it could be read."
        ),
    ),
    "check-b-fresh-signs": ("I am looking for CARD DESK to get a blank paper card.",),
}


ACTUAL_DAYS = {1, 2, 3, 4, 6, 9}
ORIGINAL_PRINT_SHA = {
    "print/check-a-fresh-place.pdf": "fb22d896e2ffd8c41ca57f2741c80c04ec292b86a9a75f78ed7a0478dc59722d",
    "print/check-b-fresh-signs.svg": "8927a895582316921479251794fa1067f72641796f184c1ed8a74b1b68b6bae5",
    "print/sign-making-timeline.pdf": "93429acc02d85a1cbb2eee5a2c1aa58647aab4dee76d1d10e75534596cd229d4",
    "print/paper-learning-place-map.svg": "497a35b2d8a8f6a43439ebd192a500145baa790e3dea45b6372a9c618c37cfb4",
    "print/fictional-views-and-care-cards.svg": "ac7845665d986cb28d0045631bcbfb803e025836a959b56b524388876f382a82",
    "print/picture-record-mat.svg": "c48335d40ad49a0ae66c03e91956eb297781d759e1bf7fe44d6c20c1fb733816",
    "print/check-a-fresh-place.svg": "a25bda3b4138b040c00e49d593b88dabe89847670802aa65c7e3a8b35a12d6fd",
    "print/ask-map-source-mat.svg": "bcf9a0cb778d61d74d584c95e0fe87c051d3d20ce5406f72b02049a5e98c862d",
    "print/check-b-fresh-signs.pdf": "c020b400e78e5c4d2c2dba251322a7217494862eeb0ae59014e511cd551c5cc7",
    "print/sign-making-timeline.svg": "f0dddb148d1673e65877175440b0c7a438e12fe5098649aef12d5a77523bc6b8",
    "print/original-sign-bank.pdf": "0843558b3b8657a58f0c9a61c70c84974ad2478e763116309734bb970ecc6450",
    "print/original-sign-bank.svg": "001aa4a024ddbb06d90ea3a34cb61bd2bda4a0519f12cf60a5aae4eb6644533e",
    "print/source-and-conclusion-mat.pdf": "7a29c3e06c846f4e9bd457cc4d96f912e530cf9268447f7763249e18f7671f41",
    "print/source-and-conclusion-mat.svg": "41b81a7001cc1e03d643bd66fdb5de9517a35a3c8cbb040b30e81f81500fc6cf",
    "print/make-a-sign-mat.pdf": "723314bc3c7fa991742d7f3ac87325692b657e51117a70e9ed7c0fba6871adf1",
    "print/make-a-sign-mat.svg": "ab97c6c913e4c2ef2607aebe226cb5929173a058a0e8c2cc59b1c775eb3aad4a",
    "print/ask-map-source-mat.pdf": "58967e0b6306b0d205b59f1f5248f07e108b33674233b80b6ae06350b8f7e642",
    "print/fictional-views-and-care-cards.pdf": "9fc6c18d780aa7a82afc84bbfd77a1825d093b8c5cc78985dca1f8cd177f197a",
    "print/picture-record-mat.pdf": "fc4b8b91de36b1265a3898b5c14a2f1b91525204354f476e8edc62b617d5bc74",
    "print/paper-learning-place-map.pdf": "cee40ec37e913bae67ae06a7f96314092289142c52472ce88190647aab201737",
}
HELD_FRESH_SHA = {
    "STUDENT-CHECKS.md": "09954268bd481ae3c99ebf0b6f4d720f9b67c6e4fe4917ba8b7e76215344918a",
    "print/check-a-fresh-place.svg": "a25bda3b4138b040c00e49d593b88dabe89847670802aa65c7e3a8b35a12d6fd",
    "print/check-a-fresh-place.pdf": "fb22d896e2ffd8c41ca57f2741c80c04ec292b86a9a75f78ed7a0478dc59722d",
    "print/check-b-fresh-signs.svg": "8927a895582316921479251794fa1067f72641796f184c1ed8a74b1b68b6bae5",
    "print/check-b-fresh-signs.pdf": "c020b400e78e5c4d2c2dba251322a7217494862eeb0ae59014e511cd551c5cc7",
}
HELD_KEY_PREFIX_SHA = "231658098a8bf0cf4366b685cf97cc9ec2774534f492c2813fa3f362ea164be5"


def need(ok: object, message: str) -> None:
    """Fail with a useful artifact location or reason."""
    if not ok:
        raise AssertionError(message)


def read(name: str) -> str:
    """Read one authored UTF-8 file."""
    return (ROOT / name).read_text(encoding="utf-8")


def compact(value: str) -> str:
    """Normalise only source layout whitespace."""
    return " ".join(value.split())


def workbook_rows(workbook: Path) -> dict[int, list[str]]:
    """Read the actual pinned workbook rows with standard XML, not another map."""
    ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
    wanted = set(ROWS.values()) | {1222, 1223, 1224, 1225}
    with ZipFile(workbook) as archive:
        shared = [
            "".join(node.itertext())
            for node in ET.fromstring(archive.read("xl/sharedStrings.xml")).findall(
                "x:si", ns
            )
        ]
        top = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
        result: dict[int, list[str]] = {}
        for row in top.findall("x:sheetData/x:row", ns):
            number = int(row.get("r"))
            if number not in wanted:
                continue
            values = [""] * 12
            for cell in row.findall("x:c", ns):
                letters = re.match(r"[A-Z]+", cell.get("r")).group(0)
                col = 0
                for char in letters:
                    col = col * 26 + ord(char) - 64
                val = cell.find("x:v", ns)
                text = val.text if val is not None and val.text else ""
                if cell.get("t") == "s":
                    text = shared[int(text)]
                elif cell.get("t") == "inlineStr":
                    text = "".join(cell.find("x:is", ns).itertext())
                if col <= 12:
                    values[col - 1] = compact(text)
            result[number] = values
        return result


def source() -> None:
    """Pin workbook bytes, all nine verbatim rows and two QCAA entry links."""
    workbook = (
        STUDIO
        / "research/sources/acara-australian-curriculum-v9-download-2026-09-29.xlsx"
    )
    data = json.loads(
        (STUDIO / "data/frameworks/acara-v9.json").read_text(encoding="utf-8")
    )
    snapshot = json.loads(read("source-snapshot.json"))
    need(
        hashlib.sha256(workbook.read_bytes()).hexdigest() == SOURCE_SHA,
        "Official ACARA workbook SHA drift",
    )
    need(
        data["source_sha256"] == snapshot["workbook_sha256"] == SOURCE_SHA,
        "ACARA import/workbook hash disagreement",
    )
    need(snapshot["content_rows"] == ROWS, "Source row pin drift")
    need(
        snapshot["queensland_prep_entry_point_url"] == QCAA_URL
        and snapshot["queensland_prep_alignment_url"] == QCAA_ALIGNMENT_URL,
        "QCAA Prep link pin drift",
    )
    need(
        set(snapshot["baseline_partial_codes"]) == TAUGHT_CODES
        and set(snapshot["not_claimed_without_local_sources"]) == HELD_CODES,
        "Baseline/hold code accounting drift",
    )
    crosswalk = read("CURRICULUM-CROSSWALK.md")
    need(
        QCAA_URL in crosswalk
        and QCAA_ALIGNMENT_URL in crosswalk
        and "partial" in crosswalk.lower(),
        "Queensland/partial claim boundary absent",
    )
    records = {
        record["code"]: record
        for record in data["records"]
        if record.get("record_type") == "content_description"
        and record.get("code") in ROWS
    }
    need(set(records) == set(ROWS), "Missing official Foundation HASS row")
    for code, row_number in ROWS.items():
        record = records[code]
        attrs = record["attributes"]
        need(
            record["source_row"] == row_number
            and attrs["level"] == "Foundation Year"
            and attrs["learning_area"] == "Humanities and Social Sciences"
            and attrs["subject"] == "HASS F-6",
            f"Wrong HASS level/subject/row: {code}",
        )
        row_pattern = (
            rf"^\| {code} \| {row_number} \| "
            rf"Humanities and Social Sciences · HASS F-6 · Foundation Year \| "
            rf"{re.escape(record['plain_text'])} \|"
        )
        need(
            re.search(row_pattern, crosswalk, re.MULTILINE),
            f"Crosswalk lacks verbatim official wording: {code}",
        )
        if code in HELD_CODES:
            need(
                re.search(
                    rf"^\| {code} \|.*\*\*Hold: not taught or assessed",
                    crosswalk,
                    re.MULTILINE,
                ),
                f"Local/privacy hold absent: {code}",
            )
    need(
        snapshot["knowledge_codes_conditional_on_actual_place_sources"] == ["AC9HSFK03"]
        and snapshot["knowledge_outcomes_observed"] == []
        and set(snapshot["actual_route_days"]) == ACTUAL_DAYS,
        "Actual opportunity/observed outcome accounting drift",
    )
    need(
        snapshot["held_original_print_sha256"] == ORIGINAL_PRINT_SHA
        and snapshot["held_fresh_file_sha256"] == HELD_FRESH_SHA
        and snapshot["held_teacher_key_prefix_sha256"] == HELD_KEY_PREFIX_SHA,
        "Original/fresh/key baseline pin drift",
    )
    direct = workbook_rows(workbook)
    for code, row in ROWS.items():
        need(
            direct[row][:3]
            == ["Humanities and Social Sciences", "HASS F-6", "Foundation Year"]
            and direct[row][4] == code
            and compact(direct[row][9]) == compact(records[code]["plain_text"]),
            f"Direct workbook row drift: {code}",
        )
    need(
        snapshot["illustrative_elaboration_rows"]
        == {
            "AC9HSFK03_E1": 1222,
            "AC9HSFK03_E2": 1223,
            "AC9HSFK03_E3": 1224,
            "AC9HSFK03_E4": 1225,
        },
        "K03 elaboration pin drift",
    )
    for row in range(1222, 1226):
        need(
            direct[row][4] == f"AC9HSFK03_E{row-1221}" and direct[row][10],
            "Direct K03 elaboration drift",
        )
    need(
        "imaginary places in stories" in direct[1223][10]
        and "could or do" in direct[1225][10],
        "Legitimate imagined/possible-care context drift",
    )
    receipt = snapshot["live_verification"]
    need(
        receipt["checked_at"] == "2026-09-30"
        and receipt["acara_workbook"]["sha256"] == SOURCE_SHA
        and receipt["acara_workbook"]["bytes"] == 2049917
        and receipt["qcaa_prep_alignment"]["copyright_year"] == 2023,
        "Current primary receipt drift",
    )
    need(
        "State of Queensland (QCAA) 2023" in read("SOURCE-AND-RIGHTS.md"),
        "QCAA attribution drift",
    )
    print(
        "PASS direct workbook nine rows/K03 elaborations; current rights; prepared conditional K03/no outcomes"
    )


def pedagogy() -> None:
    """Check ten scripts, 30 equivalent routes and 20 worked context swaps."""
    lessons = read("LESSONS.md")
    marks = list(re.finditer(r"^## Week (\d+) · Day (\d+)\b", lessons, re.MULTILINE))
    need(
        [int(mark.group(2)) for mark in marks] == list(range(1, 11)),
        "Teacher day sequence drift",
    )
    for index, mark in enumerate(marks):
        day = int(mark.group(2))
        end = marks[index + 1].start() if index + 1 < len(marks) else len(lessons)
        body = lessons[mark.end() : end]
        times = [
            int(value)
            for value in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*",
                body,
                re.MULTILINE,
            )
        ]
        need(
            int(mark.group(1)) == (1 if day <= WEEK_SPLIT_DAY else 2)
            and times == [3, 4, 5, 7, 4, 2]
            and sum(times) == LESSON_MINUTES,
            f"Day {day} week/minutes drift: {times}",
        )
        codes = set(re.findall(r"\bAC9HSF(?:K|S)\d\d\b", body))
        need(codes == EXPECTED_CODES[day], f"Day {day} code drift: {codes}")
        need("check" in body.lower(), f"Day {day} evidence check missing")
    cards = read("LEARNER-CARDS.md")
    card_marks = list(re.finditer(r"^## Day (\d+)\b", cards, re.MULTILINE))
    need(
        [int(mark.group(1)) for mark in card_marks] == list(range(1, 11)),
        "Clean learner day sequence drift",
    )
    for index, mark in enumerate(card_marks):
        end = (
            card_marks[index + 1].start() if index + 1 < len(card_marks) else len(cards)
        )
        body = cards[mark.end() : end]
        need(
            all(re.search(rf"^\- \*\*{route} ·", body, re.MULTILINE) for route in "ABC")
            and "**Same target:**" in body
            and "**Home:**" in body,
            f"Day {mark.group(1)} route/shared target/home absent",
        )
    swaps = read("PRACTICE-SWAPS.md")
    for day in range(1, 11):
        need(
            re.search(rf"^\| {day} \| \*\*[^|]+ \| \*\*[^|]+ \|", swaps, re.MULTILINE),
            f"Two worked context swaps absent on Day {day}",
        )
    need(
        "not fixed learning styles" in cards
        and "No real home" in read("STUDENT-CHECKS.md")
        and "K01/K02/K04" in lessons,
        "Access/privacy/cultural hold drift",
    )
    print("PASS ten distinct 25-minute scripts, 30 routes and 20 worked swaps")


def checks() -> None:
    """Check source/key agreement, freshness and public assessment boundary."""
    student = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    practice = (
        read("PRACTICE-SWAPS.md")
        + read("LEARNER-CARDS.md")
        + read("ACTUAL-PLACE-PATHWAY.md")
    )
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)\b", student, re.MULTILINE)
        == [("A", "5"), ("B", "10")],
        "Fresh formative check order drift",
    )
    need(
        "teacher/KEY-AND-NEXT.md" not in student
        and "teacher/KEY-AND-NEXT.md" not in read("LEARNER-CARDS.md"),
        "Learner-facing page links to public teacher key",
    )
    need(
        "not secure exams" in student
        and "public by URL" in key
        and "first child-controlled" in student,
        "Public check/first response boundary absent",
    )
    need(
        "three sign pictures are mixed up" in student
        and "order blank sign" not in student,
        "Check B sequence disclosed in child instructions",
    )
    for phrase in (
        "CARD RACK is top-middle",
        "NEST SEAT/quiet picture-card looking to Lumi",
        "MODEL TABLE/building paper shapes to Oko",
        "An adult put it back there so it could be read.",
        "CARD DESK is bottom-left",
        (
            "blank sign → rectangle-card icon and CARD DESK words added "
            "→ sign placed beside CARD DESK"
        ),
        "actual visitor feedback",
        "effect is **unknown**",
    ):
        need(phrase in key, f"Private-key source/limit phrase drift: {phrase}")
    for phrase in (
        "Paper Kite Room",
        "Paper Workshop Pavilion",
        "NEST SEAT",
        "CARD DESK",
    ):
        need(
            phrase not in practice,
            f"Held check source leaked into routine practice: {phrase}",
        )
    need(
        all(
            phrase in read("print/TEXT-ALTERNATIVES.md")
            for phrase in (
                "CARD RACK",
                "NEST SEAT",
                "MODEL TABLE",
                "CARD DESK",
                "DRAW WALL",
                "REST COVE",
            )
        ),
        "Held print/text source disagreement",
    )
    for name, expected in HELD_FRESH_SHA.items():
        data = (
            read(name).encode("utf-8")
            if name.endswith(".md")
            else (ROOT / name).read_bytes()
        )
        need(
            hashlib.sha256(data).hexdigest() == expected,
            f"Held fresh byte drift: {name}",
        )
    prefix = key.split("\n## Actual optional place route")[0]
    need(
        hashlib.sha256(prefix.encode("utf-8")).hexdigest() == HELD_KEY_PREFIX_SHA,
        "Held keyed interpretation drift",
    )
    print(
        "PASS five held fresh files and original key prefix byte-unchanged; first-response/key separation"
    )


def svg_places(stem: str) -> set[tuple[str, str, str]]:
    """Read exact labelled cells from a rendered SVG source page."""
    top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
    namespace = {"svg": "http://www.w3.org/2000/svg"}
    return {
        (item.get("data-place"), item.get("data-row"), item.get("data-col"))
        for item in top.findall(".//svg:rect", namespace)
        if item.get("data-place") is not None
    }


def svg_rect_values(stem: str, attribute: str) -> Counter:
    """Read machine-visible source-card/timeline attributes."""
    top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
    namespace = {"svg": "http://www.w3.org/2000/svg"}
    return Counter(
        item.get(attribute)
        for item in top.findall(".//svg:rect", namespace)
        if item.get(attribute) is not None
    )


def assets() -> None:
    """Check eleven physical A4 pairs, selectable text and exact source maps."""
    alternatives = read("print/TEXT-ALTERNATIVES.md")
    tree = ast.parse(read("print/generate_print.py"))
    page_map = next(
        node.value
        for node in tree.body
        if isinstance(node, ast.Assign)
        and any(
            isinstance(target, ast.Name) and target.id == "PAGES"
            for target in node.targets
        )
    )
    need(
        isinstance(page_map, ast.Dict)
        and {key.value for key in page_map.keys if isinstance(key, ast.Constant)}
        == set(STEMS),
        "Print generator page inventory drift",
    )
    namespace = {"svg": "http://www.w3.org/2000/svg"}
    for stem in STEMS:
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        pdf = ROOT / "print" / f"{stem}.pdf"
        need(
            top.get("width") == "210mm"
            and top.get("height") == "297mm"
            and top.find("svg:title", namespace) is not None
            and top.find("svg:desc", namespace) is not None,
            f"SVG A4 or accessible metadata drift: {stem}",
        )
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        body = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        linear_pdf = " ".join(body.split())
        need(
            re.search(r"Pages:\s+1\b", info)
            and "(A4)" in info
            and "CC BY 4.0" in body
            and len(body.split()) >= MIN_PDF_WORDS,
            f"PDF page/selectable-text drift: {stem}",
        )
        need(
            f"{stem}.svg" in alternatives and f"{stem}.pdf" in alternatives,
            f"Full linear/tactile alternative missing: {stem}",
        )
        for phrase in PRINTED_SOURCE_PHRASES.get(stem, ()):
            need(
                phrase in linear_pdf and phrase in alternatives,
                f"Printed source/text alternative drift: {stem}: {phrase}",
            )
    need(svg_places("paper-learning-place-map") == MAIN_MAP, "Main map cell drift")
    need(svg_places("check-a-fresh-place") == CHECK_A_MAP, "Check A map cell drift")
    need(svg_places("check-b-fresh-signs") == CHECK_B_MAP, "Check B map cell drift")
    need(
        svg_rect_values("fictional-views-and-care-cards", "data-source-card")
        == Counter({"PIP": 1, "REN": 1, "CARE": 1}),
        "Main speaker/care card drift",
    )
    need(
        svg_rect_values("check-a-fresh-place", "data-source-card")
        == Counter({"LUMI": 1, "OKO": 1, "CARE": 1}),
        "Check A speaker/care card drift",
    )
    need(
        svg_rect_values("check-b-fresh-signs", "data-source-card")
        == Counter({"VISITOR": 1}),
        "Check B visitor source drift",
    )
    for stem in ("sign-making-timeline", "check-b-fresh-signs"):
        need(
            svg_rect_values(stem, "data-step") == Counter({"1": 1, "2": 1, "3": 1}),
            f"Three source timeline frames drift: {stem}",
        )
    check_b_top = ET.parse(ROOT / "print/check-b-fresh-signs.svg").getroot()
    printed_order = [
        item.get("data-step")
        for item in check_b_top.findall(".//svg:rect", namespace)
        if item.get("data-step") is not None
    ]
    need(printed_order == ["3", "1", "2"], "Check B timeline is no longer mixed")
    need(
        "untagged" in alternatives and "tactile" in alternatives.lower(),
        "PDF/tactile access boundary absent",
    )
    for name, expected in ORIGINAL_PRINT_SHA.items():
        need(
            hashlib.sha256((ROOT / name).read_bytes()).hexdigest() == expected,
            f"Original print byte drift: {name}",
        )
    cue = ET.parse(ROOT / "print/place-play-card.svg").getroot()
    choices = [
        node.get("data-choice")
        for node in cue.findall(".//svg:rect", namespace)
        if node.get("data-choice")
    ]
    blanks = [
        node.get("data-blank-cue")
        for node in cue.findall(".//svg:rect", namespace)
        if node.get("data-blank-cue")
    ]
    need(
        choices == ["FIND", "TELL", "CARE"] and blanks == ["place", "tell"],
        "Blank cue/choice geometry drift",
    )
    for stem in STEMS:
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        for node in top.findall(".//svg:text", namespace):
            size = int(node.get("font-size"))
            weight = "-Bold" if node.get("font-weight") == "700" else ""
            font = ImageFont.truetype(
                f"/usr/share/fonts/truetype/dejavu/DejaVuSans{weight}.ttf", size
            )
            x, y = float(node.get("x")), float(node.get("y"))
            text = "".join(node.itertext())
            need(
                x >= 35 and x + font.getlength(text) <= 759 and 35 <= y <= 1085,
                f"Page text overflow: {stem}: {text}",
            )
    visible = ["".join(n.itertext()) for n in cue.findall(".//svg:text", namespace)]
    pdf = " ".join(
        subprocess.check_output(
            ["pdftotext", str(ROOT / "print/place-play-card.pdf"), "-"], text=True
        ).split()
    )
    for phrase in visible:
        need(
            phrase in pdf and phrase in alternatives,
            f"Exact blank cue alternative drift: {phrase}",
        )
    print(
        "PASS eleven A4 pairs/text bounds/geometry; all twenty original print bytes pinned"
    )


def actual_route() -> None:
    """Check six practical ordinary branches and component-specific boundaries."""
    path = read("ACTUAL-PLACE-PATHWAY.md")
    for phrase in (
        "one small shared source kit",
        "actual account date",
        "actual view/date",
        "child does one action",
        "No upload, automatic photo",
        "No pilot data",
        "A private response, absent home participation or pass is not an error",
        "Complete P / private / pass route",
        "Actual physical action and measured benefit are not requirements",
        "Source facts, child's response and knowledge judgement are distinct",
        "familiarity remains developing",
    ):
        need(phrase.lower() in path.lower(), f"Actual route safeguard absent: {phrase}")
    lessons = read("LESSONS.md")
    marks = list(re.finditer(r"^## Week \d+ · Day (\d+)\b", lessons, re.MULTILINE))
    for i, m in enumerate(marks):
        day = int(m.group(1))
        body = lessons[
            m.end() : marks[i + 1].start() if i + 1 < len(marks) else len(lessons)
        ]
        if day in ACTUAL_DAYS:
            need(
                "**R opportunity:**" in body
                and "P:" in body
                and "R:" in body
                and "**Make/direct" in body
                and "**Next move:**" in body,
                f"Day {day} practical actual/P branch absent",
            )
        else:
            need("**R opportunity:**" not in body, f"Actual branch leaked to Day {day}")
    need(
        "no actual outcomes collected in authoring" in lessons.lower(),
        "Prepared/outcome boundary absent",
    )
    for false in (
        "all children belong because they matched",
        "care must be performed",
        "fiction cannot teach k03",
        "every child attained k03",
    ):
        need(false not in path.lower() + lessons.lower(), f"False K03 claim: {false}")
    print(
        "PASS six one-job R/P branches; private/pass; care understanding distinct from act/effect"
    )


def slug(value: str) -> str:
    """Resolve a simple local Markdown heading anchor."""
    value = re.sub(r"\[[^]]+\]\([^)]+\)", "", value)
    value = re.sub(r"[*_]", "", value).strip().lower()
    value = re.sub(r"[^\w -]", "", value)
    return value.replace(" ", "-")


def links(*, allow_manifest_bootstrap: bool = False) -> int:
    """Check local file paths and heading anchors without network requests."""
    count = 0
    for path in ROOT.rglob("*.md"):
        body = path.read_text(encoding="utf-8")
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", body):
            if url.startswith(("https://", "http://", "mailto:")):
                continue
            name, marker, anchor = unquote(url).partition("#")
            target = (path.parent / name).resolve() if name else path
            if allow_manifest_bootstrap and target == ROOT / "manifest.json":
                count += 1
                continue
            need(
                target.is_file(),
                f"Broken local link {path.relative_to(ROOT)} -> {url}",
            )
            if marker and target.suffix.lower() == ".md":
                headings = [
                    slug(title)
                    for title in re.findall(
                        r"^#{1,6} (.+)$",
                        target.read_text(encoding="utf-8"),
                        re.MULTILINE,
                    )
                ]
                need(anchor in headings, f"Broken heading anchor: {url}")
            count += 1
    print(f"PASS {count} local file and heading links")
    return count


def manifest_data() -> dict:
    """Hash every authored file except the self-referential manifest."""
    files = sorted(
        path
        for path in ROOT.rglob("*")
        if path.is_file()
        and path.name != "manifest.json"
        and "__pycache__" not in path.parts
        and ".ruff_cache" not in path.parts
    )
    return {
        "schema": "subjectnest-authored-pack-manifest-v1",
        "pack": "foundation/hass/term-1/weeks-01-02",
        "created_at": "2026-09-30",
        "review_status": (
            "author_desk_checked_pending_teacher_child_"
            "cultural_authority_accessibility_local_review"
        ),
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial": sorted(TAUGHT_CODES),
        "curriculum_codes_held": sorted(HELD_CODES),
        "knowledge_codes_conditional_on_actual_place_sources": ["AC9HSFK03"],
        "knowledge_outcomes_observed": [],
        "actual_route_days": sorted(ACTUAL_DAYS),
        "files": {
            str(path.relative_to(ROOT)): {
                "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
                "bytes": path.stat().st_size,
            }
            for path in files
        },
    }


def state() -> dict[str, tuple[str, int, int]]:
    """Guard authored bytes, size and mtime during read-only checks."""
    return {
        str(p.relative_to(ROOT)): (
            hashlib.sha256(p.read_bytes()).hexdigest(),
            p.stat().st_size,
            p.stat().st_mtime_ns,
        )
        for p in ROOT.rglob("*")
        if p.is_file() and not {"__pycache__", ".ruff_cache"}.intersection(p.parts)
    }


def audit(*, bootstrap: bool = False) -> dict:
    """Check the teaching/source boundaries before comparing or renewing hashes."""
    source()
    pedagogy()
    checks()
    assets()
    actual_route()
    links(allow_manifest_bootstrap=bootstrap)
    expected = manifest_data()
    if not bootstrap:
        need(
            json.loads(read("manifest.json")) == expected,
            "SHA-256 manifest missing or stale",
        )
        print(f"PASS SHA-256 manifest for {len(expected['files'])} files")
    return expected


def negative_checks() -> int:
    """Reject virtual defects and a temporary generated defect without pack edits."""
    original_read, original_parse, original_output = (
        read,
        ET.parse,
        subprocess.check_output,
    )
    count = 0

    def probe(label, operation, expected, *, changes=None, svg=None, bad_pdf=False):
        global read
        nonlocal count
        changes = changes or {}

        def virtual_read(name):
            return changes.get(name, original_read(name))

        def virtual_parse(path, *args, **kwargs):
            tree = original_parse(path, *args, **kwargs)
            if svg and Path(path).name == svg[0]:
                tree = copy.deepcopy(tree)
                svg[1](tree.getroot())
            return tree

        def virtual_output(command, *args, **kwargs):
            value = original_output(command, *args, **kwargs)
            if bad_pdf and command[0] == "pdfinfo":
                return re.sub(r"Pages:\s+1", "Pages: 2", value)
            return value

        read, ET.parse, subprocess.check_output = (
            virtual_read,
            virtual_parse,
            virtual_output,
        )
        try:
            try:
                with contextlib.redirect_stdout(io.StringIO()):
                    operation()
            except AssertionError as error:
                need(
                    expected in str(error),
                    f"Wrong negative rejection: {label}: {error}",
                )
            else:
                raise AssertionError(f"Negative defect accepted: {label}")
        finally:
            read, ET.parse, subprocess.check_output = (
                original_read,
                original_parse,
                original_output,
            )
        count += 1
        print(f"PASS negative {count:02d}: {label}")

    snapshot = json.loads(original_read("source-snapshot.json"))
    for field, value in (
        ("knowledge_outcomes_observed", ["AC9HSFK03"]),
        ("knowledge_codes_conditional_on_actual_place_sources", []),
        ("actual_route_days", [1, 2, 3, 4, 6]),
    ):
        changed = copy.deepcopy(snapshot)
        changed[field] = value
        probe(
            field,
            source,
            "Actual opportunity/observed outcome accounting drift",
            changes={"source-snapshot.json": json.dumps(changed)},
        )
    changed = copy.deepcopy(snapshot)
    changed["held_original_print_sha256"] = {}
    probe(
        "protected original baseline",
        source,
        "Original/fresh/key baseline pin drift",
        changes={"source-snapshot.json": json.dumps(changed)},
    )
    changed = copy.deepcopy(snapshot)
    changed["live_verification"]["qcaa_prep_alignment"]["copyright_year"] = 2022
    probe(
        "QCAA publication year",
        source,
        "Current primary receipt drift",
        changes={"source-snapshot.json": json.dumps(changed)},
    )
    probe(
        "exact K03 description",
        source,
        "Crosswalk lacks verbatim official wording: AC9HSFK03",
        changes={
            "CURRICULUM-CROSSWALK.md": original_read("CURRICULUM-CROSSWALK.md").replace(
                "the features of familiar places they belong to",
                "the features of all places",
                1,
            )
        },
    )
    path = original_read("ACTUAL-PLACE-PATHWAY.md")
    for phrase in (
        "actual account date",
        "actual view/date",
        "child does one action",
        "A private response, absent home participation or pass is not an error",
        "Actual physical action and measured benefit are not requirements",
        "Complete P / private / pass route",
    ):
        # Preserve exact case in the mutation, while matching the safeguard case-insensitively.
        changed = re.sub(
            re.escape(phrase), "[removed]", path, count=1, flags=re.IGNORECASE
        )
        probe(
            phrase,
            actual_route,
            "Actual route safeguard absent",
            changes={"ACTUAL-PLACE-PATHWAY.md": changed},
        )
    lessons = original_read("LESSONS.md")
    probe(
        "25-minute clock",
        pedagogy,
        "Day 1 week/minutes drift",
        changes={"LESSONS.md": lessons.replace("· 7 min.**", "· 9 min.**", 1)},
    )
    probe(
        "ordinary actual branch",
        actual_route,
        "Day 1 practical actual/P branch absent",
        changes={
            "LESSONS.md": lessons.replace(
                "**R opportunity:**", "**Omitted opportunity:**", 1
            )
        },
    )
    probe(
        "third access choice",
        pedagogy,
        "Day 1 route/shared target/home absent",
        changes={
            "LEARNER-CARDS.md": original_read("LEARNER-CARDS.md").replace(
                "- **C ·", "- **D ·", 1
            )
        },
    )
    probe(
        "fresh source leak",
        checks,
        "Held check source leaked",
        changes={"ACTUAL-PLACE-PATHWAY.md": path + "\nPaper Kite Room\n"},
    )
    probe(
        "fresh first-response bytes",
        checks,
        "Held fresh byte drift: STUDENT-CHECKS.md",
        changes={"STUDENT-CHECKS.md": original_read("STUDENT-CHECKS.md") + "\n"},
    )
    probe(
        "original keyed interpretation",
        checks,
        "Held keyed interpretation drift",
        changes={
            "teacher/KEY-AND-NEXT.md": original_read("teacher/KEY-AND-NEXT.md").replace(
                "#", "##", 1
            )
        },
    )
    probe(
        "exact cue alternative",
        assets,
        "Exact blank cue alternative drift",
        changes={
            "print/TEXT-ALTERNATIVES.md": original_read(
                "print/TEXT-ALTERNATIVES.md"
            ).replace("Use one space. You can leave it blank.", "[removed]", 1)
        },
    )
    ns = {"s": "http://www.w3.org/2000/svg"}
    probe(
        "cue choice geometry",
        assets,
        "Blank cue/choice geometry drift",
        svg=(
            "place-play-card.svg",
            lambda root: root.find('.//s:rect[@data-choice="FIND"]', ns).set(
                "data-choice", "PROVE"
            ),
        ),
    )
    probe(
        "actual PDF page count", assets, "PDF page/selectable-text drift", bad_pdf=True
    )
    changed = json.loads(original_read("manifest.json"))
    changed["knowledge_outcomes_observed"] = ["AC9HSFK03"]
    probe(
        "false outcome manifest",
        audit,
        "SHA-256 manifest missing or stale",
        changes={"manifest.json": json.dumps(changed)},
    )
    # Actually render a changed protected source to temporary storage: no existing
    # PDF is used as the generated result, and the preflight must reject it.
    generator = {
        "__file__": str(ROOT / "print/generate_print.py"),
        "__name__": "temporary_negative_renderer",
    }
    # Execute only this trusted local generator to probe its protected-output guard.
    exec(  # noqa: S102
        compile(original_read("print/generate_print.py"), "generate_print.py", "exec"),
        generator,
    )
    maker = generator["PAGES"]["paper-learning-place-map"]
    generator["PAGES"]["paper-learning-place-map"] = lambda: maker().replace(
        "Paper Learning Place", "Changed Place", 1
    )
    with tempfile.TemporaryDirectory(
        prefix="subjectnest-protected-output-negative-"
    ) as temp:
        probe(
            "protected generated output preflight",
            lambda: generator["generate"](Path(temp)),
            "Protected generated output drift",
        )
    return count


def main() -> None:
    """Run read-only checks; renew hashes only with an explicit editing flag."""
    parser = argparse.ArgumentParser()
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--write-manifest", action="store_true")
    mode.add_argument("--negative-checks", action="store_true")
    args = parser.parse_args()
    before = state()
    expected = audit(bootstrap=args.write_manifest)
    if args.write_manifest:
        (ROOT / "manifest.json").write_text(
            json.dumps(expected, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
        print(f"WROTE SHA-256 manifest for {len(expected['files'])} files")
    else:
        if args.negative_checks:
            print(f"PASS {negative_checks()} read-only negative probes")
        need(
            state() == before,
            "Read-only check changed bytes, size or modification time",
        )
        print("PASS every authored byte, size and modification time preserved")
    print("PACK PASS · author desk QA only; no live classroom/access/authority trial")


if __name__ == "__main__":
    main()
