# SPDX-License-Identifier: Apache-2.0
"""Read-only curriculum, pedagogy, source-scope, print and hash audit for W37–38."""
from __future__ import annotations

import argparse
import ast
import copy
import hashlib
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]
SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
ROWS = {
    "AC9HSFK01": 1213,
    "AC9HSFK02": 1217,
    "AC9HSFK03": 1221,
    "AC9HSFK04": 1226,
    "AC9HSFS01": 1233,
    "AC9HSFS02": 1237,
    "AC9HSFS03": 1241,
    "AC9HSFS04": 1245,
    "AC9HSFS05": 1251,
}
KNOWLEDGE = {f"AC9HSFK0{i}" for i in range(1, 5)}
HELD = {"AC9HSFK01", "AC9HSFK03", "AC9HSFK04"}
CONDITIONAL = {"AC9HSFK02"}
SKILLS = set(ROWS) - KNOWLEDGE
QCAA = "https://www.qcaa.qld.edu.au/p-10/aciq/version-9/learning-areas/p-10-humanities-and-social-sciences/hass"
ALIGN = "https://www.qcaa.qld.edu.au/downloads/aciqv9/humanities-and-social-sciences/curriculum/ac9_hass_prep_as_cd_alignment.pdf"
STEMS = (
    "book-gathering-report",
    "book-display-trace",
    "event-source-accounts",
    "label-plan-request",
    "event-trace-fold",
    "marker-pocket-mat",
    "actual-event-record",
    "check-a-frame-book",
    "check-b-circle-note",
)
EXPECTED = {
    181: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    182: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04", "AC9HSFS05"},
    183: SKILLS,
    184: SKILLS,
    185: SKILLS,
    186: SKILLS,
    187: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    188: SKILLS,
    189: SKILLS,
    190: SKILLS,
}

REQUIRED = {
    "README.md",
    "ACTUAL-SOURCE-PATHWAY.md",
    "LESSONS.md",
    "MATERIALS.md",
    "LEARNER-CARDS.md",
    "PRACTICE-SWAPS.md",
    "FAMILY-CARER-OPTIONS.md",
    "STUDENT-CHECKS.md",
    "teacher/KEY-AND-NEXT.md",
    "CURRICULUM-CROSSWALK.md",
    "LOCAL-SOURCE-INSERT.md",
    "SOURCE-AND-RIGHTS.md",
    "RUN-THROUGH.md",
    "source-snapshot.json",
    "CODE-LICENSE.txt",
    "verify_pack.py",
    "print/generate_print.py",
    "print/TEXT-ALTERNATIVES.md",
    "print/DEJAVU-FONT-LICENSE.txt",
}
REQUIRED.update(f"print/{s}.{e}" for s in STEMS for e in ("svg", "pdf"))
NS = {"s": "http://www.w3.org/2000/svg"}
XNS = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
FONT_ROOT = Path("/usr/share/fonts/truetype/dejavu")


def need(ok: object, message: str) -> None:
    """Stop at a concrete missing or drifted requirement."""
    if not ok:
        raise AssertionError(message)


def read(name: str) -> str:
    """Read a named pack text file without writing."""
    return (ROOT / name).read_text(encoding="utf-8")


def compact(value: str) -> str:
    """Normalise layout whitespace while preserving exact source words."""
    return " ".join(value.split())


def table_row(name: str, day: int, count: int) -> list[str]:
    """Require one nonempty fixed-width participation row per day."""
    rows = re.findall(rf"^\| {day} \|(.+)$", read(name), re.MULTILINE)
    need(len(rows) == 1, f"{name}: Day {day} absent/duplicated")
    cells = [x.strip() for x in rows[0].strip().strip("|").split("|")]
    need(len(cells) == count and all(cells), f"{name}: Day {day} cell drift")
    return cells


def workbook_rows(workbook: Path) -> dict[int, list[str]]:
    """Read the actual pinned Learning areas worksheet using standard XML."""
    with ZipFile(workbook) as z:
        strings: list[str] = []
        if "xl/sharedStrings.xml" in z.namelist():
            strings = [
                "".join(si.itertext())
                for si in ET.fromstring(z.read("xl/sharedStrings.xml")).findall(
                    "x:si", XNS
                )
            ]
        sheet = ET.fromstring(z.read("xl/worksheets/sheet1.xml"))
        out: dict[int, list[str]] = {}
        for row in sheet.findall("x:sheetData/x:row", XNS):
            number = int(row.attrib["r"])
            if number not in ROWS.values():
                continue
            cells = [""] * 12
            for cell in row.findall("x:c", XNS):
                letters = re.match(r"[A-Z]+", cell.attrib["r"]).group(0)
                col = 0
                for char in letters:
                    col = col * 26 + ord(char) - 64
                val = cell.find("x:v", XNS)
                content = val.text if val is not None and val.text else ""
                if cell.get("t") == "s":
                    content = strings[int(content)]
                elif cell.get("t") == "inlineStr":
                    content = "".join(cell.find("x:is", XNS).itertext())
                if col <= 12:
                    cells[col - 1] = compact(content)
            out[number] = cells
        return out


def sources() -> None:
    """Compare direct workbook rows, imported records and the exact crosswalk."""
    workbook = (
        STUDIO
        / "research/sources/acara-australian-curriculum-v9-download-2026-09-29.xlsx"
    )
    imported = json.loads((STUDIO / "data/frameworks/acara-v9.json").read_text())
    snapshot = json.loads(read("source-snapshot.json"))
    cross = read("CURRICULUM-CROSSWALK.md")
    need(
        hashlib.sha256(workbook.read_bytes()).hexdigest()
        == imported["source_sha256"]
        == snapshot["workbook_sha256"]
        == SHA,
        "official workbook SHA drift",
    )
    need(snapshot["content_rows"] == ROWS, "official row pins drift")
    need(
        snapshot["queensland_prep_entry_point_url"] == QCAA
        and snapshot["queensland_prep_alignment_url"] == ALIGN
        and QCAA in cross
        and ALIGN in cross,
        "QCAA pins drift",
    )
    need(
        set(snapshot["skill_practice_partial_codes"]) == SKILLS
        and not snapshot["fictional_concept_rehearsal_only_codes"]
        and set(snapshot["knowledge_codes_held_for_authentic_local_sources"]) == HELD
        and set(snapshot["knowledge_codes_conditional_on_actual_event_evidence"])
        == CONDITIONAL
        and not snapshot["knowledge_outcomes_observed"]
        and not snapshot["knowledge_codes_conditional_on_actual_place_evidence"],
        "skill/knowledge boundary drift",
    )
    records = {
        r["code"]: r
        for r in imported["records"]
        if r.get("record_type") == "content_description" and r.get("code") in ROWS
    }
    direct = workbook_rows(workbook)
    need(
        set(records) == set(ROWS) and set(direct) == set(ROWS.values()),
        "official Foundation set absent",
    )
    for code, number in ROWS.items():
        r = records[code]
        values = direct[number]
        need(
            values[:3]
            == ["Humanities and Social Sciences", "HASS F-6", "Foundation Year"]
            and values[4] == code
            and values[9] == compact(r["plain_text"])
            and r["source_row"] == number,
            f"direct source row drift {code}",
        )
        pattern = rf"^\| {code} \| {number} \| Humanities and Social Sciences · HASS F-6 · Foundation Year \| {re.escape(values[9])} \|"
        need(re.search(pattern, cross, re.MULTILINE), f"exact crosswalk drift {code}")
        if code in HELD:
            need(
                "hold"
                in next(
                    line
                    for line in cross.splitlines()
                    if line.startswith(f"| {code} |")
                ).lower(),
                f"authentic hold absent {code}",
            )
    need(
        snapshot["live_verification"]["checked_at"] == "2026-09-30"
        and snapshot["live_verification"]["acara_workbook"]["sha256"] == SHA,
        "current author source receipt absent",
    )
    alignment = snapshot["live_verification"]["qcaa_prep_alignment"]
    need(
        alignment["pages"] == 1
        and alignment["document_identifier"] == "230524"
        and alignment["status"]
        == "official_pdf_opened_and_all_nine_descriptions_compared",
        "current QCAA alignment pin drift",
    )
    sequence = snapshot["live_verification"]["qcaa_content_sequence"]
    need(
        sequence["url"]
        == "https://www.qcaa.qld.edu.au/downloads/aciqv9/humanities-and-social-sciences/curriculum/ac9_hass_p-6_cd_sequence.pdf"
        and sequence["pages"] == 3
        and sequence["document_identifier"] == "220841"
        and sequence["status"]
        == "official_pdf_opened_all_nine_prep_descriptions_compared",
        "current QCAA sequence pin drift",
    )
    for term in (
        "other states",
        "not secure exams",
        "conditional",
        "actual-source-pathway.md",
    ):
        need(term in cross.lower(), f"crosswalk boundary absent: {term}")
    gate = read("LOCAL-SOURCE-INSERT.md").lower()
    need(
        all(
            term in gate
            for term in (
                "cultural authority",
                "hold the relevant knowledge claim",
                "icip",
                "free, prior and informed consent",
            )
        ),
        "local/ICIP gate absent",
    )


def pedagogy() -> None:
    """Require ten timed practical scripts, 30 distinct routes and 20 worked swaps."""
    body = read("LESSONS.md")
    marks = list(re.finditer(r"^## Week (\d+) · Day (\d+) · (.+)$", body, re.MULTILINE))
    need(
        [int(m.group(2)) for m in marks] == list(range(181, 191)),
        "ten-day sequence drift",
    )
    need(len({m.group(3) for m in marks}) == 10, "lesson titles duplicated")
    for i, mark in enumerate(marks):
        day = int(mark.group(2))
        need(int(mark.group(1)) == (37 if day <= 185 else 38), f"Day {day} week drift")
        block = body[
            mark.end() : marks[i + 1].start() if i + 1 < len(marks) else len(body)
        ]
        times = [
            int(v)
            for v in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*", block, re.MULTILINE
            )
        ]
        need(
            times == [3, 4, 5, 7, 4, 2] and sum(times) == 25, f"Day {day} timing drift"
        )
        target = re.search(
            r"^\*\*Target/codes:\*\* (.*?)(?:\*\*Actual route/codes:|\*\*Ready:)",
            block,
            re.MULTILINE,
        )
        need(
            target
            and set(re.findall(r"\bAC9HSF[KS]\d\d\b", target.group(1)))
            == EXPECTED[day],
            f"Day {day} exact codes drift",
        )
        need(
            all(
                v in block
                for v in (
                    "**Child product:**",
                    "**Next move:**",
                    "**Check/respond · 4 min.**",
                )
            ),
            f"Day {day} product/feedback absent",
        )
        if day not in (185, 190):
            need(
                "**Actual route/codes:** AC9HSFK02 conditional" in block
                and len(re.findall(r"\bR:", block)) >= 4
                and len(re.findall(r"\bP:", block)) >= 3,
                f"Day {day} integrated actual/paper route absent",
            )
        else:
            need(
                "fictional check" in block.lower()
                or "no k02 inference from fiction" in block.lower(),
                f"Day {day} knowledge boundary absent",
            )
        need(
            len(set(table_row("LEARNER-CARDS.md", day, 3))) == 3,
            f"Day {day} access options duplicated",
        )
        need(
            all("→" in v for v in table_row("PRACTICE-SWAPS.md", day, 2)),
            f"Day {day} variations not worked",
        )
        table_row("FAMILY-CARER-OPTIONS.md", day, 1)
    for phrase in (
        "choose an event source pocket",
        "unfold the event and marking trace",
        "one person's own account",
        "sharing-source flap",
        "plain source marker",
        "repair one event-marker claim",
        "useful job",
        "honest event trail",
    ):
        need(phrase in body.lower(), "practical progression absent: " + phrase)

    need(
        "not permanent learning-style categories" in read("LEARNER-CARDS.md").lower(),
        "fixed learning-style boundary absent",
    )
    need(
        "no child must disclose" in read("README.md").lower()
        and "skipping has no assessment cost"
        in read("FAMILY-CARER-OPTIONS.md").lower(),
        "privacy/optional boundary absent",
    )
    materials = read("MATERIALS.md")
    for phrase in (
        "This is an invented event report.",
        "These are my own words.",
        "I have not spoken for other families or visitors.",
        "A label made now is not a record that a new celebration happened.",
        "A dated everyday activity does not by itself establish a significant event or celebration/commemoration.",
    ):
        need(phrase in materials, "source boundary absent: " + phrase)

    actual_pathway()


def actual_pathway() -> None:
    """Require complete actual-source care/use steps and explicit evidence gaps."""
    body = read("ACTUAL-SOURCE-PATHWAY.md")
    for day in range(181, 191):
        table_row("ACTUAL-SOURCE-PATHWAY.md", day, 3)
    for phrase in (
        "educator preflight",
        "actually marked significant event",
        "actual event source/date",
        "actual celebration/commemoration trace",
        "willing particular-person account",
        "source-specific sharing with others",
        "family-specific breadth remains open",
        "a dated everyday activity",
        "future invitation",
        "exact words",
        "permission",
        "no actual k02 records have been collected",
        "no child, family, event or speaker data in this repository",
        "done stays blank unless observed",
        "if actual route unavailable",
        "physical marker use is optional",
        "unobserved",
        "plain marker or no-marker route",
        "return copied pages to the designated envelope",
        "source review is not proof of use",
        "silence is not consent",
        "element decision",
        "second account optional",
        "private source pocket/oral explanation",
        "marker made today",
        "model replies",
    ):
        need(phrase in body.lower(), "actual-event boundary absent: " + phrase)

    row = next(
        line
        for line in read("CURRICULUM-CROSSWALK.md").splitlines()
        if line.startswith("| AC9HSFK02 |")
    )
    need(
        "conditional" in row.lower()
        and "Days 181–184/186–189" in row
        and "no actual k02 outcome" in row.lower(),
        "conditional K02 mapping drift",
    )


def checks() -> None:
    """Require two held new cases without ordinary speaker or worked-key leakage."""
    learner, key = read("STUDENT-CHECKS.md"), read("teacher/KEY-AND-NEXT.md")
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)", learner, re.MULTILINE)
        == [("A", "185"), ("B", "190")],
        "fresh schedule drift",
    )
    need(
        learner.count("\n\n1. ") == 2 and "KEY-AND-NEXT" not in learner,
        "public check/key separation drift",
    )
    need(
        "Paper Frame Book worked interpretation" in key
        and "Paper Circle Note worked interpretation" in key
        and "public formative checks" in key,
        "teacher key absent",
    )
    need(
        "until its designated day" in learner
        and "blank unrelated mechanisms" in read("LEARNER-CARDS.md"),
        "held-check neutral access boundary absent",
    )
    for phrase in (
        "not yet observed",
        "each child's first answer separately",
        "pacing is untested",
    ):
        need(
            phrase in learner.lower(),
            "fresh participation/evidence boundary drift: " + phrase,
        )
    ordinary = (
        read("PRACTICE-SWAPS.md")
        + read("MATERIALS.md")
        + "\n".join(
            line
            for line in read("LEARNER-CARDS.md").splitlines()
            if not line.startswith(("| 185 |", "| 190 |"))
        )
    )
    for name in ("Bela", "Cato", "Rumi", "Davi", "Esme"):
        need(not re.search(rf"\b{name}\b", ordinary), "fresh speaker leaked: " + name)
    for phrase in (
        "Story Day Five",
        "Story Day Seven",
        "Story Day Nine",
        "square page inside a wide frame",
        "Story Day One",
        "Story Day Eight",
        "undated drawing",
        "I have not reported that look taking place",
        "what you actually made/directed",
        "what source/history and today's making each show",
    ):
        need(phrase in learner, "fresh source/prompt drift: " + phrase)
    for phrase in (
        "not yet observable",
        "exact assistance",
        "no actual k02 outcome",
        "family breadth remains open",
        "source review is not proof of use",
        "a dated everyday activity alone is insufficient",
        "particular",
        "past event",
        "no actual commemoration inferred",
    ):
        need(phrase in key.lower(), "key/evidence boundary drift: " + phrase)


def assets() -> None:
    """Check measured A4 text, searchable PDFs, exact alternatives and keyed facts."""
    alt = 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(t, ast.Name) and t.id == "PAGES" for t in node.targets)
    )
    need(
        isinstance(pages, ast.Dict) and {k.value for k in pages.keys} == set(STEMS),
        "generator page set drift",
    )
    for stem in STEMS:
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        need(
            top.get("width") == "210mm"
            and top.get("height") == "297mm"
            and top.get("viewBox") == "0 0 794 1123"
            and top.find("s:title", NS) is not None
            and top.find("s:desc", NS) is not None
            and top.get("aria-labelledby") == "title desc",
            f"{stem} A4/semantic SVG drift",
        )
        nodes = top.findall(".//s:text", NS)
        exact = compact(" ".join("".join(n.itertext()) for n in nodes))
        section = re.search(
            rf"^## {stem}\.svg / {stem}\.pdf\n\n\*\*Exact visible text:\*\* (.*?)\n\n\*\*Illustration and tactile/no-print:\*\* (.*?)(?=\n\n## |\Z)",
            alt,
            re.MULTILINE | re.DOTALL,
        )
        need(
            section
            and compact(section.group(1)) == exact
            and len(section.group(2).split()) >= 25,
            f"{stem} exact text/tactile alternative drift",
        )
        need(
            sum(
                len(top.findall(".//s:" + tag, NS))
                for tag in ("path", "circle", "ellipse", "polygon")
            )
            >= 2,
            f"{stem} illustration absent",
        )
        for node in nodes:
            value = "".join(node.itertext())
            size = int(node.get("font-size", "20"))
            font = ImageFont.truetype(
                str(
                    FONT_ROOT
                    / (
                        "DejaVuSans-Bold.ttf"
                        if node.get("font-weight") == "700"
                        else "DejaVuSans.ttf"
                    )
                ),
                size,
            )
            x, y = float(node.get("x", "0")), float(node.get("y", "0"))
            left, up, right, down = font.getbbox(value, anchor="ls")
            need(
                x + left >= 29
                and x + right <= 765
                and y + up >= 20
                and y + down <= 1100,
                f"{stem} text bounds drift: {value}",
            )
        pdf = ROOT / "print" / f"{stem}.pdf"
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        raw = subprocess.check_output(["pdftotext", "-raw", str(pdf), "-"], text=True)
        fonts = subprocess.check_output(["pdffonts", str(pdf)], text=True)
        need(
            re.search(r"Pages:\s+1\b", info) and "(A4)" in info,
            "PDF A4/page drift " + stem,
        )
        need(
            "DejaVu" in fonts and "yes yes yes" in fonts,
            "embedded/searchable font drift " + stem,
        )
        need(compact(raw) == exact, f"{stem} exact searchable PDF text drift")
    for stem, source in (
        ("book-gathering-report", "MATERIALS.md"),
        ("book-display-trace", "MATERIALS.md"),
        ("event-source-accounts", "MATERIALS.md"),
        ("label-plan-request", "MATERIALS.md"),
        ("check-a-frame-book", "STUDENT-CHECKS.md"),
        ("check-b-circle-note", "STUDENT-CHECKS.md"),
    ):
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        words = compact(
            " ".join("".join(n.itertext()) for n in top.findall(".//s:text", NS))
        )
        for quote in re.findall("“([^”]+)”", words):
            need(
                quote in compact(read(source).replace("**", "")),
                f"{stem} exact source-script quotation drift: {quote}",
            )
    need(
        "PDFs are not tagged" in alt
        and "bitstream" in read("print/DEJAVU-FONT-LICENSE.txt").lower(),
        "PDF/font claim boundary absent",
    )
    keyed_geometry()


def keyed_geometry() -> None:
    """Check dated/report/plan/undated trace facts and keyed source object shapes."""
    expected = {
        "event-trace-fold": (
            "main",
            [
                ("Story Day Two", "EVENT", "REPORTED"),
                ("Story Day Three", "MARKING", "REPORTED"),
                ("Story Day Six", "LABEL", "PLAN"),
            ],
        ),
        "check-a-frame-book": (
            "frame",
            [
                ("Story Day Five", "EVENT", "REPORTED"),
                ("Story Day Seven", "MARKING", "REPORTED"),
                ("Story Day Nine", "LABEL", "PLAN"),
            ],
        ),
        "check-b-circle-note": (
            "circle",
            [
                ("Story Day One", "READING", "REPORTED"),
                ("Story Day Eight", "QUIET LOOK", "PLAN"),
                ("Drawing date", "UNDATED", "OPEN"),
            ],
        ),
    }
    for stem, (kind, values) in expected.items():
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        group = top.find(f'.//s:g[@data-timeline-kind="{kind}"]', NS)
        need(group is not None, f"{stem} timeline absent")
        items = group.findall("s:g", NS)
        need(
            [
                (n.get("data-date"), n.get("data-event"), n.get("data-status"))
                for n in items
            ]
            == values,
            f"{stem} dated/report/plan/undated timeline drift",
        )
        centres = []
        for i, item in enumerate(items):
            need(
                item.get("data-position") == str(i),
                f"{stem} source sequence index drift",
            )
            words = compact(" ".join(n.text or "" for n in item.findall("s:text", NS)))
            need(
                all(v in words for v in values[i]),
                f"{stem} visible timeline text drift",
            )
            rect = item.find("s:rect", NS)
            centres.append(float(rect.get("x")) + float(rect.get("width")) / 2)
            rx, ry, rw, rh = [float(rect.get(v)) for v in ("x", "y", "width", "height")]
            for node in item.findall("s:text", NS):
                font = ImageFont.truetype(
                    str(
                        FONT_ROOT
                        / (
                            "DejaVuSans-Bold.ttf"
                            if node.get("font-weight") == "700"
                            else "DejaVuSans.ttf"
                        )
                    ),
                    int(node.get("font-size")),
                )
                left, up, right, down = font.getbbox(node.text or "", anchor="ls")
                x, y = float(node.get("x")), float(node.get("y"))
                need(
                    rx + 4 <= x + left
                    and x + right <= rx + rw - 4
                    and ry + 4 <= y + up
                    and y + down <= ry + rh - 4,
                    f"{stem} timeline text exceeds its panel",
                )

        need(
            centres == sorted(centres) and len(set(centres)) == 3,
            f"{stem} timeline left/right drift",
        )
    for stem in ("book-gathering-report", "book-display-trace"):
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        need(
            top.find('.//s:g[@data-object="open-book"]', NS) is not None,
            f"{stem} book source absent",
        )
        banner = top.find('.//s:rect[@data-object="wide-banner"]', NS)
        need(
            banner is not None
            and float(banner.get("width")) > float(banner.get("height")),
            f"{stem} wide banner drift",
        )
    top = ET.parse(ROOT / "print/check-a-frame-book.svg").getroot()
    frame = top.find('.//s:rect[@data-object="wide-frame"]', NS)
    page = top.find('.//s:rect[@data-object="square-page"]', NS)
    need(frame is not None and page is not None, "fresh frame/page absent")
    fx, fy, fw, fh = [float(frame.get(v)) for v in ("x", "y", "width", "height")]
    px, py, pw, ph = [float(page.get(v)) for v in ("x", "y", "width", "height")]
    need(
        abs(pw - ph) < 0.001
        and fw > fh
        and fx < px
        and fy < py
        and px + pw < fx + fw
        and py + ph < fy + fh,
        "fresh square page inside wide frame drift",
    )
    top = ET.parse(ROOT / "print/check-b-circle-note.svg").getroot()
    page = top.find('.//s:rect[@data-object="rectangle-page"]', NS)
    dot = top.find('.//s:circle[@data-object="round-label"]', NS)
    need(
        page is not None
        and dot is not None
        and float(page.get("width")) > float(page.get("height"))
        and float(page.get("x")) + float(page.get("width"))
        < float(dot.get("cx")) - float(dot.get("r")),
        "fresh rectangle page left of round label drift",
    )
    need(
        "Actual event/date/source/family/consent/action/knowledge are blank"
        in read("print/TEXT-ALTERNATIVES.md"),
        "actual printable prefilled-evidence boundary drift",
    )


def links(build: bool) -> int:
    """Validate local Markdown links without network access or mutation."""
    total = 0
    for page in ROOT.rglob("*.md"):
        for link in re.findall(
            r"(?<!!)\[[^]]+\]\(([^)]+)\)", read(page.relative_to(ROOT).as_posix())
        ):
            if link.startswith(("https://", "http://", "mailto:")):
                continue
            name, marker, anchor = unquote(link).partition("#")
            target = (page.parent / name).resolve() if name else page
            if not (build and target == ROOT / "manifest.json"):
                need(
                    target.is_file(),
                    f"broken local link {page.relative_to(ROOT)} -> {link}",
                )
            if marker and target.suffix == ".md":
                heads = re.findall(r"^#{1,6} (.+)$", target.read_text(), re.MULTILINE)
                slugs = {
                    re.sub(
                        r"[^\w -]", "", re.sub(r"[*_]", "", h).strip().lower()
                    ).replace(" ", "-")
                    for h in heads
                }
                need(anchor in slugs, f"broken anchor {link}")
            total += 1
    for page in ROOT.rglob("*.md"):
        need(
            not re.search(
                r"/(?:home|Users|tmp)/", read(page.relative_to(ROOT).as_posix())
            ),
            "private workstation path in published document: " + page.name,
        )
    return total


def manifest_data() -> dict[str, object]:
    """Return a complete file receipt; the caller deliberately saves it."""
    files = sorted(
        p
        for p in ROOT.rglob("*")
        if p.is_file()
        and p.name != "manifest.json"
        and "__pycache__" not in p.parts
        and ".ruff_cache" not in p.parts
    )
    names = {p.relative_to(ROOT).as_posix() for p in files}
    need(
        names == REQUIRED,
        f"pack file set drift: missing={sorted(REQUIRED-names)}, extra={sorted(names-REQUIRED)}",
    )
    for file in files:
        if file.suffix in (".md", ".py", ".svg", ".txt", ".json"):
            body = file.read_text()
            need(
                body.endswith("\n")
                and "\r" not in body
                and all(line == line.rstrip() for line in body.splitlines()),
                f"whitespace drift {file.relative_to(ROOT)}",
            )
    return {
        "schema": "subjectnest-authored-pack-manifest-v1",
        "pack": "foundation/hass/term-4/weeks-37-38",
        "created_at": "2026-09-30",
        "review_status": "author_desk_checked_pending_educator_local_access_and_child_review",
        "curriculum_source_sha256": SHA,
        "curriculum_codes_partial_conditional": sorted(SKILLS | CONDITIONAL),
        "knowledge_codes_conditional_on_actual_event_evidence": sorted(CONDITIONAL),
        "knowledge_outcomes_observed": [],
        "curriculum_codes_fictional_rehearsal_only": [],
        "curriculum_codes_held_for_authentic_local_sources": sorted(HELD),
        "files": {
            p.relative_to(ROOT).as_posix(): {
                "sha256": hashlib.sha256(p.read_bytes()).hexdigest(),
                "bytes": p.stat().st_size,
            }
            for p in files
        },
    }


def audit(build: bool = False) -> tuple[int, dict[str, object]]:
    """Run all checks quietly; building prints a proposed receipt in main only."""
    sources()
    pedagogy()
    checks()
    assets()
    n = links(build)
    expected = manifest_data()
    if not build:
        need(
            json.loads(read("manifest.json")) == expected,
            "SHA-256 manifest missing/stale",
        )
    return n, expected


def negative_checks() -> None:
    """Reject 29 in-memory drifts and preserve every file's bytes/size/mtime."""

    def state() -> dict[str, tuple[str, int, int]]:
        return {
            p.relative_to(ROOT).as_posix(): (
                hashlib.sha256(p.read_bytes()).hexdigest(),
                p.stat().st_size,
                p.stat().st_mtime_ns,
            )
            for p in ROOT.rglob("*")
            if p.is_file()
        }

    audit()
    before = state()
    original_read, original_parse = read, ET.parse
    results: list[tuple[str, str]] = []

    def replace(name: str, first: str, changed: str):
        def reader(target: str) -> str:
            value = original_read(target)
            if target == name:
                need(first in value, "negative fixture no longer matches: " + name)
                value = value.replace(first, changed)
            return value

        return reader

    def json_change(field: str, changed: object):
        def reader(target: str) -> str:
            value = original_read(target)
            if target == "source-snapshot.json":
                obj = json.loads(value)
                obj[field] = changed
                value = json.dumps(obj)
            return value

        return reader

    def reject(label: str, reader=None, geometry=None) -> None:
        globals()["read"] = reader or original_read
        if geometry:

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

            ET.parse = parse
        try:
            audit()
        except AssertionError as error:
            results.append((label, str(error)))
        else:
            raise AssertionError("negative mutation escaped: " + label)
        finally:
            globals()["read"], ET.parse = original_read, original_parse

    reject("workbook digest", json_change("workbook_sha256", "0" * 64))
    reject(
        "exact official description",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "share narratives and observations",
            "share guesses and observations",
        ),
    )
    reject(
        "K01 authentic hold",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "**Hold:** no authentic child family",
            "**Practice:** no authentic child family",
        ),
    )
    reject(
        "K03 distinct hold",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "**Hold:** event venue/pocket",
            "**Practice:** event venue/pocket",
        ),
    )
    reject(
        "blanket K02 hold",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "**Conditional actual-event opportunity:**",
            "**Hold actual-event opportunity:**",
        ),
    )
    reject(
        "invented actual outcome",
        json_change("knowledge_outcomes_observed", ["AC9HSFK02"]),
    )
    reject(
        "daily timing", replace("LESSONS.md", "**Open · 3 min.**", "**Open · 9 min.**")
    )
    reject("ordinary actual branch removed", replace("LESSONS.md", "R:", "OTHER:"))
    reject(
        "actual completed marking removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "actually marked significant event",
            "merely dated activity",
        ),
    )
    reject(
        "actual marking trace removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "actual celebration/commemoration trace",
            "future generic plan",
        ),
    )
    reject(
        "particular-person willingness removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "willing particular-person account",
            "required public account",
        ),
    )
    reject(
        "source-specific sharing removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "source-specific sharing with others",
            "assumed everyone sharing",
        ),
    )
    reject(
        "family breadth closed without evidence",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "Family-specific breadth remains open",
            "Family-specific breadth is complete",
        ),
    )
    reject(
        "no actual evidence boundary removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "No actual K02 records have been collected",
            "Actual K02 records have been collected",
        ),
    )
    reject(
        "silence treated as consent",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md", "silence is not consent", "silence is consent"
        ),
    )
    reject(
        "child no-marker choice removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "plain marker or no-marker route",
            "compulsory public ritual",
        ),
    )
    reject(
        "useful copied-page return removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "return copied pages to the designated envelope",
            "move the actual sacred object",
        ),
    )
    reject(
        "fresh speaker leakage",
        replace("PRACTICE-SWAPS.md", "invented organiser", "invented Bela"),
    )
    reject(
        "learner key leakage",
        replace(
            "STUDENT-CHECKS.md",
            "# Two held fresh checks",
            "KEY-AND-NEXT\n# Two held fresh checks",
        ),
    )
    reject(
        "fresh pace treated as attainment",
        replace(
            "STUDENT-CHECKS.md", "not yet observed", "must count as independent success"
        ),
    )
    reject(
        "peer answer as individual evidence",
        replace(
            "STUDENT-CHECKS.md",
            "each child's first answer separately",
            "the pair's shared answer",
        ),
    )
    reject(
        "exact text alternative drift",
        replace(
            "print/TEXT-ALTERNATIVES.md",
            "Paper Book Gathering · A",
            "Paper Book Gathering · Wrong",
        ),
    )
    reject(
        "printed quotation source drift",
        replace(
            "MATERIALS.md",
            "some visitors looked at a book-page display",
            "all families looked at a book-page display",
        ),
    )
    reject("QCAA sequence pin", replace("source-snapshot.json", '"220841"', '"999999"'))

    def plan_as_report(top):
        group = top.find('.//s:g[@data-timeline-kind="circle"]', NS)
        group[1].set("data-status", "REPORTED")

    reject(
        "planned commemoration made reported",
        geometry=("check-b-circle-note.svg", plan_as_report),
    )

    def page_outside(top):
        page = top.find('.//s:rect[@data-object="square-page"]', NS)
        page.set("x", "1")

    reject(
        "square page outside frame", geometry=("check-a-frame-book.svg", page_outside)
    )

    def oversized_panel_text(top):
        group = top.find('.//s:g[@data-timeline-kind="frame"]', NS)
        group[0].find("s:text", NS).set("font-size", "26")

    reject(
        "fresh timeline text exceeds panel",
        geometry=("check-a-frame-book.svg", oversized_panel_text),
    )
    reject(
        "private path in published document",
        replace(
            "RUN-THROUGH.md",
            "From this pack directory:",
            "/home/private/workstation: From this pack directory:",
        ),
    )
    reject(
        "stale receipt",
        replace(
            "manifest.json", '"created_at": "2026-09-30"', '"created_at": "1900-01-01"'
        ),
    )
    audit()
    need(before == state(), "negative checks changed pack bytes/size/mtime")
    need(len(results) == 29, "negative case set drift")
    print(
        "PASS: 29/29 in-memory mutations rejected; restored normal audit; all pack bytes, sizes and modification times preserved"
    )
    for label, reason in results:
        print(label + ": " + reason)


def main() -> None:
    """Run an offline read-only audit or in-memory negative checks."""
    parser = argparse.ArgumentParser(description=__doc__)
    group = parser.add_mutually_exclusive_group()
    group.add_argument(
        "--manifest-json",
        action="store_true",
        help="Print a proposed receipt; writes no file",
    )
    group.add_argument(
        "--negative-checks",
        action="store_true",
        help="Reject 29 in-memory drifts without changing files",
    )
    args = parser.parse_args()
    if args.negative_checks:
        negative_checks()
        return
    n, expected = audit(args.manifest_json)
    if args.manifest_json:
        print(json.dumps(expected, ensure_ascii=False, indent=2))
    else:
        print(
            f"PASS: nine exact direct workbook rows/QCAA pins; ten 25-minute scripts with eight integrated actual-event branches; conditional K02/no outcomes observed; 30 routes; 20 swaps; 10 bridges; two separate fresh checks; nine A4 pairs/exact alternatives/text bounds/keyed event/marking/plan/undated trace geometry; {n} local links; {len(expected['files'])} SHA-256 files"
        )


if __name__ == "__main__":
    main()
