# SPDX-License-Identifier: Apache-2.0
"""Fail-closed read-only source, pedagogy, print and digest audit for HASS W17-18."""

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

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,
}
HELD = {f"AC9HSFK0{i}" for i in range(1, 5)}
SKILLS = set(ROWS) - HELD
REHEARSAL = "AC9HSFK03"
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 = (
    "arch-square-map",
    "arch-square-eye-view",
    "arch-square-voices",
    "arch-square-keeper",
    "feature-key-mat",
    "source-claim-mat",
    "check-a-tile-pavilion",
    "check-b-paper-lantern-yard",
)
PDF_PHRASES = {
    "arch-square-map": (
        "OVERHEAD PICTORIAL MAP",
        "PLANTER 1",
        "PLANTER 2",
        "It does not say why anyone values this square",
    ),
    "arch-square-eye-view": (
        "EYE-LEVEL SKETCH",
        "ONE NEAR PLANTER",
        "far planter is outside this sketch",
    ),
    "arch-square-voices": (
        "MIRA says",
        "THEO says",
        "The shaded bench is special to me",
        "I do not know why Mira likes the bench",
    ),
    "arch-square-keeper": (
        "REN asks",
        "portable game cards",
        "I have not reported whether anyone followed",
        "REQUEST ≠ OBSERVED ACTION",
    ),
    "feature-key-mat": ("ARCH SHAPE", "SHADE ROOF", "RECTANGLE WITH LEGS", "PLANTER"),
    "source-claim-mat": (
        "MAP LOCATION",
        "PERSON’S REASON",
        "KEEPER ASKS",
        "STILL UNKNOWN",
    ),
    "check-a-tile-pavilion": (
        "TILE PAVILION",
        "TWO ROUND MOSAIC TABLES",
        "FAR TABLE OUT OF VIEW",
        "I cannot tell you whether Sam likes that seat",
    ),
    "check-b-paper-lantern-yard": (
        "PAPER LANTERN YARD",
        "ARI",
        "BEA",
        "does not report that anyone",
        "moved the card",
    ),
}
EXPECTED = {
    81: {"AC9HSFS01", "AC9HSFS02", "AC9HSFS04", REHEARSAL},
    82: {"AC9HSFS02", "AC9HSFS04", REHEARSAL},
    83: {"AC9HSFS03", "AC9HSFS04", "AC9HSFS05", REHEARSAL},
    84: {"AC9HSFS01", "AC9HSFS03", "AC9HSFS04", REHEARSAL},
    85: set(SKILLS) | {REHEARSAL},
    86: {"AC9HSFS01", "AC9HSFS04", REHEARSAL},
    87: {"AC9HSFS01", "AC9HSFS03", "AC9HSFS04", REHEARSAL},
    88: {"AC9HSFS01", "AC9HSFS04", REHEARSAL},
    89: set(SKILLS) | {REHEARSAL},
    90: set(SKILLS) | {REHEARSAL},
}
REQUIRED = {
    "README.md",
    "MATERIALS.md",
    "LESSONS.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",
}
for stem in STEMS:
    REQUIRED.update((f"print/{stem}.svg", f"print/{stem}.pdf"))


def need(ok, msg):
    if not ok:
        raise AssertionError(msg)


def read(name):
    return (ROOT / name).read_text(encoding="utf-8")


def row(rel, day, n):
    found = re.findall(rf"^\| {day} \|(.+)$", read(rel), re.MULTILINE)
    need(len(found) == 1, f"{rel}: Day {day} absent or duplicate")
    cells = [c.strip() for c in found[0].strip().strip("|").split("|")]
    need(len(cells) == n and all(cells), f"{rel}: Day {day} cell drift")
    return cells


def source():
    workbook = (
        STUDIO
        / "research/sources/acara-australian-curriculum-v9-download-2026-09-29.xlsx"
    )
    imported = json.loads(
        (STUDIO / "data/frameworks/acara-v9.json").read_text(encoding="utf-8")
    )
    snap = json.loads(read("source-snapshot.json"))
    cross = read("CURRICULUM-CROSSWALK.md")
    need(
        hashlib.sha256(workbook.read_bytes()).hexdigest()
        == imported["source_sha256"]
        == snap["workbook_sha256"]
        == SHA,
        "ACARA source digest drift",
    )
    need(snap["content_rows"] == ROWS, "source row pin drift")
    need(
        snap["queensland_prep_entry_point_url"] == QCAA
        and snap["queensland_prep_alignment_url"] == ALIGN
        and QCAA in cross
        and ALIGN in cross,
        "QCAA Prep pin drift",
    )
    need(
        set(snap["skill_practice_partial_codes"]) == SKILLS
        and set(snap["fictional_concept_rehearsal_only_codes"]) == {REHEARSAL}
        and set(snap["knowledge_codes_held_for_authentic_local_sources"]) == HELD,
        "skill/held accounting drift",
    )
    records = {
        r["code"]: r
        for r in imported["records"]
        if r.get("record_type") == "content_description" and r.get("code") in ROWS
    }
    need(set(records) == set(ROWS), "official Foundation HASS row set absent")
    for code, no in ROWS.items():
        item = records[code]
        a = item["attributes"]
        need(
            item["source_row"] == no
            and a["learning_area"] == "Humanities and Social Sciences"
            and a["subject"] == "HASS F-6"
            and a["level"] == "Foundation Year",
            f"official source row drift {code}",
        )
        exact = rf"^\| {code} \| {no} \| Humanities and Social Sciences · HASS F-6 · Foundation Year \| {re.escape(item['plain_text'])} \|"
        need(re.search(exact, cross, re.MULTILINE), f"crosswalk wording drift {code}")
        if code in HELD:
            line = next(x for x in cross.splitlines() if x.startswith(f"| {code} |"))
            need("hold" in line.lower(), f"authentic hold absent {code}")
    for phrase in (
        "fictional concept rehearsal only",
        "not secure exams",
        "other states",
        "country/place",
    ):
        need(phrase in cross.lower(), f"crosswalk boundary absent {phrase}")
    need(
        "cultural authority" in read("LOCAL-SOURCE-INSERT.md").lower()
        and "hold the relevant knowledge claim"
        in read("LOCAL-SOURCE-INSERT.md").lower(),
        "local gate missing",
    )


def pedagogy():
    body = read("LESSONS.md")
    marks = list(re.finditer(r"^## Week \d+ · Day (\d+)\b", body, re.MULTILINE))
    need(
        [int(m.group(1)) for m in marks] == list(range(81, 91)), "teacher day sequence"
    )
    for i, m in enumerate(marks):
        d = int(m.group(1))
        sec = body[m.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\.\*\*", sec, re.MULTILINE
            )
        ]
        need(times == [3, 4, 5, 7, 4, 2], f"Day {d} timed sequence {times}")
        target = re.search(r"^\*\*Target/codes:\*\* ([^\n]+)", sec, re.MULTILINE)
        need(target is not None, f"Day {d} target absent")
        codes = set(re.findall(r"\bAC9HSF(?:K|S)\d\d\b", target.group(1)))
        need(codes == EXPECTED[d], f"Day {d} code mapping drift {codes}")
        need(
            "**Check/respond · 4 min.**" in sec
            and "**Exit · 2 min.**" in sec
            and "**Next move:**" in sec,
            f"Day {d} feedback absent",
        )
        routes = row("LEARNER-CARDS.md", d, 3)
        need(len(set(routes)) == 3, f"Day {d} access routes repeated")
        swaps = row("PRACTICE-SWAPS.md", d, 2)
        need(all("→" in s for s in swaps), f"Day {d} swap not worked")
        row("FAMILY-CARER-OPTIONS.md", d, 1)
    need(
        "no child must disclose" in read("README.md").lower()
        and "not permanent learning-style categories"
        in read("LEARNER-CARDS.md").lower(),
        "access/privacy boundary absent",
    )


def checks():
    student = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)\b", student, re.MULTILINE)
        == [("A", "85"), ("B", "90")],
        "check schedule",
    )
    need(
        student.count("\n\n1. ") == 2 and "teacher/KEY-AND-NEXT.md" not in student,
        "public first response/key separation",
    )
    for phrase in (
        "Tile Pavilion worked interpretation",
        "Paper Lantern Yard worked interpretation",
        "not a child’s actual familiar place",
    ):
        need(phrase.lower() in key.lower(), f"worked key/limit absent: {phrase}")
    before = read("PRACTICE-SWAPS.md") + "\n".join(
        x
        for x in read("LEARNER-CARDS.md").splitlines()
        if not x.startswith("| 85 |") and not x.startswith("| 90 |")
    )
    for name in ("Tile Pavilion", "Lia", "Sam", "Paper Lantern Yard", "Ari", "Bea"):
        need(name not in before, f"fresh case leaked into ordinary practice: {name}")
    need(
        "far table is gone" in student
        and "does not report that anyone moved the card" in student,
        "independent unknowns absent",
    )


def assets():
    alt = read("print/TEXT-ALTERNATIVES.md")
    tree = ast.parse(read("print/generate_print.py"))
    page_node = 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(page_node, ast.Dict)
        and {k.value for k in page_node.keys if isinstance(k, ast.Constant)}
        == set(STEMS),
        "generator page set drift",
    )
    ns = {"s": "http://www.w3.org/2000/svg"}
    linear_alt = " ".join(alt.lower().split())
    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,
            f"{stem} SVG A4/access metadata",
        )
        svg_text = " ".join("".join(e.itertext()) for e in top.findall(".//s:text", ns))
        need(
            len(top.findall(".//s:path", ns))
            + len(top.findall(".//s:circle", ns))
            + len(top.findall(".//s:polygon", ns))
            >= 2,
            f"{stem} not illustrated",
        )
        pdf = ROOT / "print" / f"{stem}.pdf"
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        raw = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        words = " ".join(raw.lower().split())
        fonts = subprocess.check_output(["pdffonts", str(pdf)], text=True)
        need(
            re.search(r"Pages:\s+1\b", info)
            and "(A4)" in info
            and "DejaVu" in fonts
            and len(raw.split()) >= 20,
            f"{stem} PDF/access/size drift",
        )
        need(
            f"{stem}.svg" in alt
            and f"{stem}.pdf" in alt
            and "tactile/no-print" in alt.lower(),
            f"{stem} alternative absent",
        )
        for phrase in PDF_PHRASES[stem]:
            need(
                phrase.lower() in words and phrase.lower() in linear_alt,
                f"{stem} PDF/source alternative mismatch: {phrase}",
            )
        need(
            "CC BY 4.0" in raw and "SUBJECTNEST" in raw and "SUBJECTNEST" in svg_text,
            f"{stem} imprint missing",
        )
    circles = (
        ET.parse(ROOT / "print/arch-square-map.svg")
        .getroot()
        .findall(".//s:circle", ns)
    )
    need(
        len(circles) == 2
        and {(c.get("cx"), c.get("cy")) for c in circles}
        == {("570", "572"), ("659", "619")},
        "two mapped planters geometry drift",
    )
    check = ET.parse(ROOT / "print/check-a-tile-pavilion.svg").getroot()
    need(
        len(check.findall(".//s:circle", ns)) == 3,
        "check A map/sketch circle structure drift",
    )
    need(
        "bitstream" in read("print/DEJAVU-FONT-LICENSE.txt").lower(),
        "font licence missing",
    )


def slug(s):
    s = re.sub(r"\[[^]]+\]\([^)]+\)", "", s)
    s = re.sub(r"[*_]", "", s).strip().lower()
    s = re.sub(r"[^\w -]", "", s)
    return s.replace(" ", "-")


def links(bootstrap):
    n = 0
    for p in ROOT.rglob("*.md"):
        for link in re.findall(
            r"(?<!!)\[[^]]+\]\(([^)]+)\)", p.read_text(encoding="utf-8")
        ):
            if link.startswith(("https://", "http://", "mailto:")):
                continue
            name, marker, anchor = unquote(link).partition("#")
            dest = (p.parent / name).resolve() if name else p
            if bootstrap and dest == ROOT / "manifest.json":
                n += 1
                continue
            need(dest.is_file(), f"broken link {p.relative_to(ROOT)} -> {link}")
            if marker and dest.suffix == ".md":
                heads = re.findall(
                    r"^#{1,6} (.+)$", dest.read_text(encoding="utf-8"), re.MULTILINE
                )
                need(anchor in {slug(h) for h in heads}, f"broken anchor {link}")
            n += 1
    return n


def manifest_data():
    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
    )
    rel = {p.relative_to(ROOT).as_posix() for p in files}
    need(
        rel == REQUIRED,
        f"file set drift missing={sorted(REQUIRED - rel)} extra={sorted(rel - REQUIRED)}",
    )
    for p in files:
        if p.suffix in (".md", ".py", ".svg", ".txt", ".json"):
            s = p.read_text(encoding="utf-8")
            need(
                s.endswith("\n")
                and "\r" not in s
                and all(line == line.rstrip() for line in s.splitlines()),
                f"{p.name} whitespace",
            )
    return {
        "schema": "subjectnest-authored-pack-manifest-v1",
        "pack": "foundation/hass/term-2/weeks-17-18",
        "created_at": "2026-09-30",
        "review_status": "author_desk_checked_pending_school_material_and_child_review",
        "curriculum_source_sha256": SHA,
        "curriculum_codes_partial_conditional": sorted(SKILLS),
        "curriculum_codes_fictional_rehearsal_only": [REHEARSAL],
        "curriculum_codes_held_for_authentic_local_sources": sorted(HELD),
        "files": {
            str(p.relative_to(ROOT)): {
                "sha256": hashlib.sha256(p.read_bytes()).hexdigest(),
                "bytes": p.stat().st_size,
            }
            for p in files
        },
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--write-manifest", action="store_true")
    a = ap.parse_args()
    source()
    pedagogy()
    checks()
    assets()
    n = links(a.write_manifest)
    expected = manifest_data()
    target = ROOT / "manifest.json"
    if a.write_manifest:
        target.write_text(
            json.dumps(expected, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
        )
    else:
        need(
            json.loads(target.read_text(encoding="utf-8")) == expected,
            "SHA256 manifest missing or stale",
        )
    print(
        f"PASS: nine exact ACARA rows/QCAA Prep; ten 25-minute scripts; 30 routes; 20 swaps; 10 bridges; two separate checks; eight A4 pairs; {n} local links; {len(expected['files'])} SHA256 files"
    )


if __name__ == "__main__":
    main()
