# SPDX-License-Identifier: Apache-2.0
"""Read-only curriculum, pedagogy, source-scope, print and hash audit for W35–36."""
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", "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 = (
    "share-corner-place",
    "two-feature-layouts",
    "two-place-reasons",
    "dara-care-step",
    "one-try-notes",
    "try-logbook",
    "actual-display-record",
    "check-a-paper-arch",
    "check-b-paper-sail",
)
EXPECTED = {
    171: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    172: SKILLS,
    173: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    174: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS03", "AC9HSFS04"},
    175: SKILLS,
    176: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    177: SKILLS,
    178: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"},
    179: SKILLS,
    180: 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",
    )
    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(171, 181)),
        "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)) == (35 if day <= 175 else 36), 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 (175, 180):
            need(
                "**Actual route/codes:** AC9HSFK03 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 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 (
        "place-feature display kit",
        "give each place reason a source",
        "ask-first and return flap",
        "two layouts",
        "one try",
        "repair one step",
        "useful place-care handover",
    ):
        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 my own reason.",
        "This is a care request.",
        "A planned layout is not a use report.",
        "I have not reported anyone else’s use.",
        "equal paper space does not establish agreement or fairness",
    ):
        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(171, 181):
        table_row("ACTUAL-SOURCE-PATHWAY.md", day, 3)
    for phrase in (
        "educator preflight",
        "actual date/time",
        "familiar shared 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 unless observed",
        "if actual route unavailable",
        "physical care action is optional",
        "unobserved",
        "temporarily place approved picture pages on an approved tabletop/holder",
        "return those approved pages to the designated folder/space",
        "source check ≠ use proof",
        "silence is not agreement",
        "element decision",
        "second willing account optional",
        "renewed reader willingness",
        "private owner-review",
    ):
        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 171–174/176–179" 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", "175"), ("B", "180")],
        "fresh schedule drift",
    )
    need(
        learner.count("\n\n1. ") == 2 and "KEY-AND-NEXT" not in learner,
        "public check/key separation drift",
    )
    need(
        "Paper Arch worked interpretation" in key
        and "Paper Sail 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(("| 175 |", "| 180 |"))
        )
    )
    for name in ("Uma", "Oren", "Ves", "Zed", "Wynn", "Tali", "Nela"):
        need(not re.search(rf"\b{name}\b", ordinary), "fresh speaker leaked: " + name)
    for phrase in (
        "rectangle picture wall",
        "oval reading pad",
        "wall-page below pad-page",
        "triangle picture frame",
        "square page tray",
        "tray-page above frame-page",
        "chose pass before the paper 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 (
        "silence is not agreement",
        "not yet observable",
        "exact assistance",
        "no actual k03 outcome",
        "two own reasons that can coexist",
        "place/source communication portion",
        "source repair isn't proof of use",
        "not a controlled causal comparison",
    ):
        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 (
        ("share-corner-place", "MATERIALS.md"),
        ("two-feature-layouts", "MATERIALS.md"),
        ("two-place-reasons", "MATERIALS.md"),
        ("dara-care-step", "MATERIALS.md"),
        ("one-try-notes", "MATERIALS.md"),
        ("check-a-paper-arch", "STUDENT-CHECKS.md"),
        ("check-b-paper-sail", "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 source feature shapes and same-card plan relations; no actual results."""
    expected = {
        "share-corner-place": [("main", "place")],
        "two-feature-layouts": [("main", "beside"), ("main", "stacked")],
        "check-a-paper-arch": [
            ("arch", "place"),
            ("arch", "beside"),
            ("arch", "stacked"),
        ],
        "check-b-paper-sail": [
            ("sail", "place"),
            ("sail", "beside"),
            ("sail", "stacked"),
        ],
    }
    features = {
        "main": {"board": "square", "table": "round"},
        "arch": {"wall": "rectangle", "pad": "oval"},
        "sail": {"frame": "triangle", "tray": "square"},
    }
    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-kind")]
        need(
            [(g.get("data-kind"), g.get("data-layout")) for g in groups] == pairs,
            f"{stem} source/plan diagram set drift",
        )
        same_cards = {}
        for group in groups:
            kind, mode = group.get("data-kind"), group.get("data-layout")
            parts = group.findall("s:g", NS)
            order = list(features[kind])
            if mode == "stacked" and kind in ("arch", "sail"):
                order.reverse()
            need(
                [p.get("data-feature") for p in parts] == order,
                f"{stem} source feature/order drift",
            )
            centres = []
            sizes = []
            for part in parts:
                feature = part.get("data-feature")
                shapes = [n for n in part if n.get("data-shape")]
                need(
                    len(shapes) == 1
                    and shapes[0].get("data-shape") == features[kind][feature],
                    f"{stem} source shape drift",
                )
                shape = shapes[0]
                name = shape.get("data-shape")
                if name in ("square", "rectangle"):
                    w, h = float(shape.get("width")), float(shape.get("height"))
                    need(
                        (abs(w - h) < 0.001) if name == "square" else w > h,
                        f"{stem} square/rectangle geometry drift",
                    )
                    cx, cy = (
                        float(shape.get("x")) + w / 2,
                        float(shape.get("y")) + h / 2,
                    )
                elif name in ("round", "oval"):
                    need(
                        shape.tag.endswith("circle" if name == "round" else "ellipse"),
                        f"{stem} circle/oval geometry drift",
                    )
                    if name == "oval":
                        need(
                            float(shape.get("rx")) > float(shape.get("ry")),
                            f"{stem} oval aspect drift",
                        )
                    cx, cy = float(shape.get("cx")), float(shape.get("cy"))
                else:
                    pts = [
                        tuple(map(float, v.split(",")))
                        for v in shape.get("points").split()
                    ]
                    need(
                        len(pts) == 3 and pts[0][1] < pts[1][1] == pts[2][1],
                        f"{stem} triangle geometry drift",
                    )
                    cx, cy = (
                        sum(t[0] for t in pts) / 3,
                        (min(t[1] for t in pts) + max(t[1] for t in pts)) / 2,
                    )
                card = part.find('s:rect[@data-page="true"]', NS)
                need((card is None) == (mode == "place"), f"{stem} plan-card drift")
                if card is not None:
                    px, py, pw, ph = [
                        float(card.get(v)) for v in ("x", "y", "width", "height")
                    ]
                    need(
                        px < cx < px + pw and py < cy < py + ph,
                        f"{stem} icon outside page",
                    )
                    sizes.append((pw, ph))
                    centres.append((px + pw / 2, py + ph / 2))
                    need(
                        feature.upper() + " PAGE"
                        in " ".join(n.text or "" for n in part.findall("s:text", NS)),
                        f"{stem} page label drift",
                    )
                else:
                    centres.append((cx, cy))
            need(
                (
                    (
                        centres[0][0] < centres[1][0]
                        and abs(centres[0][1] - centres[1][1]) < 0.001
                    )
                    if mode != "stacked"
                    else (
                        centres[0][1] < centres[1][1]
                        and abs(centres[0][0] - centres[1][0]) < 0.001
                    )
                ),
                f"{stem} left/right or above/below plan drift",
            )
            need(not sizes or sizes[0] == sizes[1], f"{stem} same-page plan size drift")
            if sizes:
                if kind in same_cards:
                    need(
                        same_cards[kind] == sizes,
                        f"{stem} same-page size across plans drift",
                    )
                same_cards[kind] = sizes

    actual = read("print/TEXT-ALTERNATIVES.md")
    need(
        "Actual time/source/permission/consent/action/knowledge are blank" in actual,
        "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"(?<!!)\[[^]]+\]\(([^)]+)\)", 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-35-36",
        "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 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 23 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 child family",
            "**Practice:** no child family",
        ),
    )
    reject(
        "blanket K03 hold",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "**Conditional actual-source/place opportunity:**",
            "**Hold actual-source/place opportunity:**",
        ),
    )
    reject(
        "invented actual outcome",
        json_change("knowledge_outcomes_observed", ["AC9HSFK03"]),
    )
    reject(
        "daily timing", replace("LESSONS.md", "**Open · 3 min.**", "**Open · 9 min.**")
    )
    reject("missing ordinary actual branch", replace("LESSONS.md", "R:", "OTHER:"))
    reject(
        "useful actual placement removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "temporarily place approved picture pages on an approved tabletop/holder",
            "review a blank task someday",
        ),
    )
    reject(
        "particular-person willing source removed",
        replace("ACTUAL-SOURCE-PATHWAY.md", "willing adult", "required adult"),
    )
    reject(
        "second account compulsory",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "Second willing account optional",
            "Second willing account compulsory",
        ),
    )
    reject(
        "no-actual-evidence limit removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "No actual K03 records have been collected",
            "Actual K03 records have been collected",
        ),
    )
    reject(
        "silence as agreement",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "silence is not agreement",
            "silence is agreement",
        ),
    )
    reject(
        "renewed willingness removed",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "renewed reader willingness",
            "assumed reader willingness",
        ),
    )
    reject(
        "fresh speaker leakage",
        replace("PRACTICE-SWAPS.md", "invented Pat", "invented Uma"),
    )
    reject(
        "learner key leakage",
        replace(
            "STUDENT-CHECKS.md",
            "# Two held fresh learner checks",
            "KEY-AND-NEXT\n# Two held fresh learner checks",
        ),
    )
    reject(
        "exact alternative drift",
        replace(
            "print/TEXT-ALTERNATIVES.md",
            "Paper Share Corner · A",
            "Paper Share Corner · Wrong",
        ),
    )
    reject(
        "printed source quotation drift",
        replace(
            "MATERIALS.md",
            "I show my paper drawings there",
            "I show everyone’s paper drawings there",
        ),
    )
    reject(
        "QCAA alignment pin", replace("source-snapshot.json", '"230524"', '"999999"')
    )
    reject("QCAA sequence pin", replace("source-snapshot.json", '"220841"', '"999999"'))

    def stretch(top):
        node = top.find('.//s:rect[@data-shape="square"]', NS)
        node.set("width", str(float(node.get("width")) + 12))

    reject("source square geometry", geometry=("share-corner-place.svg", stretch))

    def change_order(top):
        node = top.find('.//s:g[@data-layout="stacked"]', NS)
        node[0].set("data-feature", "wall")

    reject("fresh above/below order", geometry=("check-a-paper-arch.svg", change_order))

    def changed_size(top):
        node = top.find('.//s:g[@data-layout="stacked"]', NS)
        for card in node.findall('.//s:rect[@data-page="true"]', NS):
            card.set("width", str(float(card.get("width")) + 8))
            card.set("x", str(float(card.get("x")) - 4))

    reject(
        "same-page size across plans",
        geometry=("two-feature-layouts.svg", changed_size),
    )
    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) == 23, "negative case set drift")
    print(
        "PASS: 23/23 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 23 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-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 feature/same-page/layout geometry; {n} local links; {len(expected['files'])} SHA-256 files"
        )


if __name__ == "__main__":
    main()
