# SPDX-License-Identifier: Apache-2.0
"""Read-only by default: source, lesson, link, A4, SHA and regeneration audit."""

from __future__ import annotations

import contextlib
import io
import argparse
import html
from zipfile import ZipFile
import hashlib
import json
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import unquote

from pypdf import PdfReader

ROOT = Path(__file__).resolve().parent
STUDIO = ROOT.parents[4]
SOURCE_SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
ROWS = {"AC9ADRFE01": 20732, "AC9ADRFD01": 20741, "AC9ADRFC01": 20751, "AC9ADRFP01": 20760}
DESCRIPTIONS = {
    "AC9ADRFE01": "explore how and why the arts are important for people and communities",
    "AC9ADRFD01": "use play, imagination, arts knowledge, processes and/or skills to discover possibilities and develop ideas",
    "AC9ADRFC01": "create arts works that communicate ideas",
    "AC9ADRFP01": "share their arts works with audiences",
}
CONTEXT_ROWS = {'AC9EFLY02': 72, 'AC9MFST01': 17211}
CONTEXT_DESCRIPTIONS = {'AC9EFLY02': 'interact in informal and structured situations by listening while others speak and using features of voice including volume levels', 'AC9MFST01': 'collect, sort and compare data represented by objects and images in response to given investigative questions that relate to familiar situations'}
URLS = (
    "https://www.qcaa.qld.edu.au/p-10/aciq/version-9/learning-areas/p-10-the-arts/drama",
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/the-arts/curriculum/ac9_drama_prep_as_cd_alignment.pdf",
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/the-arts/assessment/ac9_arts_perform_tc_prep.pdf",
)
STEMS = ('one-prop','role-clue','place-play','source-scene','meaning-swap','tiny-surprise','quiet-finish','fresh-o','fresh-p')

def need(ok: object, message: str) -> None:
    if not ok: raise AssertionError(message)

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

def source() -> None:
    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"))
    snapshot = json.loads(read("source-snapshot.json"))
    crosswalk = read("CURRICULUM-CROSSWALK.md")
    rights = read("SOURCE-AND-RIGHTS.md")
    need(hashlib.sha256(workbook.read_bytes()).hexdigest() == imported["source_sha256"] == snapshot["workbook_sha256"] == SOURCE_SHA, "Official workbook/import/snapshot SHA drift")
    need(snapshot["content_rows"] == ROWS and snapshot["contextual_rows"] == CONTEXT_ROWS, "Exact source row drift")
    need([snapshot[key] for key in ("queensland_prep_entry_point_url", "queensland_prep_alignment_url", "queensland_prep_techniques_url")] == list(URLS), "QCAA source URL drift")
    need(all(url in crosswalk and url in rights for url in URLS), "QCAA primary link missing")
    records = {item["code"]: item for item in imported["records"] if item.get("code") in set(ROWS) | set(CONTEXT_ROWS)}
    for code, row in ROWS.items():
        item = records[code]
        need(item["source_row"] == row and item["attributes"]["level"] == "Foundation Year" and item["attributes"]["subject"] == "Drama", f"Source pin mismatch {code}")
        need(item["plain_text"] == DESCRIPTIONS[code] and DESCRIPTIONS[code] in crosswalk, f"Description mismatch {code}")
    for code, row in CONTEXT_ROWS.items():
        item = records[code]
        need(item["source_row"] == row and item["plain_text"] == CONTEXT_DESCRIPTIONS[code] and CONTEXT_DESCRIPTIONS[code] in crosswalk, f"Context source mismatch {code}")
    need(not re.search(r"\|\n\n\| AC9", crosswalk), "Crosswalk table rows must be contiguous")
    need("403" in rights and "not observed" in crosswalk.lower(), "Official fetch or claim boundary missing")
    receipts = json.loads(read("source-fetch-receipts.json"))["receipts"]
    need(len(receipts) == 4 and receipts[0]["http_status"] == 200 and receipts[0]["sha256"] == SOURCE_SHA, "Fresh ACARA fetch receipt drift")
    need(all(item["http_status"] == 403 for item in receipts[1:]), "QCAA blocked-fetch receipt drift")
    need("bytes/layout unverified" in snapshot["qcaa_evidence_status"], "Current QCAA PDF limitation missing")
    week15 = STUDIO / "content/foundation/term-1/week-09-show-what-we-found.md"
    week16 = STUDIO / "content/foundation/term-1/week-10-what-changed.md"
    need("practice" in week15.read_text() and "new evidence" in week16.read_text(), "Integrated context drift")
    with ZipFile(workbook) as archive:
        ns = {"s":"http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
        strings = ["".join(item.itertext()) for item in ET.fromstring(archive.read("xl/sharedStrings.xml")).findall("s:si",ns)]
        sheet = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
        for code, row in {**ROWS, **CONTEXT_ROWS}.items():
            cells = {}
            for cell in sheet.find(f's:sheetData/s:row[@r="{row}"]',ns):
                val = cell.find("s:v",ns)
                if val is not None: cells[re.sub(r"[0-9]", "", cell.get("r"))] = strings[int(val.text)] if cell.get("t")=="s" else val.text
            desc = html.unescape(re.sub(r"<[^>]+>", "", cells["J"]))
            need(cells["E"] == code and re.sub(r"\s+", " ", desc).strip() == records[code]["plain_text"], "Actual workbook cell/import mismatch: " + code)
    need("complete four-page" in crosswalk and "403" in rights and "screenshot" in crosswalk,"Indexed-versus-current-byte limit missing")
    supplied=read("SUPPLIED-WORK.md")
    need(all(x in supplied for x in ["PROP-MODEL-09","original saved dramatic text","help imagine different story meanings for one ordinary prop","depicted setup and enactments are unmade proposals","PROP-CHECK-O-09","PROP-CHECK-P-10"]),"Supplied actual saved-work identity/type/purpose/unmade boundary drift")
    need("230416" in crosswalk and "230142-01" in crosswalk and "four-page" in snapshot["qcaa_evidence_status"],"Current source version/page count drift")
    print("PASS actual official workbook/import E/J cells, 4 Drama + 2 context pins, fresh receipts and saved text/unmade performance boundary")



def pedagogy() -> None:
    lessons = read("LESSONS.md")
    blocks = re.split(r"(?=^## Week \d+ · Day \d+ ·)", lessons, flags=re.MULTILINE)[1:]
    days = []
    for block in blocks:
        day = int(re.search(r"^## Week \d+ · Day (\d+) ·", block).group(1)); days.append(day)
        minutes = [int(x) for x in re.findall(r"^\d\. \*\*[^*]+ · (\d+) min\.\*\*", block, flags=re.MULTILINE)]
        need(minutes == [3,4,5,7,4,2] and sum(minutes)==25, f"Day {day} timing drift")
        need("**Goal/codes:**" in block and "**Prepare:**" in block and "AC9ADRFD01" in block, f"Day {day} runnable Drama goal missing")
    need(days==list(range(41,51)), "Expected Days 41–50")
    cards=read("LEARNER-CARDS.md")
    for day in days:
        segment=re.search(rf"^## Day {day} .*?(?=^## Day \d+ |^## Record|\Z)",cards,re.MULTILINE|re.DOTALL)
        need(segment and all(re.search(rf"\*\*{route} ·",segment.group()) for route in "ABC") and "**At home" in segment.group(), f"Day {day} route/home missing")
    swaps=read("PRACTICE-SWAPS.md")
    need([int(x) for x in re.findall(r"^\| (\d{2}) \|",swaps,re.MULTILINE)]==days,"20 worked domains absent")
    need(sum(line.count("AFTER first check capture only") for line in swaps.splitlines() if line.startswith("| "))==4,"Check models must follow first capture")
    checks=read("STUDENT-CHECKS.md");key=read("teacher/KEY-AND-NEXT.md")
    need(all("## Check "+c in checks and "## Check "+c in key for c in ["O","P"]),"Two held checks/separate key absent")
    need(not re.search(r"Possible response|Fixed source criteria|Mine is a pretend lighthouse|In my story it is a pretend path",checks),"Held learner case leaks worked creative answer")
    need("Possible response" in key and "not timed finishes" in checks and "term grade" in checks and "PLAN ONLY" in checks,"Held status/follow-up boundary missing")
    materials=read("MATERIALS.md")
    need(all(x in materials for x in ["already approved locally","No prop on/over a head, face or body","CHILD-DEVISED / ADULT-ENACTED","CHILD-DEVISED / ADULT-DRAWN","A verbal-only scene proposal is PLAN ONLY","not a size-based safety guarantee","no audit","unmade proposals"] if x!="no audit"),"Actual material/authorship/enactment/safety boundary drift")
    need("## Parent/carer pass" in read("RUN-THROUGH.md") and "## Funder/reviewer pass" in read("RUN-THROUGH.md"),"Four-role desk critique absent")
    need(len(re.findall(r"^## Day \d+ · Optional extension",read("EXAMPLE-BANK.md"),re.MULTILINE))==10,"Ten optional extensions missing")
    need("one gesture" in read("README.md").lower() or "one action" in read("README.md").lower(),"Tiny practical play outcome absent")
    print("PASS ten 25-minute scripts, 30 same-goal routes, 20 worked domains, 10 home + 10 extensions, held O/P and separate key")


def assets() -> None:
    alternatives=read("print/TEXT-ALTERNATIVES.md");ns={"s":"http://www.w3.org/2000/svg"}
    need(len(list((ROOT/"print").glob("*.svg")))==len(list((ROOT/"print").glob("*.pdf")))==9,"Nine A4 pairs missing/extra")
    for stem in STEMS:
        svg=ROOT/"print"/(stem+".svg");pdf=ROOT/"print"/(stem+".pdf");xml=ET.parse(svg).getroot()
        need(xml.get("width")=="210mm" and xml.get("height")=="297mm" and xml.get("viewBox")=="0 0 794 1123","A4 SVG geometry drift: "+stem)
        need(xml.find("s:title",ns) is not None and xml.find("s:desc",ns) is not None,"SVG alternative absent")
        words=[node.text or "" for node in xml.findall("s:text",ns)]
        pages=PdfReader(pdf).pages;need(len(pages)==1 and abs(float(pages[0].mediabox.width)-595.28)<1 and abs(float(pages[0].mediabox.height)-841.89)<1,"Searchable one-page A4 absent")
        need(re.sub(r"\s+","",pages[0].extract_text())==re.sub(r"\s+","","".join(words)),"Whole SVG/PDF wording drift: "+stem)
        bbox=ET.fromstring(subprocess.run(["pdftotext","-bbox",str(pdf),"-"],check=True,capture_output=True,text=True).stdout)
        for item in bbox.iter():
            if item.tag.endswith("}word"):need(0<=float(item.get("xMin"))<float(item.get("xMax"))<=595.3 and 0<=float(item.get("yMin"))<float(item.get("yMax"))<=841.9,"Clipped actual PDF wording")
        section=alternatives.split("## "+stem+"\n",1)[1].split("\n## ",1)[0];exact=section.split("**Exact visible wording in reading order:**",1)[1]
        need([line[2:] for line in exact.splitlines() if line.startswith("- ")]==words,"Exact alternative/order drift: "+stem)
        need(f"({stem}.pdf)" in section and f"({stem}.svg)" in section and "Tactile" in section,"Accessible pair link/route absent")
    need("not tagged" in alternatives and "no prop/print/touch/screen" in alternatives.lower(),"Linear/nonvisual boundary absent")
    def parts(stem):return {n.get("data-part"):n for n in ET.parse(ROOT/"print"/(stem+".svg")).getroot() if n.get("data-part")}
    o=parts("fresh-o");p=parts("fresh-p")
    need(o["O source ordinary prop flat cloth"].get("d")=="M415.0 346 Q483 329 512 344 Q543 355 581.0 341 L578.0 377 Q509 393 475 377 L415.0 381 Z","Fresh O actual flat cloth geometry drift")
    need(o["O source invented role proposed point"].get("d")=="M186 304 L400 315 L426 313","Fresh O actual pointing pose drift")
    need(p["P source ordinary prop blank flat card"].get("x")=="415.0" and p["P source ordinary prop blank flat card"].get("y")=="346","Fresh P actual flat card position drift")
    need(p["P source invented role proposed open palm"].get("d")=="M186 304 L257 329 L276 306 M276 306 L285 298 M276 306 L291 306 M276 306 L290 315","Fresh P actual open-palm source drift")
    need(o["O blank child scene"].get("fill")==p["P blank child scene"].get("fill")=="white","Fresh creative response prefilled")
    source=parts("source-scene")
    need(source["source A ordinary prop blank flat card"].get("width")==source["source B ordinary prop blank flat card"].get("width")=="166","Same ordinary source prop drift")
    need("PROP-MODEL-09" in read("print/source-scene.svg") and "enactments unmade" in read("print/source-scene.svg"),"Print source identity/status missing")
    need("SKY WATCHER / INVENTED RIDGE" in read("print/fresh-o.svg") and "PRETEND CLOUD" in read("print/fresh-o.svg") and "VISITOR / INVENTED HALL" in read("print/fresh-p.svg") and "PRETEND DOORWAY" in read("print/fresh-p.svg"),"Fresh source meanings/roles drift")
    print("PASS 9 actual searchable A4 pages, whole SVG/PDF words, exact alternatives, blank creative areas and actual fixed source relations")



def digital() -> None:
    page=read("digital/index.html");js=read("digital/app.js");css=read("digital/style.css");guide=read("digital/README.md")
    need("Content-Security-Policy" in page and "default-src 'none'" in page and "connect-src 'none'" in page,"Offline scaffold drift")
    need(all(f'id="{n}"' in page for n in ["prop","meaning","clue","place","picture","description","short","status","reset"]),"Planner controls/alternatives missing")
    need('aria-live="polite"' in page and ':focus-visible' in css and '.controls{display:none}' in css and '<noscript>' in page,"Keyboard/live/noJS route absent")
    need(not re.search(r"\b(?:fetch|XMLHttpRequest|localStorage|sessionStorage|sendBeacon|WebSocket|serviceWorker)\b",js),"Unexpected planner network/storage API")
    need("scene proposal, not enacted drama" in page and "Closing or reloading clears" in page and "textContent" in js,"State/privacy boundary absent")
    need(not any(n in page+js for n in ["fresh-o","fresh-p","PROP-CHECK-O-09","PROP-CHECK-P-10","pretend cloud","pretend doorway","STUDENT-CHECKS","KEY-AND-NEXT"]),"Practice planner loads held source/key")
    subprocess.run(["node","--check",str(ROOT/"digital/app.js")],check=True,capture_output=True)
    for name,width in [("desktop",1280),("mobile",390)]:
        data=(ROOT/"digital/previews"/(name+".png")).read_bytes();need(data[:8]==b"\x89PNG\r\n\x1a\n" and struct.unpack(">I",data[16:20])[0]==width and f"previews/{name}.png" in guide,"Current browser preview absent")
    need("162 cases" in guide and "1280, 390, 320" in guide and (ROOT/"digital/qa_browser.cjs").is_file(),"Actual browser audit missing")
    print("PASS offline small planner, actual-state alternatives, JS syntax, no held-check leak and two previews")


def negative_checks() -> None:
    """Reject meaningful source/key/geometry/network/representation faults in temporary copies."""
    global ROOT
    original=ROOT
    probes=[
        ("wrong official row","source-snapshot.json",lambda s:s.replace('"AC9ADRFD01": 20741','"AC9ADRFD01": 20751'),source),
        ("creative key leaked","STUDENT-CHECKS.md",lambda s:s+'\nPossible response: Mine is a pretend lighthouse\n',pedagogy),
        ("fresh role pose altered","print/fresh-p.svg",lambda s:s.replace('M186 304 L257 329 L276 306','M186 304 L400 315 L426 313'),assets),
        ("outside connection API","digital/app.js",lambda s:s+"\nfetch('https://example.invalid');\n",digital),
        ("saved graphic falsely performed","SUPPLIED-WORK.md",lambda s:s.replace('depicted setup and enactments are unmade proposals','depicted setup and enactments are actual observed performances'),source),
        ("words-only plan falsely child enacted","MATERIALS.md",lambda s:s.replace('A verbal-only scene proposal is PLAN ONLY','A verbal-only scene proposal is CHILD ENACTED'),pedagogy),
    ]
    for label,file,mutate,check in probes:
        with tempfile.TemporaryDirectory(prefix="subjectnest-drama-negative-") as tmp:
            ROOT=Path(tmp)/"pack";shutil.copytree(original,ROOT,ignore=shutil.ignore_patterns("__pycache__"))
            target=ROOT/file;before=target.read_text();after=mutate(before);need(before!=after,"Invalid negative probe: "+label);target.write_text(after)
            try:
                try:
                    with contextlib.redirect_stdout(io.StringIO()):check()
                except AssertionError:pass
                else:raise AssertionError("Negative fault accepted: "+label)
            finally:ROOT=original
    print("PASS six temporary-copy rejections: source row, creative answer leak, role pose, network API, false performed source, false child enactment")


def regeneration() -> None:
    with tempfile.TemporaryDirectory(prefix="subjectnest-drama-9-10-") as tmp:
        temp = Path(tmp)
        shutil.copy2(ROOT / "print/generate_print.py", temp / "generate_print.py")
        subprocess.run([sys.executable, str(temp / "generate_print.py")], check=True, capture_output=True)
        for stem in STEMS:
            for ext in ("svg", "pdf"):
                name = f"{stem}.{ext}"
                need(hashlib.sha256((temp / name).read_bytes()).digest() == hashlib.sha256((ROOT / "print" / name).read_bytes()).digest(), f"Nondeterministic or stale asset: {name}")
    print("PASS deterministic regeneration of all eighteen A4 assets")


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


def links(*, bootstrap: bool) -> None:
    count = 0
    for path in ROOT.rglob("*.md"):
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", path.read_text(encoding="utf-8")):
            if url.startswith(("https://", "http://", "mailto:")):
                continue
            name, marker, anchor = unquote(url).partition("#")
            target = (path.parent / name).resolve() if name else path
            if bootstrap and target == ROOT / "manifest.json":
                count += 1
                continue
            need(target.is_file(), f"Broken local link: {path.relative_to(ROOT)} -> {url}")
            if marker and target.suffix == ".md":
                headings = [slug(value) for value in re.findall(r"^#{1,6} (.+)$", target.read_text(encoding="utf-8"), re.MULTILINE)]
                need(anchor in headings, f"Broken heading anchor: {url}")
            count += 1
    print(f"PASS {count} local links and heading anchors")


def manifest_data() -> dict:
    files = sorted(path for path in ROOT.rglob("*") if path.is_file() and path.name != "manifest.json" and "__pycache__" not in path.parts)
    return {
        "schema": "subjectnest-authored-pack-manifest-v1",
        "pack": "foundation/drama/term-1/weeks-09-10",
        "created_at": "2026-09-30",
        "pack_version": "0.1.0-draft",
        "review_status": "author_desk_checked_pending_school_child_accessibility_and_cultural_review",
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial_conditional": sorted(ROWS),
        "contextual_codes_separate_evidence": sorted(CONTEXT_ROWS),
        "files": {str(path.relative_to(ROOT)): {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size} for path in files},
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-manifest", action="store_true", help="One-time authoring update; default is read-only")
    args = parser.parse_args()
    for path in ROOT.rglob("*"):
        if path.suffix in {".md", ".py", ".js", ".cjs", ".html", ".json"} and path.name != "manifest.json":
            need(("/" + "home/") not in path.read_text(encoding="utf-8"), "Private workstation path in public artifact: " + str(path.relative_to(ROOT)))
    source()
    pedagogy()
    assets()
    regeneration()
    digital()
    negative_checks()
    links(bootstrap=args.write_manifest)
    expected = manifest_data()
    manifest = ROOT / "manifest.json"
    if args.write_manifest:
        manifest.write_text(json.dumps(expected, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(f"WROTE SHA-256 manifest for {len(expected['files'])} files")
    else:
        need(json.loads(manifest.read_text(encoding="utf-8")) == expected, "SHA-256 manifest missing or stale")
        print(f"PASS SHA-256 manifest for {len(expected['files'])} files")
    print("PACK PASS · author desk QA only; no child or classroom trial")


if __name__ == "__main__":
    main()
