# SPDX-License-Identifier: Apache-2.0
"""Fail-closed offline receipt for Foundation Design Weeks 3-4."""

from __future__ import annotations

import argparse
import ast
import hashlib
import json
import re
import subprocess
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
from urllib.parse import unquote

ROOT = Path(__file__).resolve().parent
STUDIO = ROOT.parents[4]
SOURCE_SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
ROWS = {"AC9TDEFK01": 19725, "AC9TDEFP01": 19733}
QCAA_URL = (
    "https://www.qcaa.qld.edu.au/p-10/aciq/version-9/"
    "learning-areas/p-10-technologies/design-and-technologies"
)
QCAA_ALIGNMENT_URL = (
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/technologies/"
    "curriculum/ac9_tech_prep_as_cd_alignment.pdf"
)
WEEK_SPLIT_DAY = 15
LESSON_MINUTES = 25
MIN_PDF_WORDS = 18
STEMS = (
    "page-help-ideas",
    "help-service-choice",
    "page-space-layout",
    "safe-page-turn",
    "design-evidence-mat",
    "school-source-slot",
    "check-a-request-board",
    "check-b-stop-gate",
)
EXPECTED_CODES = {
    11: {"AC9TDEFK01"},
    12: {"AC9TDEFK01"},
    13: {"AC9TDEFK01", "AC9TDEFP01"},
    14: {"AC9TDEFP01"},
    15: {"AC9TDEFK01", "AC9TDEFP01"},
    16: {"AC9TDEFP01"},
    17: {"AC9TDEFP01"},
    18: {"AC9TDEFP01"},
    19: {"AC9TDEFK01", "AC9TDEFP01"},
    20: {"AC9TDEFP01"},
}
PRINTED_PHRASES = {
    "page-help-ideas": (
        "ONE BROAD BLANK PAPER PAGE",
        "TOP TAB IDEA",
        "SIDE TAB IDEA",
    ),
    "help-service-choice": ("ASK FIRST", "YES", "NO THANKS", "HELP", "STEP BACK"),
    "page-space-layout": ("PAGE SPACE", "PENCIL SPACE", "CLEAR EDGE"),
    "safe-page-turn": (
        "1 FOLD BROAD TAB",
        "2 PLACE PAGE",
        "3 LIFT EDGE",
        "4 TURN ONE PAGE",
        "TURNED / SLIPPED / NOT TRIED",
    ),
    "design-evidence-mat": (
        "PURPOSE / USER",
        "TWO IDEAS / CHOICE",
        "REAL ACTION / RESULT OR NOT TRIED",
    ),
    "school-source-slot": ("REAL PRODUCT / SERVICE / ENVIRONMENT",),
    "check-a-request-board": (
        "PLEASE POINT",
        "I WILL LOOK FIRST",
        "show ONE message at a time",
    ),
    "check-b-stop-gate": (
        "LARGE FLAT RECTANGLE MARKER",
        "BROAD BLANK PAPER SHEET",
        "START",
        "END",
        "STOPPED / CROSSED / NOT TRIED",
    ),
}


def need(ok: object, message: str) -> None:
    """Fail with an actionable reason on source, content or hash drift."""
    if not ok:
        raise AssertionError(message)


def read(name: str) -> str:
    """Read one authored UTF-8 document."""
    return (ROOT / name).read_text(encoding="utf-8")


def source() -> None:
    """Check official original workbook, imported rows and 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(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 ACARA 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",
    )
    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), "Foundation Design 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"] == "Design and Technologies",
            f"Wrong official row, level or subject: {code}",
        )
        exact = (
            rf"^\| {code} \| {row} \| Technologies · Design and Technologies · "
            rf"Foundation Year \| {re.escape(record['plain_text'])} \|"
        )
        need(re.search(exact, crosswalk, re.MULTILINE), f"Verbatim row absent: {code}")
    need(
        "school-selected" in crosswalk.lower()
        and "actual observed material action" in crosswalk.lower()
        and "not secure exams" in crosswalk.lower(),
        "State/evidence boundary absent",
    )
    print("PASS two exact ACARA v9 Foundation Design rows and QCAA Prep pins")


def pedagogy() -> None:
    """Check ten distinct timed days, thirty routes and twenty worked swaps."""
    lessons = read("LESSONS.md")
    marks = list(re.finditer(r"^## Week (\d+) · Day (\d+)\b", lessons, re.MULTILINE))
    need(
        [int(mark.group(2)) for mark in marks] == list(range(11, 21)),
        "Teacher Day 11-20 sequence drift",
    )
    for index, mark in enumerate(marks):
        day = int(mark.group(2))
        end = marks[index + 1].start() if index + 1 < len(marks) else len(lessons)
        body = lessons[mark.end() : end]
        times = [
            int(x)
            for x in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*",
                body,
                re.MULTILINE,
            )
        ]
        need(
            int(mark.group(1)) == (3 if day <= WEEK_SPLIT_DAY else 4)
            and times == [3, 4, 5, 7, 4, 2]
            and sum(times) == LESSON_MINUTES,
            f"Day {day} week/time drift: {times}",
        )
        target = re.search(r"^\*\*Target/codes:\*\* ([^\n]+)", body, re.MULTILINE)
        need(target, f"Day {day} target missing")
        codes = set(re.findall(r"\bAC9TDEF(?: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", "capture")),
            f"Day {day} response move absent",
        )
    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)),
        "Learner Day 11-20 sequence 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,
            f"Three routes/shared target/home absent Day {mark.group(1)}",
        )
    swaps = read("PRACTICE-SWAPS.md")
    lines = re.findall(r"^\| (\d+) \| (.+) \| (.+) \|$", swaps, re.MULTILINE)
    need(
        [int(day) for day, _, _ in lines] == list(range(11, 21))
        and all(one.startswith("**") and two.startswith("**") for _, one, two in lines),
        "Twenty distinct worked swap cells absent",
    )
    need(
        "not fixed learning styles" in cards
        and "NOT TRIED" in cards
        and "school-approved" in lessons,
        "Access/material evidence boundary absent",
    )
    print("PASS ten 25-minute scripts, 30 routes and 20 worked domain swaps")


def checks() -> None:
    """Keep fresh public source values out of routine and key out of child copy."""
    student = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    routine = read("LEARNER-CARDS.md") + read("PRACTICE-SWAPS.md")
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)\b", student, re.MULTILINE)
        == [("A", "15"), ("B", "20")],
        "Fresh check schedule drift",
    )
    need(
        "teacher/KEY-AND-NEXT.md" not in student
        and "teacher/KEY-AND-NEXT.md" not in read("LEARNER-CARDS.md")
        and "public by url" in key.lower()
        and "not secure exams" in student.lower(),
        "Public formative/key separation absent",
    )
    for leaked in (
        "PLEASE POINT",
        "I WILL LOOK FIRST",
        "LARGE FLAT RECTANGLE MARKER",
        "Paper Puppet Request Board",
        "Paper Stop Gate",
    ):
        need(leaked not in routine, f"Fresh source leaked into routine: {leaked}")
    for phrase in (
        "PLEASE POINT",
        "I WILL LOOK FIRST",
        "one message at a time",
        "one broad fold-over cover",
        "LARGE FLAT RECTANGLE MARKER",
        "BROAD BLANK PAPER SHEET",
        "START",
        "END",
        "STOPPED",
        "CROSSED",
        "NOT TRIED",
        "No prior result is printed",
    ):
        need(phrase.lower() in key.lower(), f"Teacher answer/hold drift: {phrase}")
    need(
        "no real" in student.lower()
        and "adult" in key.lower()
        and "no prior result" in key.lower(),
        "Safety/result boundary absent",
    )
    print("PASS two distinct fresh public checks and separate conditional key")


def svg_order(stem: str, attribute: str) -> list[str]:
    """Read machine-checkable source regions in SVG document order."""
    top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
    ns = {"svg": "http://www.w3.org/2000/svg"}
    return [
        node.get(attribute, "")
        for node in top.findall(".//svg:rect", ns)
        if node.get(attribute) is not None
    ]


def svg_values(stem: str, attribute: str) -> Counter[str]:
    """Count source-bearing regions in original SVG."""
    return Counter(svg_order(stem, attribute))


def assets() -> None:
    """Verify SVG/PDF inventory, exact source values and linear alternatives."""
    alternatives = 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(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),
        "Generated aid inventory drift",
    )
    ns = {"svg": "http://www.w3.org/2000/svg"}
    linear_alt = " ".join(alternatives.lower().split())
    for stem in STEMS:
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        pdf = ROOT / "print" / f"{stem}.pdf"
        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"A4 SVG/metadata drift: {stem}",
        )
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        content = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        linear_pdf = " ".join(content.lower().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/selectable text/full alternative drift: {stem}",
        )
        for phrase in PRINTED_PHRASES.get(stem, ()):
            need(
                phrase.lower() in linear_pdf and phrase.lower() in linear_alt,
                f"PDF/linear source mismatch: {stem}: {phrase}",
            )
    need(
        svg_order("page-help-ideas", "data-idea") == ["TOP TAB IDEA", "SIDE TAB IDEA"]
        and svg_order("help-service-choice", "data-branch")
        == ["ASK FIRST", "YES", "NO THANKS", "HELP", "STEP BACK"]
        and svg_order("safe-page-turn", "data-step")
        == ["1 FOLD BROAD TAB", "2 PLACE PAGE", "3 LIFT EDGE", "4 TURN ONE PAGE"],
        "Main source/step order drift",
    )
    need(
        svg_values("page-space-layout", "data-zone")
        == Counter({"PAGE SPACE": 1, "PENCIL SPACE": 1, "CLEAR EDGE": 1})
        and svg_order("check-a-request-board", "data-message")
        == ["PLEASE POINT", "I WILL LOOK FIRST"]
        and svg_order("check-b-stop-gate", "data-item")
        == ["LARGE FLAT RECTANGLE MARKER", "BROAD BLANK PAPER SHEET"]
        and svg_values("check-b-stop-gate", "data-route")
        == Counter({"START TO END": 1})
        and svg_values("check-b-stop-gate", "data-criterion")
        == Counter({"BEFORE END": 1}),
        "Fresh source geometry/criterion drift",
    )
    need(
        "untagged" in alternatives
        and "tactile" in alternatives.lower()
        and "no gate/result" in alternatives.lower(),
        "Accessible boundary text absent",
    )
    print("PASS eight original A4 SVG/PDF pairs and fresh source geometry")


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


def links(*, allow_manifest_bootstrap: bool) -> int:
    """Check every relative Markdown file/heading link without network calls."""
    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 allow_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(x)
                    for x 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 file and heading links")
    return count


def manifest_data() -> dict:
    """Hash every authored file except the self-referential manifest."""
    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/design-and-technologies/term-1/weeks-03-04",
        "created_at": "2026-09-29",
        "review_status": "author_desk_checked_pending_school_material_and_child_review",
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial_conditional": sorted(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:
    """Run offline checks and optionally freeze the SHA-256 receipt."""
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-manifest", action="store_true")
    args = parser.parse_args()
    source()
    pedagogy()
    checks()
    assets()
    links(allow_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 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 physical or child trial")


if __name__ == "__main__":
    main()
