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

from __future__ import annotations

import argparse
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 = {"AC9AVAFE01": 21465, "AC9AVAFD01": 21474, "AC9AVAFC01": 21484, "AC9AVAFP01": 21493}
DESCRIPTIONS = {
    "AC9AVAFE01": "explore how and why the arts are important for people and communities",
    "AC9AVAFD01": "use play, imagination, arts knowledge, processes and/or skills to discover possibilities and develop ideas",
    "AC9AVAFC01": "create arts works that communicate ideas",
    "AC9AVAFP01": "share their arts works with audiences",
}
MATH_CODE, MATH_ROW = "AC9MFST01", 17211
MATH_DESCRIPTION = "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/visual-arts",
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/the-arts/curriculum/ac9_visual_arts_prep_as_cd.alignment.pdf",
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/the-arts/assessment/ac9_arts_non_perform_tc_prep.pdf",
)
STEMS = (
    "colour-and-cue", "two-attention-choices", "what-i-found-card", "display-card",
    "first-now-view", "art-record", "check-q-seed-cart", "check-r-clock",
)
PDF_PHRASES = {
    "colour-and-cue": ("BLUE CIRCLE + DOTS", "ORANGE TRIANGLE + LINES", "If colour goes away"),
    "two-attention-choices": ("OPTION A", "OPTION B", "STRIPED HANDLE", "STRIPED BOX"),
    "what-i-found-card": ("FICTIONAL", "GREEN + CROSSHATCH", "REAL + DATE"),
    "display-card": ("MY ORIGINAL ART CARD", "without colour", "PLAN ONLY"),
    "first-now-view": ("FIRST", "NOW", "BLUE SQUARE ONLY", "BLUE SQUARE + OUTLINE"),
    "art-record": ("MY ART IDEA / FOCUS", "FIRST / NOW / WHY", "ACTUAL WILLING VIEWER"),
    "check-q-seed-cart": ("FICTIONAL SEED-POD CART", "OVAL POD BOX", "HOOK HANDLE", "IDEA 1", "IDEA 2"),
    "check-r-clock": ("FICTIONAL FIRST DRAFT", "ROUND BLUE FACE", "MY NOW VERSION"),
}


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_mathematics_row"] == {MATH_CODE: MATH_ROW}, "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) | {MATH_CODE}}
    for code, row in ROWS.items():
        item = records[code]
        need(item["source_row"] == row and item["attributes"]["level"] == "Foundation Year" and item["attributes"]["subject"] == "Visual Arts", f"Source pin mismatch {code}")
        need(item["plain_text"] == DESCRIPTIONS[code] and DESCRIPTIONS[code] in crosswalk, f"Description mismatch {code}")
    item = records[MATH_CODE]
    need(item["source_row"] == MATH_ROW and item["plain_text"] == MATH_DESCRIPTION and MATH_DESCRIPTION in crosswalk, "Contextual mathematics row mismatch")
    need("403" in rights and "not observed" in crosswalk.lower(), "Official fetch or claim boundary missing")
    print("PASS official ACARA SHA, exact rows/descriptions and QCAA pins with honest fetch limit")


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: {minutes}")
        need("**Goal/codes:**" in block and "**Prepare:**" in block, f"Day {day} runnable fields missing")
        need("AC9AVAFD01" in block or day == 49, f"Day {day} Visual Arts goal missing")
    need(days == list(range(41, 51)), f"Expected ten daily scripts: {days}")
    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\d) \|", swaps, flags=re.MULTILINE)] == days, "Twenty domain swaps absent")
    checks = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    need("## Check Q" in checks and "## Check R" in checks and "## Check Q" in key and "## Check R" in key, "Fresh checks/separate key missing")
    need("Possible response" not in checks and "Possible response" in key, "Learner copy leaks worked solution")
    need("PLAN ONLY" in checks and "not a secure" in checks and "not a term grade" in key, "Assessment boundary missing")
    print("PASS ten timed 25-minute scripts, 30 routes, 20 worked swaps and two held checks")


def assets() -> None:
    alternatives = read("print/TEXT-ALTERNATIVES.md")
    need(len(STEMS) == 8 and len(list((ROOT / "print").glob("*.svg"))) == 8 and len(list((ROOT / "print").glob("*.pdf"))) == 8, "Eight pairs missing or extra")
    for stem in STEMS:
        svg = ROOT / "print" / f"{stem}.svg"
        pdf = ROOT / "print" / f"{stem}.pdf"
        xml = ET.parse(svg).getroot()
        need(xml.attrib.get("width") == "210mm" and xml.attrib.get("height") == "297mm" and xml.attrib.get("viewBox") == "0 0 794 1123", f"A4 SVG geometry mismatch: {stem}")
        need(xml.find("{http://www.w3.org/2000/svg}title") is not None and xml.find("{http://www.w3.org/2000/svg}desc") is not None, f"SVG text alternative missing: {stem}")
        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, f"A4 PDF mismatch: {stem}")
        extracted = pages[0].extract_text()
        need(all(phrase in extracted for phrase in PDF_PHRASES[stem]), f"PDF text mismatch: {stem}")
        need(f"({stem}.pdf)" in alternatives and f"({stem}.svg)" in alternatives, f"Alternative pair link missing: {stem}")
    need("not tagged" in alternatives.lower() and "tactile" in alternatives.lower() and "no print/touch" in alternatives.lower(), "Linear/tactile/no-print boundary absent")
    print("PASS eight A4 SVG/PDF pairs, searchable text and exact access alternatives")


def regeneration() -> None:
    with tempfile.TemporaryDirectory(prefix="subjectnest-va-w9-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 sixteen A4 assets")


def digital() -> None:
    html = read("digital/index.html")
    js = read("digital/app.js")
    css = read("digital/style.css")
    guide = read("digital/README.md")
    need("Content-Security-Policy" in html and "default-src 'none'" in html and '<script src="app.js" defer>' in html and '<link rel="stylesheet" href="style.css">' in html, "Offline browser scaffold drift")
    need(all(f'id="{name}"' in html for name in ("part", "colour", "cue", "preview", "description", "save-a", "save-b", "clear", "idea-a", "idea-b", "status")), "Browser controls missing")
    need("aria-live=\"polite\"" in html and ":focus-visible" in css and "addEventListener" in js and "textContent" in js, "Browser access or safe text route missing")
    need(not re.search(r"\b(?:fetch|XMLHttpRequest|localStorage|sessionStorage|sendBeacon|WebSocket|serviceWorker)\b", js), "Unexpected browser network/storage API")
    need("Closing or reloading clears" in html + js and "no screen" in read("MATERIALS.md").lower(), "Privacy or no-screen boundary missing")
    subprocess.run(["node", "--check", str(ROOT / "digital/app.js")], check=True, capture_output=True)
    for name, width in (("desktop", 1280), ("mobile", 390)):
        preview = ROOT / "digital/previews" / f"{name}.png"
        raw = preview.read_bytes()
        need(raw[:8] == b"\x89PNG\r\n\x1a\n" and struct.unpack(">I", raw[16:20])[0] == width and f"previews/{name}.png" in guide, f"Browser preview missing: {name}")
    print("PASS offline text/keyboard art-choice scaffold, JS syntax and two preview dimensions")


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/visual-arts/term-1/weeks-09-10",
        "created_at": "2026-09-30",
        "review_status": "author_desk_checked_pending_school_child_accessibility_and_cultural_review",
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial_conditional": sorted(ROWS),
        "contextual_mathematics_code": MATH_CODE,
        "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()
    source()
    pedagogy()
    assets()
    regeneration()
    digital()
    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()
