# SPDX-License-Identifier: Apache-2.0
"""Read-only curriculum, pedagogy, source-scope, print and hash audit for W39–40."""
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 itertools import pairwise
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 = (
    "first-view-leaf",
    "second-view-leaf",
    "place-source-accounts",
    "available-work-strip",
    "three-pocket-mat",
    "actual-place-record",
    "private-handover-cover",
    "check-a-paper-gate",
    "check-b-paper-nook",
)
EXPECTED = {d: SKILLS for d in range(191, 201)}
EXPECTED[191] = {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04"}
EXPECTED[192] = EXPECTED[197] = {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04", "AC9HSFS05"}

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"]
        and not snapshot["knowledge_codes_conditional_on_actual_event_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 useful timed scripts and all same-goal routes/home variants."""
    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(191, 201)),
        "ten-day sequence drift",
    )
    need(len({m.group(3) for m in marks}) == 10, "distinct lessons absent")
    for i, mark in enumerate(marks):
        day = int(mark.group(2))
        need(int(mark.group(1)) == (39 if day <= 195 else 40), "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:\*\* ([^\n]*)",
            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(
                p in block
                for p in (
                    "**Child product:**",
                    "**Next move:**",
                    "**Check/respond · 4 min.**",
                )
            ),
            f"Day {day} product/feedback absent",
        )
        if day not in (195, 200):
            need(
                "**Actual route/codes:** AC9HSFK03 conditional" in block
                and len(re.findall(r"\bP:", block)) >= 3
                and len(re.findall(r"\bR:", block)) >= 4,
                f"Day {day} actual/paper branches absent",
            )
        else:
            need("fictional check" in block.lower(), "fresh knowledge boundary absent")
        need(
            len(set(table_row("LEARNER-CARDS.md", day, 3))) == 3,
            "access route duplicated",
        )
        need(
            all("→" in v for v in table_row("PRACTICE-SWAPS.md", day, 2)),
            "unworked variation",
        )
        table_row("FAMILY-CARER-OPTIONS.md", day, 1)
    for phrase in (
        "three broad pockets",
        "first view leaf",
        "person’s account flap",
        "truthful help-and-work strip",
        "second dated view",
        "repair one folder claim",
        "useful private job",
        "honest handover piece",
    ):
        need(phrase in body.lower(), "practical project absent: " + phrase)
    need(
        "not permanent learning-style categories" in read("LEARNER-CARDS.md"),
        "learner stereotype boundary absent",
    )
    need(
        "No child must disclose" in read("README.md")
        and "Skipping has no assessment cost" in read("FAMILY-CARER-OPTIONS.md"),
        "privacy/optional boundary absent",
    )
    material = read("MATERIALS.md")
    for phrase in (
        "These are drawn marks, not an actual weather record.",
        "It does not say why the marks differ or what a real season is.",
        "not everyone’s",
        "No first answer, actual child, earlier date or second matching response is available.",
        "It cannot show a child improved.",
        "This paper request is not actual permission or a viewer reply.",
    ):
        need(phrase in material, "ordinary source limit absent: " + phrase)
    actual_pathway()


def actual_pathway() -> None:
    """Require genuine dated indoor-view/account/action/earlier-work workflow."""
    body = read("ACTUAL-SOURCE-PATHWAY.md").lower()
    for day in range(191, 201):
        table_row("ACTUAL-SOURCE-PATHWAY.md", day, 3)
    for phrase in (
        "educator preflight",
        "safe familiar learning place",
        "closed pane",
        "sun looking",
        "capture date/time and viewing date/time separately",
        "willing adult",
        "same-view",
        "two observations cannot establish",
        "no actual k03 records have been collected",
        "no child, family, place or speaker data in this repository",
        "done stays blank unless observed",
        "if actual route unavailable",
        "silence is not consent",
        "source review is not proof of use",
        "unknown help",
        "not available",
        "physical care action is optional",
        "folder filing alone",
        "element decision",
        "model reply",
        "unobserved",
        "recognised local country/place opportunity",
        "hold the relevant knowledge claim",
        "private",
        "no actual school name",
    ):
        if phrase == "no actual school name":
            need(
                phrase in read("CURRICULUM-CROSSWALK.md").lower(),
                "K04 authenticity absent",
            )
        else:
            need(phrase in body, "actual boundary absent: " + phrase)
    row = next(
        v
        for v in read("CURRICULUM-CROSSWALK.md").splitlines()
        if v.startswith("| AC9HSFK03 |")
    )
    need(
        "Conditional" in row
        and "Days 191–194/196–199" in row
        and "No actual K03 outcome" in row,
        "K03 precise conditional mapping drift",
    )
    need(
        "weather/season drawings do not map here"
        in read("CURRICULUM-CROSSWALK.md").lower(),
        "K04 weather false mapping",
    )
    need(
        "NOT AVAILABLE" in read("LESSONS.md")
        and "no year-complete grade" in read("LESSONS.md"),
        "portfolio authenticity absent",
    )


def checks() -> None:
    """Require held fresh cases and separate keys, with no ordinary-source leak."""
    learner, key = read("STUDENT-CHECKS.md"), read("teacher/KEY-AND-NEXT.md")
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)", learner, re.MULTILINE)
        == [("A", "195"), ("B", "200")],
        "fresh schedule drift",
    )
    need(
        learner.count("\n\n1. ") == 2 and "KEY-AND-NEXT" not in learner,
        "fresh key separation drift",
    )
    need(
        "Paper Gate worked interpretation" in key
        and "Paper Nook worked interpretation" in key,
        "worked key absent",
    )
    for phrase in (
        "until its designated day",
        "each child's first answer separately",
        "Pacing is untested",
        "not yet observed",
        "no peer-answer credit",
    ):
        need(phrase in learner, "fresh evidence boundary absent: " + phrase)
    need(
        "Blank unrelated mechanisms" in read("LEARNER-CARDS.md"),
        "fresh neutral preview absent",
    )
    ordinary = (
        read("MATERIALS.md")
        + read("PRACTICE-SWAPS.md")
        + "\n".join(
            v
            for v in read("LEARNER-CARDS.md").splitlines()
            if not v.startswith(("| 195 |", "| 200 |"))
        )
    )
    for name in ("Sera", "Jori"):
        need(not re.search(rf"\b{name}\b", ordinary), "fresh speaker leakage")
    for phrase in (
        "Story Day Two",
        "Story Day Six",
        "same closed-pane view",
        "Story Day Three",
        "Story Day Nine",
        "side view",
        "The views differ",
        "No earlier child work is available",
        "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 k03 outcome",
        "different view limits",
        "not a recovered child history",
        "no automatic regrade",
        "recognised local authority-approved",
    ):
        need(phrase in key.lower(), "key scope 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 (
        ("first-view-leaf", "MATERIALS.md"),
        ("second-view-leaf", "MATERIALS.md"),
        ("place-source-accounts", "MATERIALS.md"),
        ("available-work-strip", "MATERIALS.md"),
        ("private-handover-cover", "MATERIALS.md"),
        ("check-a-paper-gate", "STUDENT-CHECKS.md"),
        ("check-b-paper-nook", "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("**", "")),
                "exact source-script quote drift: " + stem,
            )
    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 visible source marks and measured text inside its own panels."""
    cases = {"first-view-leaf": ("first", 1, 0), "second-view-leaf": ("second", 2, 3)}
    for stem, (kind, leaves, drops) in cases.items():
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        group = top.find(f'.//s:g[@data-view-kind="{kind}"]', NS)
        need(group is not None, "model view absent")
        planter = group.find('.//s:rect[@data-object="square-planter"]', NS)
        need(
            planter is not None and planter.get("width") == planter.get("height"),
            "square planter geometry drift",
        )
        need(
            len(group.findall('.//s:path[@data-object="leaf"]', NS)) == leaves
            and len(group.findall('.//s:path[@data-object="drop"]', NS)) == drops,
            "source leaf/drop geometry drift",
        )
    for stem, kinds, obj, counts in (
        ("check-a-paper-gate", ("gate1", "gate2"), "cloud", (1, 2)),
        ("check-b-paper-nook", ("nook1", "nook2"), "dot", (2, 1)),
    ):
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        for kind, count in zip(kinds, counts):
            group = top.find(f'.//s:g[@data-view-kind="{kind}"]', NS)
            need(
                group is not None
                and len(group.findall(f'.//*[@data-object="{obj}"]')) == count,
                "fresh visible marks drift",
            )
    for stem in ("check-a-paper-gate", "check-b-paper-nook"):
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        for group in top.findall(".//s:g[@data-view-kind]", NS):
            kind = group.get("data-view-kind")
            if kind.startswith("gate"):
                square = group.find('s:rect[@data-object="square-opening"]', NS)
                clouds = group.findall('s:path[@data-object="cloud"]', NS)
                bounds = []
                for cloud in clouds:
                    values = [
                        float(v) for v in re.findall(r"-?\d+(?:\.\d+)?", cloud.get("d"))
                    ]
                    xs, ys = values[::2], values[1::2]
                    bounds.append((min(xs), max(xs), min(ys), max(ys)))
                need(
                    all(b[3] < float(square.get("y")) for b in bounds),
                    "cloud/opening vertical relation drift",
                )
                need(
                    all(a[1] < b[0] for a, b in pairwise(bounds)),
                    "two cloud marks overlap",
                )
            else:
                shelf = group.find('s:rect[@data-object="low-shelf"]', NS)
                dots = group.findall('s:circle[@data-object="dot"]', NS)
                sy = float(shelf.get("y"))
                if kind == "nook1":
                    need(
                        all(float(d.get("cy")) + float(d.get("r")) < sy for d in dots),
                        "front dots not above shelf",
                    )
                else:
                    d = dots[0]
                    need(
                        float(d.get("cx")) - float(d.get("r"))
                        > float(shelf.get("x")) + float(shelf.get("width"))
                        and sy <= float(d.get("cy")) <= sy + float(shelf.get("height")),
                        "side dot not beside shelf",
                    )
    need(
        "Actual source/date/place/speaker/permission/action/knowledge are blank"
        in read("print/TEXT-ALTERNATIVES.md"),
        "actual print evidence prefilled",
    )
    for stem in STEMS:
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        for panel in top.findall('.//s:g[@data-panel="true"]', NS):
            rect = panel.find("s:rect", NS)
            rx, ry, rw, rh = [float(rect.get(v)) for v in ("x", "y", "width", "height")]
            for node in panel.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,
                    "text exceeds its own panel: " + stem,
                )


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-39-40",
        "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 in-memory semantic/layout/hash drifts without touching pack files."""

    def state():
        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 = []

    def replace(name, first, changed):
        def reader(target):
            value = original_read(target)
            if target == name:
                need(first in value, "mutation fixture unmatched: " + name)
                value = value.replace(first, changed)
            return value

        return reader

    def json_change(field, changed):
        def reader(target):
            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, reader=None, geometry=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("mutation escaped: " + label)
        finally:
            globals()["read"], ET.parse = original_read, original_parse

    reject("workbook digest", json_change("workbook_sha256", "0" * 64))
    reject(
        "exact description",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "share narratives and observations",
            "share guesses and observations",
        ),
    )
    reject(
        "K01 hold",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "**Hold:** no authentic child family",
            "**Practice:** no authentic child family",
        ),
    )
    reject(
        "K02 hold",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "**Hold:** no new actual significant-event",
            "**Practice:** no new actual significant-event",
        ),
    )
    reject(
        "K04 hold",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "**Hold:** exact Country/Place",
            "**Practice:** exact Country/Place",
        ),
    )
    reject(
        "false K04 weather mapping",
        replace(
            "CURRICULUM-CROSSWALK.md",
            "Weather/season drawings do not map here",
            "Weather/season drawings fill K04",
        ),
    )
    reject(
        "blanket K03 hold",
        json_change("knowledge_codes_conditional_on_actual_place_evidence", []),
    )
    reject(
        "invented actual outcome",
        json_change("knowledge_outcomes_observed", ["AC9HSFK03"]),
    )
    reject(
        "timing", replace("LESSONS.md", "Make/direct · 7 min.", "Make/direct · 8 min.")
    )
    reject(
        "ordinary actual branch",
        replace(
            "LESSONS.md",
            "**Actual route/codes:** AC9HSFK03 conditional",
            "**Actual route/codes:** unavailable",
        ),
    )
    reject(
        "actual date precision",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "capture date/time and viewing date/time separately",
            "date guessed from worksheet",
        ),
    )
    reject(
        "safe view",
        replace("ACTUAL-SOURCE-PATHWAY.md", "sun looking", "direct sunlight viewing"),
    )
    reject(
        "willing adult account",
        replace("ACTUAL-SOURCE-PATHWAY.md", "willing adult", "compulsory informant"),
    )
    reject(
        "silence consent",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md", "silence is not consent", "silence is consent"
        ),
    )
    reject(
        "folder filing as care",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "Folder filing alone",
            "Folder filing proves care",
        ),
    )
    reject(
        "no actual evidence",
        replace(
            "ACTUAL-SOURCE-PATHWAY.md",
            "No actual K03 records have been collected",
            "Actual K03 completed",
        ),
    )
    reject(
        "weather marks as actual",
        replace(
            "MATERIALS.md",
            "These are drawn marks, not an actual weather record.",
            "These are actual local weather records.",
        ),
    )
    reject(
        "invented growth",
        replace(
            "MATERIALS.md",
            "It cannot show a child improved.",
            "It proves year-long improvement.",
        ),
    )
    reject(
        "request as permission",
        replace(
            "MATERIALS.md",
            "This paper request is not actual permission or a viewer reply.",
            "Permission and viewer learning are complete.",
        ),
    )
    reject(
        "fresh pacing as attainment",
        replace(
            "STUDENT-CHECKS.md", "Pacing is untested", "Pacing certifies attainment"
        ),
    )
    reject(
        "peer answer as individual",
        replace("STUDENT-CHECKS.md", "no peer-answer credit", "credit peer answers"),
    )
    reject(
        "fresh key leak",
        replace(
            "STUDENT-CHECKS.md",
            "# Held fresh public formative checks",
            "# Held fresh public formative checks\nRead KEY-AND-NEXT first",
        ),
    )
    reject(
        "fresh speaker leak",
        replace(
            "PRACTICE-SWAPS.md",
            "unrelated blank three fields",
            "Sera unrelated blank three fields",
        ),
    )
    reject(
        "exact alternative", replace("print/TEXT-ALTERNATIVES.md", "PLANTER", "PLANT")
    )
    reject(
        "fresh view false comparison",
        replace("STUDENT-CHECKS.md", "The views differ", "The views match"),
    )
    reject(
        "private workstation path",
        replace(
            "RUN-THROUGH.md",
            "From this directory:",
            "/home/private/station From this directory:",
        ),
    )
    reject(
        "stale receipt",
        replace(
            "manifest.json", '"created_at": "2026-09-30"', '"created_at": "1900-01-01"'
        ),
    )

    def lose_leaf(top):
        group = top.find('.//s:g[@data-view-kind="second"]', NS)
        group.remove(group.find('s:path[@data-object="leaf"]', NS))

    reject("second leaf geometry", geometry=("second-view-leaf.svg", lose_leaf))

    def over_panel(top):
        top.find('.//s:g[@data-panel="true"]/s:rect', NS).set("height", "45")

    reject("panel overflow", geometry=("check-a-paper-gate.svg", over_panel))
    audit()
    need(before == state(), "negative audit changed bytes/size/mtime")
    need(len(results) == 29, "negative case count drift")
    print(
        "PASS: 29/29 in-memory mutations rejected; restored normal audit; all pack bytes/sizes/mtime 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-place branches; conditional K03/no outcomes observed; 30 routes; 20 swaps; 10 bridges; two separate fresh checks; nine A4 pairs/exact alternatives/text/panel bounds/keyed source-view mark geometry; {n} local links; {len(expected['files'])} SHA-256 files"
        )


if __name__ == "__main__":
    main()
