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

import argparse
import ast
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", "AC9HSFK02", "AC9HSFK04"}
CONDITIONAL = {"AC9HSFK03"}
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 = (
    "window-one-view",
    "window-two-view",
    "two-place-voices",
    "lex-care-step",
    "paper-cover-report",
    "pocket-guide-mat",
    "actual-trail-record",
    "check-a-paper-cove",
    "check-b-paper-lantern",
)
EXPECTED = {
    161: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    162: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    163: SKILLS,
    164: SKILLS,
    165: SKILLS,
    166: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    167: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    168: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    169: SKILLS,
    170: 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_place_evidence"])
        == CONDITIONAL
        and not snapshot["knowledge_outcomes_observed"],
        "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",
    )
    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_through_search_result_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(161, 171)),
        "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)) == (33 if day <= 165 else 34), 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 (165, 170):
            need(
                "**Actual route/codes:** AC9HSFK03 conditional" in block
                and block.count("R:") >= 4
                and block.count("P:") >= 3,
                f"Day {day} integrated actual/paper route absent",
            )
        else:
            need(
                "fictional check" in block.lower()
                or "no k03 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 (
        "view window",
        "two own-reason pockets",
        "pocket place guide",
        "give the guide a useful job",
        "small guided look",
    ):
        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 (
        "same invented scene",
        "This is my own reason.",
        "This is a care step, not a use report.",
        "I have not tried this in a real place.",
        "non-visibility does not establish absence",
    ):
        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(161, 171):
        table_row("ACTUAL-SOURCE-PATHWAY.md", day, 3)
    for phrase in (
        "educator preflight",
        "actual date/time",
        "familiar learning place",
        "willing adult",
        "exact words",
        "permission",
        "care procedure",
        "no actual k03 records have been collected",
        "no child, location or speaker data in this repository",
        "done stays blank",
        "if the actual route is unavailable",
        "physical care action is optional",
        "unobserved",
        "place that guide in an approved holder",
        "return one approved shared picture page",
        "source check is not proof of use",
        "don't stage a difference or growth",
        "element decision",
        "second willing account is optional",
    ):
        need(phrase in body.lower(), "actual-place boundary absent: " + phrase)
    row = next(
        line
        for line in read("CURRICULUM-CROSSWALK.md").splitlines()
        if line.startswith("| AC9HSFK03 |")
    )
    need(
        "conditional" in row.lower()
        and "Days 161–164/166–169" in row
        and "no actual k03 outcome" in row.lower(),
        "conditional K03 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", "165"), ("B", "170")],
        "fresh schedule drift",
    )
    need(
        learner.count("\n\n1. ") == 2 and "KEY-AND-NEXT" not in learner,
        "public check/key separation drift",
    )
    need(
        "Paper Cove worked interpretation" in key
        and "Paper Lantern Bay 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",
    )
    ordinary = (
        read("PRACTICE-SWAPS.md")
        + read("MATERIALS.md")
        + "\n".join(
            line
            for line in read("LEARNER-CARDS.md").splitlines()
            if not line.startswith(("| 165 |", "| 170 |"))
        )
    )
    for name in ("Rae", "Sami", "Paz", "Rin", "Teo", "Ari", "Miro"):
        need(not re.search(rf"\b{name}\b", ordinary), "fresh speaker leaked: " + name)
    for phrase in (
        "two star marks",
        "one moon mark",
        "three diamond marks",
        "two dot marks",
        "Before one model try",
        "What do we still need to ask other people",
        "what you actually made or did",
        "plan, a paper thing you made",
    ):
        need(phrase in learner, "fresh source/prompt drift: " + phrase)
    for phrase in (
        "non-visibility isn't absence",
        "reported before/after belongs to one model condition",
        "not yet observable",
        "exact assistance",
        "no actual k03 outcome",
        "two own reasons can coexist",
        "place/source communication portion",
    ):
        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(
            len(top.findall(".//s:path", NS)) + len(top.findall(".//s:circle", NS))
            >= 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 (
        ("window-one-view", "MATERIALS.md"),
        ("window-two-view", "MATERIALS.md"),
        ("two-place-voices", "MATERIALS.md"),
        ("lex-care-step", "MATERIALS.md"),
        ("paper-cover-report", "MATERIALS.md"),
        ("pocket-guide-mat", "MATERIALS.md"),
        ("check-a-paper-cove", "STUDENT-CHECKS.md"),
        ("check-b-paper-lantern", "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:
    """Verify limited-view shapes/counts and partial model cover, not whole-place facts."""
    expected = {
        "window-one-view": [("window", "one")],
        "window-two-view": [("window", "two")],
        "check-a-paper-cove": [("cove", "one"), ("cove", "two")],
        "check-b-paper-lantern": [("lantern", "one"), ("lantern", "two")],
    }
    marksets = {
        "window": ({"oval": 3}, {"narrow": 2}),
        "cove": ({"star": 2}, {"moon": 1}),
        "lantern": ({"diamond": 3}, {"dot": 2}),
    }
    for stem, pairs in expected.items():
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        groups = [g for g in top.findall(".//s:g", NS) if g.get("data-scene-kind")]
        need(
            [(g.get("data-scene-kind"), g.get("data-view")) for g in groups] == pairs,
            f"{stem} source diagram set drift",
        )
        for group in groups:
            kind, view = group.get("data-scene-kind"), group.get("data-view")
            bench = group.find('s:rect[@data-feature="bench"]', NS)
            first = group.find('s:g[@data-feature="first"]', NS)
            second = group.find('s:g[@data-feature="second"]', NS)
            screen = group.find('s:rect[@data-feature="screen"]', NS)
            need(
                bench is not None and first is not None, f"{stem} source feature absent"
            )
            bw, bh = float(bench.get("width")), float(bench.get("height"))
            need(
                (bw > bh) == (kind == "lantern")
                and (kind == "lantern" or abs(bw - bh) < 0.001),
                f"{stem} source square/rectangle drift",
            )
            need(
                (second is None) == (view == "one")
                and (screen is not None) == (view == "one"),
                f"{stem} hidden-region/second-feature drift",
            )
            for i, feature in enumerate((first, second)):
                if feature is None:
                    continue
                marks = [n for n in feature if n.get("data-mark")]
                got = {
                    name: sum(n.get("data-mark") == name for n in marks)
                    for name in {n.get("data-mark") for n in marks}
                }
                need(
                    got == marksets[kind][i],
                    f"{stem} source-backed mark count/type drift",
                )
                outline = (
                    feature.find('s:polygon[@data-outline="holder"]', NS)
                    if kind == "lantern"
                    else feature.find('s:circle[@data-outline="pot"]', NS)
                )
                need(outline is not None, f"{stem} source round/triangle outline drift")
                feature_x = (
                    float(outline.get("cx"))
                    if kind != "lantern"
                    else float(outline.get("points").split()[0].split(",")[0])
                )
                need(
                    float(bench.get("x")) + bw < feature_x,
                    f"{stem} feature left/right drift",
                )
                if i == 1:
                    first_outline = (
                        first.find('s:polygon[@data-outline="holder"]', NS)
                        if kind == "lantern"
                        else first.find('s:circle[@data-outline="pot"]', NS)
                    )
                    first_x = (
                        float(first_outline.get("cx"))
                        if kind != "lantern"
                        else float(first_outline.get("points").split()[0].split(",")[0])
                    )
                    need(
                        first_x < feature_x,
                        f"{stem} first/second feature position drift",
                    )
            if screen is not None:
                need(
                    float(screen.get("x")) > float(bench.get("x")) + bw
                    and float(screen.get("width")) > 0,
                    f"{stem} hidden-right-region geometry drift",
                )
    top = ET.parse(ROOT / "print/paper-cover-report.svg").getroot()
    groups = [g for g in top.findall(".//s:g", NS) if g.get("data-model-phase")]
    need(
        [g.get("data-model-phase") for g in groups] == ["before", "after"],
        "reported model phase drift",
    )
    for group in groups:
        page = group.find('s:rect[@data-feature="page"]', NS)
        cover = group.find('s:rect[@data-feature="cover"]', NS)
        need(
            page is not None
            and (cover is not None) == (group.get("data-model-phase") == "after"),
            "reported model cover phase drift",
        )
        if cover is not None:
            px, py, pw, ph = (float(page.get(v)) for v in ("x", "y", "width", "height"))
            cx, cy, cw, ch = (
                float(cover.get(v)) for v in ("x", "y", "width", "height")
            )
            overlap = max(0, min(px + pw, cx + cw) - max(px, cx)) * max(
                0, min(py + ph, cy + ch) - max(py, cy)
            )
            need(0 < overlap < pw * ph, "reported partial-page cover geometry 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"(?<!!)\[[^]]+\]\(([^)]+)\)", page.read_text()):
            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
    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-33-34",
        "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_place_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 main() -> None:
    """Run an offline audit; JSON mode prints a proposed manifest only."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--manifest-json",
        action="store_true",
        help="Print a proposed receipt; does not write any file",
    )
    args = parser.parse_args()
    sources()
    pedagogy()
    checks()
    assets()
    n = links(args.manifest_json)
    expected = manifest_data()
    if args.manifest_json:
        print(json.dumps(expected, ensure_ascii=False, indent=2))
    else:
        need(
            json.loads(read("manifest.json")) == expected,
            "SHA-256 manifest missing/stale",
        )
        print(
            f"PASS: nine exact direct workbook rows/QCAA pins; ten 25-minute scripts with eight integrated actual-place branches; conditional K03/no outcomes observed; 30 routes; 20 swaps; 10 bridges; two separate fresh checks; nine A4 pairs/exact alternatives/text bounds/keyed view/mark/model-scope geometry; {n} local links; {len(expected['files'])} SHA-256 files"
        )


if __name__ == "__main__":
    main()
