# SPDX-License-Identifier: Apache-2.0
"""Fail-closed, read-only by default receipt for HASS Weeks 13-14."""

from __future__ import annotations

import argparse
import ast
import contextlib
import copy
import hashlib
import io
import json
import re
import subprocess
import xml.etree.ElementTree as ET
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"
ROWS = {
    "AC9HSFK01": 1213,
    "AC9HSFK02": 1217,
    "AC9HSFK03": 1221,
    "AC9HSFK04": 1226,
    "AC9HSFS01": 1233,
    "AC9HSFS02": 1237,
    "AC9HSFS03": 1241,
    "AC9HSFS04": 1245,
    "AC9HSFS05": 1251,
}
HELD_CODES = {"AC9HSFK02", "AC9HSFK04"}
CONDITIONAL_CODES = {"AC9HSFK01"}
PARTIAL_CODES = set(ROWS) - HELD_CODES - CONDITIONAL_CODES
ACTUAL_DAYS = {61, 62, 63, 64, 69}
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"
)
STEMS = (
    "page-nook-album",
    "jules-note",
    "person-source-mat",
    "page-nook-sketch",
    "page-nook-views",
    "place-source-mat",
    "check-a-harbor-album",
    "check-b-pillar-nook",
    "family-link-play-mat",
    "born-grew-up-story-mat",
)
FINAL_DAY = 70
CHECK_COUNT = 2
MIN_PDF_WORDS = 20
EXPECTED_CODES = {
    61: {"AC9HSFS01", "AC9HSFS02"},
    62: {"AC9HSFS02", "AC9HSFS04"},
    63: {"AC9HSFS03", "AC9HSFS04", "AC9HSFS05"},
    64: {"AC9HSFS01", "AC9HSFS03", "AC9HSFS05"},
    65: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS03", "AC9HSFS04", "AC9HSFS05"},
    66: {"AC9HSFK03", "AC9HSFS01", "AC9HSFS02"},
    67: {"AC9HSFK03", "AC9HSFS03", "AC9HSFS04"},
    68: {"AC9HSFK03", "AC9HSFS01", "AC9HSFS03", "AC9HSFS04"},
    69: {"AC9HSFK03", "AC9HSFS01", "AC9HSFS03", "AC9HSFS04", "AC9HSFS05"},
    70: set(PARTIAL_CODES),
}
PDF_PHRASES = {
    "page-nook-album": (
        "JULES",
        "EMI",
        "COUSINS",
        "PAT",
        "DARWIN",
        "ADELAIDE",
        "CAIRNS",
    ),
    "jules-note": ("JULES", "Pat showed us", "Emi chose the cover", "Cairns"),
    "person-source-mat": ("PERSON / RELATIONSHIP", "BORN IN", "GREW UP", "NOT STATED"),
    "page-nook-sketch": ("BOOK SHELF · LEFT", "OVAL RUG · CENTRE", "LOW TABLE · RIGHT"),
    "page-nook-views": ("JULES", "EMI", "special to me", "would rather"),
    "place-source-mat": (
        "DRAWN FEATURE",
        "WHOSE VIEW",
        "POSSIBLE CARE QUESTION",
        "REAL RESULT NOT SHOWN",
    ),
    "check-a-harbor-album": (
        "NORA",
        "VEE",
        "cousins",
        "BO",
        "Hobart",
        "Broome",
        "Townsville",
    ),
    "family-link-play-mat": (
        "Pick people",
        "CONNECTION",
        "Who told us",
        "TELL",
        "CLOSE",
        "PRETEND",
    ),
    "born-grew-up-story-mat": (
        "BORN",
        "GREW UP",
        "Time order",
        "SOURCE SAYS",
        "Keep private parts closed",
    ),
    "check-b-pillar-nook": (
        "BENCH · LEFT",
        "RUG · CENTRE",
        "BOOK STAND · RIGHT",
        "AVA",
        "LOU",
    ),
}

HELD_FRESH_SHA = {
    "STUDENT-CHECKS.md": "ed06eeaad583650a2b40aa2e539e241ab22936c318167dcf414c89748416fc5d",
    "print/check-a-harbor-album.svg": "fa69bcac485c2400ab1c781b14f2f9f8c18f3393fd29d1f5612de7b5578798f2",
    "print/check-a-harbor-album.pdf": "ef3844464b1e5ec9d83bb1a944883569ec542ed925091b592722de312df23dbc",
    "print/check-b-pillar-nook.svg": "02f4107a04f4ea10b27ef483f1206eedde80fd0b33f8da5f8d3cf19dbbd50aca",
    "print/check-b-pillar-nook.pdf": "4ecff8ffdd85893ef3428e4d9de47ceec4a3fdfd41e2faffac470ec45e15b387",
}
HELD_KEY_PREFIX_SHA = "ea80d180e9e857a60977087fff62724296ab578fe1a5d4d90ae653c84f510a53"


def need(ok: object, message: str) -> None:
    """Fail with one actionable explanation."""
    if not ok:
        raise AssertionError(message)


def read(name: str) -> str:
    """Read authored UTF-8 text."""
    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()) | {1214, 1215, 1216}
    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 exact official rows and Queensland Prep entry points."""
    workbook = (
        STUDIO
        / "research/sources/acara-australian-curriculum-v9-download-2026-09-29.xlsx"
    )
    imported = json.loads(
        (STUDIO / "data/frameworks/acara-v9.json").read_text(encoding="utf-8")
    )
    snapshot = json.loads(read("source-snapshot.json"))
    crosswalk = read("CURRICULUM-CROSSWALK.md")
    need(
        hashlib.sha256(workbook.read_bytes()).hexdigest()
        == imported["source_sha256"]
        == snapshot["workbook_sha256"]
        == SOURCE_SHA,
        "ACARA official workbook/import/snapshot SHA drift",
    )
    need(snapshot["content_rows"] == ROWS, "ACARA row pin drift")
    need(
        set(snapshot["baseline_partial_codes"]) == PARTIAL_CODES
        and set(snapshot["not_claimed_without_local_sources"]) == HELD_CODES
        and set(snapshot["knowledge_codes_conditional_on_actual_family_sources"])
        == CONDITIONAL_CODES
        and snapshot["knowledge_outcomes_observed"] == []
        and set(snapshot["actual_route_days"]) == ACTUAL_DAYS
        and snapshot["held_fresh_file_sha256"] == HELD_FRESH_SHA
        and snapshot["held_teacher_key_prefix_sha256"] == HELD_KEY_PREFIX_SHA,
        "Partial/held source accounting drift",
    )
    need(
        snapshot["queensland_prep_entry_point_url"] == QCAA_URL
        and snapshot["queensland_prep_alignment_url"] == QCAA_ALIGNMENT_URL
        and QCAA_URL in crosswalk
        and QCAA_ALIGNMENT_URL in crosswalk,
        "QCAA Prep URL pin drift",
    )
    records = {
        record["code"]: record
        for record in imported["records"]
        if record.get("record_type") == "content_description"
        and record.get("code") in ROWS
    }
    need(set(records) == set(ROWS), "Foundation HASS rows missing")
    for code, row in ROWS.items():
        item = records[code]
        attrs = item["attributes"]
        need(
            item["source_row"] == row
            and attrs["learning_area"] == "Humanities and Social Sciences"
            and attrs["subject"] == "HASS F-6"
            and attrs["level"] == "Foundation Year",
            f"Official exact record drift: {code}",
        )
        pattern = (
            rf"^\| {code} \| {row} \| Humanities and Social Sciences · HASS F-6 · "
            rf"Foundation Year \| {re.escape(item['plain_text'])} \|"
        )
        need(
            re.search(pattern, crosswalk, re.MULTILINE),
            f"Verbatim crosswalk drift: {code}",
        )
        if code in HELD_CODES:
            need(
                re.search(
                    rf"^\| {code} \|.*\*\*Hold(?: in public pack)?:",
                    crosswalk,
                    re.MULTILINE,
                ),
                f"Local/private/cultural hold absent: {code}",
            )
    need(
        "conditional k01 actual-family opportunities" in crosswalk.lower()
        and "k02/k04 held" in crosswalk.lower()
        and "country/place" in crosswalk.lower()
        and "not secure exams" in crosswalk.lower(),
        "Crosswalk evidence boundary absent",
    )
    direct = workbook_rows(workbook)
    for code, row in ROWS.items():
        values = direct[row]
        need(
            values[:3]
            == ["Humanities and Social Sciences", "HASS F-6", "Foundation Year"]
            and values[4] == code
            and compact(values[9]) == compact(records[code]["plain_text"]),
            f"Direct workbook row drift: {code}",
        )
    need(
        snapshot["illustrative_elaboration_rows"]
        == {"AC9HSFK01_E1": 1214, "AC9HSFK01_E2": 1215, "AC9HSFK01_E3": 1216},
        "K01 elaboration pins",
    )
    for number in (1214, 1215, 1216):
        need(
            direct[number][4] == f"AC9HSFK01_E{number - 1213}" and direct[number][10],
            "Direct K01 elaboration absent",
        )
    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 alignment attribution drift",
    )
    print(
        "PASS direct workbook nine rows/K01 elaborations, current QCAA pins, conditional K01/no outcomes"
    )


def sections(body: str, pattern: str) -> list[tuple[int, str]]:
    """Split numbered day sections without accepting duplicates."""
    marks = list(re.finditer(pattern, body, re.MULTILINE))
    return [
        (
            int(mark.group(1)),
            body[
                mark.end() : (
                    marks[index + 1].start() if index + 1 < len(marks) else len(body)
                )
            ],
        )
        for index, mark in enumerate(marks)
    ]


def pedagogy() -> None:
    """Check ten timed scripts, thirty access routes and twenty worked swaps."""
    lessons = read("LESSONS.md")
    teacher_days = sections(lessons, r"^## Week \d+ · Day (\d+)\b")
    need(
        [day for day, _ in teacher_days] == list(range(61, 71)),
        "Teacher day sequence",
    )
    for day, body in teacher_days:
        minutes = [
            int(value)
            for value in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*", body, re.MULTILINE
            )
        ]
        need(minutes == [3, 4, 5, 7, 4, 2], f"Day {day} 25-min timing: {minutes}")
        target = re.search(r"^\*\*Target/codes:\*\* ([^\n]+)", body, re.MULTILINE)
        need(target is not None, f"Day {day} target absent")
        codes = set(re.findall(r"\bAC9HSF(?:K|S)\d\d\b", target.group(1)))
        if day == FINAL_DAY:
            need(len(codes) == len(PARTIAL_CODES), "Day 70 all sampled codes absent")
        need(codes == EXPECTED_CODES[day], f"Day {day} code drift: {codes}")
        need(
            "**Check/respond" in body
            and any(
                token in body.lower()
                for token in ("record", "collect", "capture", "sample", "keep first")
            )
            and "**Exit" in body,
            f"Day {day} feedback/record absent",
        )
    cards = read("LEARNER-CARDS.md")
    learner_days = sections(cards, r"^## Day (\d+)\b")
    need([day for day, _ in learner_days] == list(range(61, 71)), "Learner days")
    for day, body in learner_days:
        need(
            "**Shared target:**" in body
            and all(
                re.search(rf"^- \*\*{route} ·", body, re.MULTILINE) for route in "ABC"
            )
            and "\n\n- **A ·" in body
            and "\n\n**Home:**" in body,
            f"Day {day} routes/home or CommonMark list spacing",
        )
    swaps = read("PRACTICE-SWAPS.md")
    rows = re.findall(r"^\| (\d+) \| (.+) \| (.+) \|$", swaps, re.MULTILINE)
    need(
        [int(day) for day, _, _ in rows] == list(range(61, 71))
        and all(one.startswith("**") and two.startswith("**") for _, one, two in rows),
        "Twenty worked context swaps absent",
    )
    need(
        "not fixed learning styles" in cards.lower()
        and "no child must" in read("README.md").lower()
        and "cultural authority" in read("LOCAL-SOURCE-INSERT.md").lower(),
        "Access/privacy/cultural boundary absent",
    )
    print("PASS ten 25-minute scripts, 30 routes, 20 worked swaps")


def checks() -> None:
    """Check fresh public cases, separation and worked-answer integrity."""
    student = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    linear_key = " ".join(re.sub(r"[*_]", "", key.lower()).split())
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)\b", student, re.MULTILINE)
        == [("A", "65"), ("B", "70")],
        "Fresh check schedule drift",
    )
    need(student.count("\n\n1. ") == CHECK_COUNT, "Student list spacing drift")
    need(
        "teacher/KEY-AND-NEXT.md" not in student
        and "teacher/KEY-AND-NEXT.md" not in read("LEARNER-CARDS.md")
        and "publicly accessible" in key.lower()
        and "not secure exams" in student.lower(),
        "Public check/key separation absent",
    )
    routine = (
        read("ACTUAL-SOURCE-PATHWAY.md")
        + read("PRACTICE-SWAPS.md")
        + " ".join(
            body
            for day, body in sections(read("LEARNER-CARDS.md"), r"^## Day (\d+)\b")
            if day not in (65, 70)
        )
    )
    for leaked in ("Harbor album", "Nora", "Vee", "Pillar Nook", "Ava", "Lou"):
        need(
            leaked not in routine, f"Held source leaked into earlier routine: {leaked}"
        )
    for phrase in (
        "Nora and Vee are cousins",
        "Bo is their grandfather",
        "born in Hobart",
        "grew up in Broome",
        "born in Townsville",
        "no Vee view",
        "bench left",
        "round rug centre",
        "book stand right",
        "Ava",
        "Lou",
        "could ask",
        "universal approval",
    ):
        need(phrase.lower() in linear_key, f"Worked key fact missing: {phrase}")
    need(
        "this is an invented source packet" in student.lower()
        and "no real book" in student.lower()
        and "no real child/classroom outcome" in key.lower(),
        "Check action-observed boundary absent",
    )
    for name, expected in HELD_FRESH_SHA.items():
        value = (
            read(name).encode("utf-8")
            if name.endswith(".md")
            else (ROOT / name).read_bytes()
        )
        need(
            hashlib.sha256(value).hexdigest() == expected,
            f"Held fresh source byte drift: {name}",
        )
    prefix = key.split("\n## Actual optional route")[0]
    need(
        hashlib.sha256(prefix.encode("utf-8")).hexdigest() == HELD_KEY_PREFIX_SHA,
        "Held fresh keyed interpretation drift",
    )
    print(
        "PASS held five source files and keyed interpretations byte-unchanged; two fresh checks separate"
    )


def svg_text_positions(stem: str) -> dict[str, int]:
    """Return first x position of each SVG text label."""
    top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
    ns = {"svg": "http://www.w3.org/2000/svg"}
    return {
        "".join(node.itertext()): int(node.get("x", "0"))
        for node in top.findall(".//svg:text", ns)
    }


def assets() -> None:
    """Check ten A4 pairs, source geometry and full linear equivalents."""
    alternatives = read("print/TEXT-ALTERNATIVES.md")
    tree = ast.parse(read("print/generate_print.py"))
    pages = 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(pages, ast.Dict)
        and {key.value for key in pages.keys if isinstance(key, ast.Constant)}
        == set(STEMS),
        "Generated aid inventory drift",
    )
    ns = {"svg": "http://www.w3.org/2000/svg"}
    linear_alt = " ".join(re.sub(r"[*_]", "", alternatives.lower()).split())
    check_pages: dict[str, str] = {}
    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", ns) is not None
            and top.find("svg:desc", ns) is not None,
            f"A4 SVG/metadata drift: {stem}",
        )
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        content = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        linear_pdf = " ".join(content.lower().split())
        if stem.startswith("check-"):
            check_pages[stem] = linear_pdf
        need(
            re.search(r"Pages:\s+1\b", info)
            and "(A4)" in info
            and "CC BY 4.0" in content
            and len(content.split()) >= MIN_PDF_WORDS
            and f"{stem}.svg" in alternatives
            and f"{stem}.pdf" in alternatives,
            f"PDF/selectable text/full alternative drift: {stem}",
        )
        for phrase in PDF_PHRASES[stem]:
            need(
                phrase.lower() in linear_pdf and phrase.lower() in linear_alt,
                f"PDF/linear source mismatch: {stem}: {phrase}",
            )
    need(
        "vee was born" not in check_pages["check-a-harbor-album"]
        and "vee grew up" not in check_pages["check-a-harbor-album"]
        and "every visitor liked" not in check_pages["check-b-pillar-nook"],
        "Assessment source page states its own inferred answer",
    )
    sketch = svg_text_positions("page-nook-sketch")
    check_b = svg_text_positions("check-b-pillar-nook")
    sketch_root = ET.parse(ROOT / "print/page-nook-sketch.svg").getroot()
    check_b_root = ET.parse(ROOT / "print/check-b-pillar-nook.svg").getroot()
    need(
        sketch["BOOK SHELF · LEFT"]
        < sketch["OVAL RUG · CENTRE"]
        < sketch["LOW TABLE · RIGHT"]
        and check_b["BENCH · LEFT"]
        < check_b["RUG · CENTRE"]
        < check_b["BOOK STAND · RIGHT"]
        and len(sketch_root.findall(".//svg:ellipse", ns)) == 1
        and len(check_b_root.findall(".//svg:circle", ns)) == 1,
        "Fictional source geometry drift",
    )
    need(
        "untagged" in alternatives
        and "tactile" in alternatives.lower()
        and "the class did not meet" in alternatives.lower(),
        "Accessible alternative or fiction boundary absent",
    )
    family = ET.parse(ROOT / "print/family-link-play-mat.svg").getroot()
    circles = family.findall('.//svg:circle[@data-person-slot="blank"]', ns)
    need(len(circles) == 2, "Blank family-circle count")
    need(
        [(int(n.get("cx")), int(n.get("cy")), int(n.get("r"))) for n in circles]
        == [(228, 326, 88), (566, 326, 88)],
        "Blank family-circle geometry",
    )
    line = family.find('.//svg:path[@data-connection="blank"]', ns)
    need(
        line is not None and line.get("d") == "M316 326 L478 326",
        "Connection endpoints drift",
    )
    origins = svg_text_positions("born-grew-up-story-mat")
    need(
        origins["BORN"] < origins["GREW UP"]
        and origins["PERSON"] < origins["CONNECTION"] < origins["SOURCE SAYS"],
        "Origin/story order drift",
    )
    for stem in STEMS:
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        for node in top.findall(".//svg:text", ns):
            x, y = float(node.get("x")), float(node.get("y"))
            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
            )
            text = "".join(node.itertext())
            need(
                x >= 35 and x + font.getlength(text) <= 759 and 35 <= y <= 1085,
                f"Page text overflow: {stem}: {text}",
            )
    print(
        "PASS ten A4 pairs, blank kit geometry, actual-source agreement and text bounds"
    )


def family_route() -> None:
    """Check integrated actions, component limits and usable privacy alternatives."""
    path = read("ACTUAL-SOURCE-PATHWAY.md")
    for phrase in (
        "Child choice is required again",
        "actual date of the account",
        "No upload/export to SubjectNest",
        "One relationship link or one place box is enough",
        "No pilot response is needed",
        "People in their family",
        "How they are related",
        "Where they were born",
        "Where they were raised",
        "Full P / closed-field route",
        "not a child worksheet",
        "No reply means no participation",
    ):
        need(
            phrase.lower() in path.lower(),
            f"Actual family route safeguard absent: {phrase}",
        )
    lessons = sections(read("LESSONS.md"), r"^## Week \d+ · Day (\d+)\b")
    for day, body in lessons:
        if day in ACTUAL_DAYS:
            need(
                "**R opportunity/codes:** AC9HSFK01 conditional" in body
                and "P:" in body
                and "R:" in body
                and "**Make/direct" in body
                and "**Next move:**" in body,
                f"Day {day} ordinary actual/P practical branches absent",
            )
        else:
            need(
                "**R opportunity/codes:**" not in body,
                f"Actual branch leaked to Day {day}",
            )
    published = " ".join(
        read(n).lower()
        for n in (
            "README.md",
            "LESSONS.md",
            "ACTUAL-SOURCE-PATHWAY.md",
            "CURRICULUM-CROSSWALK.md",
        )
    )
    need(
        "no actual outcomes collected" in published and "component" in published,
        "Prepared-versus-observed boundary absent",
    )
    for false_claim in (
        "every child attained k01",
        "fiction completes k01",
        "pilot data required to author",
        "school consent is child consent",
    ):
        need(
            false_claim not in published, f"False family/authoring claim: {false_claim}"
        )
    print(
        "PASS five ordinary optional private branches, complete P/pass route and component-specific K01"
    )


def slug(title: str) -> str:
    """Reproduce local Markdown heading anchors."""
    title = re.sub(r"\[[^]]+\]\([^)]+\)", "", title)
    title = re.sub(r"[*_]", "", title).strip().lower()
    title = re.sub(r"[^\w -]", "", title)
    return title.replace(" ", "-")


def links(*, allow_manifest_bootstrap: bool) -> int:
    """Check local Markdown paths and heading targets."""
    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 link: {path.relative_to(ROOT)} -> {url}")
            if marker and target.suffix.lower() == ".md":
                headings = [
                    slug(value)
                    for value 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/heading links")
    return count


def manifest_data() -> dict:
    """Hash authored files, excluding 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-2/weeks-13-14",
        "created_at": "2026-09-30",
        "review_status": "author_desk_checked_pending_school_material_and_child_review",
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial_conditional": sorted(
            PARTIAL_CODES | CONDITIONAL_CODES
        ),
        "knowledge_codes_conditional_on_actual_family_sources": sorted(
            CONDITIONAL_CODES
        ),
        "knowledge_outcomes_observed": [],
        "actual_route_days": sorted(ACTUAL_DAYS),
        "curriculum_codes_held_for_authentic_local_sources": sorted(HELD_CODES),
        "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 every authored byte, size and modification time during read-only checks."""
    return {
        str(path.relative_to(ROOT)): (
            hashlib.sha256(path.read_bytes()).hexdigest(),
            path.stat().st_size,
            path.stat().st_mtime_ns,
        )
        for path in ROOT.rglob("*")
        if path.is_file()
        and "__pycache__" not in path.parts
        and ".ruff_cache" not in path.parts
    }


def audit(*, bootstrap: bool = False) -> dict:
    """Run substantive checks before comparing or intentionally renewing hashes."""
    source()
    pedagogy()
    checks()
    assets()
    family_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 in-memory defects without editing, rebuilding or resealing the pack."""
    original_read = read
    original_parse = ET.parse
    original_output = subprocess.check_output
    count = 0

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

        def virtual_read(name: str) -> str:
            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 = virtual_read
        ET.parse = virtual_parse
        subprocess.check_output = 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 = original_read
            ET.parse = original_parse
            subprocess.check_output = 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", ["AC9HSFK01"]),
        ("knowledge_codes_conditional_on_actual_family_sources", []),
        ("actual_route_days", [61, 62, 63, 64]),
        ("not_claimed_without_local_sources", ["AC9HSFK04"]),
    ):
        changed = copy.deepcopy(snapshot)
        changed[field] = value
        probe(
            field,
            source,
            "Partial/held source accounting drift",
            changes={"source-snapshot.json": json.dumps(changed)},
        )
    changed = copy.deepcopy(snapshot)
    changed["live_verification"]["qcaa_prep_alignment"]["copyright_year"] = 2022
    probe(
        "QCAA attribution receipt",
        source,
        "Current primary receipt drift",
        changes={"source-snapshot.json": json.dumps(changed)},
    )
    probe(
        "exact K01 wording",
        source,
        "Verbatim crosswalk drift: AC9HSFK01",
        changes={
            "CURRICULUM-CROSSWALK.md": original_read("CURRICULUM-CROSSWALK.md").replace(
                "people in their family", "people in every family", 1
            )
        },
    )
    path = original_read("ACTUAL-SOURCE-PATHWAY.md")
    for phrase in (
        "Child choice is required again",
        "actual date of the account",
        "No upload/export to SubjectNest",
        "Full P / closed-field route",
        "No reply means no participation",
    ):
        probe(
            phrase,
            family_route,
            "Actual family route safeguard absent",
            changes={"ACTUAL-SOURCE-PATHWAY.md": path.replace(phrase, "[removed]", 1)},
        )
    lessons = original_read("LESSONS.md")
    probe(
        "25-minute timetable",
        pedagogy,
        "Day 61 25-min timing",
        changes={"LESSONS.md": lessons.replace("· 7 min.**", "· 9 min.**", 1)},
    )
    probe(
        "ordinary actual opportunity",
        family_route,
        "Day 61 ordinary actual/P practical branches absent",
        changes={
            "LESSONS.md": lessons.replace(
                "**R opportunity/codes:** AC9HSFK01 conditional",
                "**R opportunity/codes:** omitted",
                1,
            )
        },
    )
    cards = original_read("LEARNER-CARDS.md")
    probe(
        "third access route",
        pedagogy,
        "Day 61 routes/home",
        changes={"LEARNER-CARDS.md": cards.replace("- **C ·", "- **D ·", 1)},
    )
    probe(
        "fresh source leak",
        checks,
        "Held source leaked into earlier routine: Nora",
        changes={"ACTUAL-SOURCE-PATHWAY.md": path + "\nNora\n"},
    )
    probe(
        "fresh first-response page bytes",
        checks,
        "Held fresh source byte drift: STUDENT-CHECKS.md",
        changes={"STUDENT-CHECKS.md": original_read("STUDENT-CHECKS.md") + "\n"},
    )
    probe(
        "fresh keyed interpretation bytes",
        checks,
        "Held fresh keyed interpretation drift",
        changes={
            "teacher/KEY-AND-NEXT.md": original_read("teacher/KEY-AND-NEXT.md").replace(
                "#", "##", 1
            )
        },
    )
    probe(
        "separate exact alternative",
        assets,
        "PDF/linear source mismatch: family-link-play-mat: Who told us",
        changes={
            "print/TEXT-ALTERNATIVES.md": original_read(
                "print/TEXT-ALTERNATIVES.md"
            ).replace("Who told us", "Whose words", 1)
        },
    )
    ns = {"svg": "http://www.w3.org/2000/svg"}
    probe(
        "blank connection geometry",
        assets,
        "Connection endpoints drift",
        svg=(
            "family-link-play-mat.svg",
            lambda root: root.find('.//svg:path[@data-connection="blank"]', ns).set(
                "d", "M316 326 L490 326"
            ),
        ),
    )
    probe(
        "birth/raising box order",
        assets,
        "Origin/story order drift",
        svg=(
            "born-grew-up-story-mat.svg",
            lambda root: next(
                node for node in root.findall(".//svg:text", ns) if node.text == "BORN"
            ).set("x", "600"),
        ),
    )
    probe(
        "actual PDF page count",
        assets,
        "PDF/selectable text/full alternative drift",
        bad_pdf=True,
    )
    changed = json.loads(original_read("manifest.json"))
    changed["knowledge_outcomes_observed"] = ["AC9HSFK01"]
    probe(
        "manifest false outcome",
        audit,
        "SHA-256 manifest missing or stale",
        changes={"manifest.json": json.dumps(changed)},
    )
    return count


def main() -> None:
    """Run offline checks and optionally write a deterministic SHA receipt."""
    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:
            count = negative_checks()
            print(f"PASS {count} in-memory negative probes")
        need(
            state() == before,
            "Read-only check changed bytes, sizes or modification times",
        )
        print("PASS every authored byte, size and modification time preserved")
    print("PACK PASS · author desk QA only; no physical or child trial")


if __name__ == "__main__":
    main()
