# SPDX-License-Identifier: Apache-2.0
"""Fail-closed, read-only-by-default receipt for this authored fortnight."""

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]
SOURCE_SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
WEEK_THREE_LAST_DAY = 15
LESSON_MINUTES = 25
MIN_PDF_WORDS = 18
FOUR = 4
ROWS = {"AC9TDIFK01": 20098, "AC9TDIFK02": 20105, "AC9TDIFP01": 20111}
QCAA_URL = (
    "https://www.qcaa.qld.edu.au/p-10/aciq/version-9/"
    "learning-areas/p-10-technologies/digital-technologies"
)
QCAA_ALIGNMENT_URL = (
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/technologies/"
    "curriculum/ac9_tech_digital_prep_as_cd_alignment.pdf"
)
STEMS = (
    "maker-cart-source",
    "copy-mismatch-board",
    "four-entry-three-way-mat",
    "hardware-software-flow",
    "empty-personal-cards",
    "source-copy-device-mat",
    "check-a-shape-depot",
    "check-b-form-drawer",
)
EXPECTED_CODES = {
    11: {"AC9TDIFK02"},
    12: {"AC9TDIFK02"},
    13: {"AC9TDIFK02"},
    14: {"AC9TDIFK02", "AC9TDIFP01"},
    15: {"AC9TDIFK02", "AC9TDIFP01"},
    16: {"AC9TDIFK01", "AC9TDIFK02"},
    17: {"AC9TDIFK01", "AC9TDIFK02"},
    18: {"AC9TDIFK01", "AC9TDIFK02"},
    19: {"AC9TDIFK02", "AC9TDIFP01"},
    20: {"AC9TDIFK02", "AC9TDIFP01"},
}
PDF_FACTS = {
    "maker-cart-source": ("PANEL, TUBE, PANEL, BLOCK", "P T P B"),
    "copy-mismatch-board": ("P T P B", "P T B B"),
    "hardware-software-flow": ("SCREEN", "KEYBOARD", "RUNNING SOFTWARE WINDOW"),
    "empty-personal-cards": ("MY NAME", "MY FACE PHOTO", "MY VOICE RECORDING"),
    "check-a-shape-depot": ("ARCH", "TILE", "RING", "A T A R", "A T R R"),
    "check-b-form-drawer": ("WEDGE", "DISC", "SLOT", "W D W S", "W D S S"),
}


def need(ok: object, message: str) -> None:
    """Fail closed on any source, content, link, or file drift."""
    if not ok:
        raise AssertionError(message)


def read(name: str) -> str:
    """Read an authored text file."""
    return (ROOT / name).read_text(encoding="utf-8")


def source() -> None:
    """Match exact official imported rows to the original pinned workbook."""
    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")
    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, "Official row pin drift")
    need(
        snapshot["queensland_prep_entry_point_url"] == QCAA_URL
        and snapshot["queensland_prep_alignment_url"] == QCAA_ALIGNMENT_URL
        and QCAA_URL in crosswalk
        and QCAA_ALIGNMENT_URL in crosswalk,
        "Queensland Prep source pin drift",
    )
    need(
        snapshot["conditional_actual_system_code"] == "AC9TDIFK01"
        and set(snapshot["partial_paper_and_category_codes"])
        == {"AC9TDIFK02", "AC9TDIFP01"},
        "Conditional/partial scope drift",
    )
    records = {
        record["code"]: record
        for record in imported["records"]
        if record.get("record_type") == "content_description"
        and record.get("code") in ROWS
    }
    need(set(records) == set(ROWS), "Official content records missing")
    for code, row in ROWS.items():
        record = records[code]
        attrs = record["attributes"]
        need(
            record["source_row"] == row
            and attrs["level"] == "Foundation Year"
            and attrs["learning_area"] == "Technologies"
            and attrs["subject"] == "Digital Technologies",
            f"Wrong official level/area/row for {code}",
        )
        exact = (
            rf"^\| {code} \| {row} \| Technologies · Digital Technologies · "
            rf"Foundation Year \| {re.escape(record['plain_text'])} \|"
        )
        need(re.search(exact, crosswalk, re.MULTILINE), f"Verbatim row absent: {code}")
    need(
        "NOT OBSERVED" in crosswalk and "not secure exams" in crosswalk,
        "Evidence/public-check boundary absent",
    )
    print("PASS exact 3 ACARA v9 rows, source SHA and Queensland Prep pins")


def lessons() -> None:
    """Check day inventory, timing, route parity, and twenty worked swaps."""
    scripts = read("LESSONS.md")
    marks = list(re.finditer(r"^## Week (\d+) · Day (\d+)\b", scripts, re.MULTILINE))
    need([int(mark.group(2)) for mark in marks] == list(range(11, 21)), "Day drift")
    for index, mark in enumerate(marks):
        day = int(mark.group(2))
        end = marks[index + 1].start() if index + 1 < len(marks) else len(scripts)
        body = scripts[mark.end() : end]
        times = [
            int(value)
            for value in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*", body, re.MULTILINE
            )
        ]
        need(
            int(mark.group(1)) == (3 if day <= WEEK_THREE_LAST_DAY else 4)
            and times == [3, 4, 5, 7, 4, 2]
            and sum(times) == LESSON_MINUTES,
            f"Day {day} timing/week drift: {times}",
        )
        target = re.search(r"^\*\*Target/codes:\*\* ([^\n]+)", body, re.MULTILINE)
        need(target, f"Day {day} missing target")
        codes = set(re.findall(r"\bAC9TDIF(?:K|P)\d\d\b", target.group(1)))
        need(codes == EXPECTED_CODES[day], f"Day {day} code drift: {codes}")
        need(
            "**Check/respond" in body
            and any(
                word in body.lower()
                for word in ("record", "first response", "first work")
            ),
            f"Day {day} response move",
        )
    cards = read("LEARNER-CARDS.md")
    card_marks = list(re.finditer(r"^## Day (\d+)\b", cards, re.MULTILINE))
    need(
        [int(mark.group(1)) for mark in card_marks] == list(range(11, 21)), "Card drift"
    )
    for index, mark in enumerate(card_marks):
        end = (
            card_marks[index + 1].start() if index + 1 < len(card_marks) else len(cards)
        )
        body = cards[mark.end() : end]
        need(
            all(re.search(rf"^- \*\*{route} ·", body, re.MULTILINE) for route in "ABC")
            and "**Shared target:**" in body
            and "**Home:**" in body
            and re.search(r"\n\n- \*\*A ·", body),
            f"Day {mark.group(1)} missing or non-semantic routes/home",
        )
    swaps = read("PRACTICE-SWAPS.md")
    rows = re.findall(r"^\| (\d+) \| (.+) \| (.+) \|$", swaps, re.MULTILINE)
    need(
        [int(day) for day, _, _ in rows] == list(range(11, 21))
        and all(a.startswith("**") and b.startswith("**") for _, a, b in rows),
        "Twenty distinct worked swaps missing",
    )
    need(
        "not fixed learning styles" in cards
        and "NOT OBSERVED" in scripts
        and "no device" in cards.lower(),
        "Route/device evidence boundary absent",
    )
    print("PASS ten 25-minute scripts, 30 switchable routes and 20 worked swaps")


def checks() -> None:
    """Check new public held cases and separated worked guidance."""
    learner = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    routine = read("PRACTICE-SWAPS.md")
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)\b", learner, re.MULTILINE)
        == [("A", "15"), ("B", "20")],
        "Fresh check/day inventory drift",
    )
    need(
        "teacher/KEY-AND-NEXT.md" not in learner
        and "teacher/KEY-AND-NEXT.md" not in read("LEARNER-CARDS.md")
        and "public by URL" in key
        and "not secure exams" in learner,
        "Public-key or clean learner-copy boundary drift",
    )
    for exact in (
        "ARCH, TILE, ARCH, RING",
        "A T A R",
        "A T R R",
        "WEDGE, DISC, WEDGE, SLOT",
        "W D W S",
        "W D S S",
    ):
        need(exact in learner and exact in key, f"Fresh case/key disagreement: {exact}")
        need(exact not in routine, f"Held sequence leaked into routine: {exact}")
    for phrase in ("position 3", "positions **1 and 3**", "MY NAME", "MY FACE PHOTO"):
        need(phrase in key, f"Worked reason/category absent: {phrase}")
    need(
        "physical tokens" in learner.lower()
        and "paper check does not assess actual" in learner.lower()
        and "separate observed gate" in key,
        "Object or K01 evidence boundary absent",
    )
    print("PASS fresh Day 15/20 public formative checks and separate worked key")


def assets() -> None:
    """Check generator output, original SVG geometry, A4 text, and alternatives."""
    generator = ast.parse(read("print/generate_print.py"))
    pages = next(
        node.value
        for node in generator.body
        if isinstance(node, ast.Assign)
        and any(
            isinstance(target, ast.Name) and target.id == "PAGES"
            for target in node.targets
        )
    )
    need(
        isinstance(pages, ast.Dict)
        and {key.value for key in pages.keys if isinstance(key, ast.Constant)}
        == set(STEMS),
        "Print generator inventory drift",
    )
    alternatives = read("print/TEXT-ALTERNATIVES.md")
    ns = {"svg": "http://www.w3.org/2000/svg"}
    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.find("svg:title", ns) is not None
            and top.find("svg:desc", ns) is not None,
            f"SVG A4/description drift: {stem}",
        )
        pdf = ROOT / "print" / f"{stem}.pdf"
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        content = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        linear = " ".join(content.split())
        need(
            re.search(r"Pages:\s+1\b", info)
            and "(A4)" in info
            and "CC BY 4.0" in content
            and len(content.split()) >= MIN_PDF_WORDS
            and f"{stem}.pdf" in alternatives
            and f"{stem}.svg" in alternatives,
            f"PDF or full alternative drift: {stem}",
        )
        for phrase in PDF_FACTS.get(stem, ()):
            need(
                phrase in linear and phrase in alternatives,
                f"Print/text fact drift: {stem}: {phrase}",
            )
    # These three pages must contain actual shapes, not labels inside coloured boxes.
    for stem, min_paths, min_circles in (
        ("maker-cart-source", 3, 0),
        ("check-a-shape-depot", 3, 2),
        ("check-b-form-drawer", 2, 2),
    ):
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        paths = len(top.findall(".//svg:path", ns))
        circles = len(top.findall(".//svg:circle", ns))
        need(
            paths >= min_paths and circles >= min_circles,
            f"Pictorial geometry missing: {stem}",
        )
    need(
        "untagged" in alternatives and "Tactile route" in alternatives,
        "Print limit/tactile detail absent",
    )
    print("PASS 8 original A4 SVG/PDF pairs, picture geometry and text alternatives")


def interactive() -> None:
    """Check contained offline input, feedback, paper parity, and claim limit."""
    html = read("interactive/copy-checker.html")
    paper = read("interactive/PAPER-EQUIVALENT.md")
    need(
        'Object.freeze(["P", "T", "P", "B"])' in html
        and 'Object.freeze(["P", "T", "B", "B"])' in html
        and html.count("<select id=") == FOUR
        and html.count('<button type="button"') == FOUR
        and 'aria-pressed="true"' in html
        and 'role="status" aria-live="polite"' in html
        and 'addEventListener("click"' in html,
        "Offline checker source/input/status drift",
    )
    need(
        not re.search(
            r"fetch\s*\(|XMLHttpRequest|localStorage|sessionStorage|"
            r"<form\b|<input\b|https?://|<script\s+src=|"
            r"navigator\.(?:mediaDevices|sendBeacon)|indexedDB",
            html,
            re.IGNORECASE,
        ),
        "Unexpected network, storage, or personal-input surface",
    )
    need(
        "P T P B" in paper
        and "P T B B" in paper
        and "large safe physical tokens" in paper
        and "AC9TDIFK02" in paper
        and "AC9TDIFK01" in paper
        and "not observed" in paper.lower()
        and (ROOT / "interactive/PAPER-EQUIVALENT.md").is_file(),
        "Paper/text parity or digital-evidence boundary drift",
    )
    print("PASS contained offline checker static safety and complete paper route")


def slug(title: str) -> str:
    """Generate local CommonMark heading anchor for link checking."""
    title = re.sub(r"\[[^]]+\]\([^)]+\)", "", title)
    title = re.sub(r"[*_]", "", title).strip().lower()
    title = re.sub(r"[^\w -]", "", title)
    return title.replace(" ", "-")


def links(*, manifest_bootstrap: bool) -> int:
    """Resolve all authored local links and Markdown anchors."""
    count = 0
    for path in ROOT.rglob("*.md"):
        body = path.read_text(encoding="utf-8")
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", body):
            if url.startswith(("https://", "http://", "mailto:")):
                continue
            name, marker, anchor = unquote(url).partition("#")
            target = (path.parent / name).resolve() if name else path
            if manifest_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.lower() == ".md":
                headings = [
                    slug(heading)
                    for heading in re.findall(
                        r"^#{1,6} (.+)$",
                        target.read_text(encoding="utf-8"),
                        re.MULTILINE,
                    )
                ]
                need(anchor in headings, f"Broken Markdown heading anchor: {url}")
            count += 1
    print(f"PASS {count} local file/heading links")
    return count


def manifest_data() -> dict:
    """Hash all child-pack files except the self-referential receipt."""
    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/digital-technologies/term-1/weeks-03-04",
        "created_at": "2026-09-30",
        "review_status": "author_desk_checked_pending_classroom_device_access_review",
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial": ["AC9TDIFK02", "AC9TDIFP01"],
        "curriculum_code_conditional_actual_system": "AC9TDIFK01",
        "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:
    """Validate before optional freeze; otherwise never write."""
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-manifest", action="store_true")
    args = parser.parse_args()
    source()
    lessons()
    checks()
    assets()
    interactive()
    links(manifest_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 receipt for {len(expected['files'])} files")
    else:
        need(
            json.loads(manifest.read_text(encoding="utf-8")) == expected,
            "Missing or stale SHA receipt",
        )
        print(f"PASS SHA-256 receipt for {len(expected['files'])} files")
    print("PACK PASS · author desk QA only; no observed child/classroom result")


if __name__ == "__main__":
    main()
