# SPDX-License-Identifier: Apache-2.0
"""Fail-closed offline receipt for Foundation Design and Technologies Weeks 1-2."""

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 = 5
LESSON_MINUTES = 25
MIN_PDF_WORDS = 18
STEMS = (
    "fictional-design-helpers",
    "two-display-ideas",
    "safe-fold-trial",
    "design-evidence-mat",
    "school-context-card",
    "two-ideas-choice-cards",
    "check-a-fresh-route",
    "check-b-fresh-carrier",
)
EXPECTED_CODES = {
    1: {"AC9TDEFK01"},
    2: {"AC9TDEFK01"},
    3: {"AC9TDEFK01", "AC9TDEFP01"},
    4: {"AC9TDEFP01"},
    5: {"AC9TDEFK01", "AC9TDEFP01"},
    6: {"AC9TDEFP01"},
    7: {"AC9TDEFP01"},
    8: {"AC9TDEFP01"},
    9: {"AC9TDEFK01", "AC9TDEFP01"},
    10: {"AC9TDEFP01"},
}
PRINTED_PHRASES = {
    "fictional-design-helpers": (
        "PAPER NOTICE STAND",
        "RETURN DESK CARD",
        "CLEAR TABLE PATH",
    ),
    "two-display-ideas": ("IDEA FLAT", "IDEA FOLD", "Stability not tested"),
    "safe-fold-trial": ("1 FOLD", "2 PLACE", "3 LET GO", "NOT TRIED"),
    "design-evidence-mat": (
        "PURPOSE AND USER",
        "TWO IDEAS / MY CHOICE",
        "ACTUAL SAFE ACTION AND RESULT",
    ),
    "school-context-card": ("APPROVED REAL PRODUCT / SERVICE / ENVIRONMENT",),
    "two-ideas-choice-cards": ("IDEA 1", "IDEA 2", "I CHOOSE"),
    "check-a-fresh-route": (
        "LEFT PAPER WORK SPACE",
        "CLEAR PAPER ROUTE",
        "RIGHT PAPER WORK SPACE",
    ),
    "check-b-fresh-carrier": (
        "ONE LARGE CIRCLE CARD",
        "ONE BROAD PAPER SHEET",
        "STAYED / SLIPPED / NOT TRIED",
    ),
}


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


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


def source() -> None:
    """Pin the official ACARA workbook and two exact Foundation records."""
    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 link 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), "Official 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 level, area or row for {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 context" in crosswalk
        and "not secure exams" in crosswalk.lower()
        and "actual observed material action" in crosswalk.lower(),
        "State/evidence limits absent from crosswalk",
    )
    print("PASS two exact ACARA v9 Foundation rows and QCAA Prep source pins")


def pedagogy() -> None:
    """Confirm ten timed scripts, 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(m.group(2)) for m in marks] == list(range(1, 11)), "Day 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(value)
            for value in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*",
                body,
                re.MULTILINE,
            )
        ]
        need(
            int(mark.group(1)) == (1 if day <= WEEK_SPLIT_DAY else 2)
            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} target absent")
        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")
    child_marks = list(re.finditer(r"^## Day (\d+)\b", cards, re.MULTILINE))
    need(
        [int(m.group(1)) for m in child_marks] == list(range(1, 11)),
        "Learner sequence drift",
    )
    for index, mark in enumerate(child_marks):
        end = (
            child_marks[index + 1].start()
            if index + 1 < len(child_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"Day {mark.group(1)} three routes/target/home absent",
        )
    swaps = read("PRACTICE-SWAPS.md")
    lines = re.findall(r"^\| (\d+) \| (.+) \| (.+) \|$", swaps, re.MULTILINE)
    need(
        [int(day) for day, _, _ in lines] == list(range(1, 11))
        and all(one.startswith("**") and two.startswith("**") for _, one, two in lines),
        "Twenty worked swaps absent",
    )
    need(
        "not fixed learning styles" in cards
        and "NOT TRIED" in cards
        and "actual" in lessons.lower()
        and "school-approved" in lessons,
        "Safe access/actual action boundary absent",
    )
    print("PASS ten 25-minute scripts, 30 routes and 20 worked context swaps")


def checks() -> None:
    """Keep both fresh public prompts and the resultless key distinct."""
    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", "5"), ("B", "10")],
        "Fresh check order 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 key/child copy boundary drift",
    )
    for leaked in (
        "Paper Art Table Route",
        "Paper Card Carrier",
        "CLEAR PAPER ROUTE",
        "ONE LARGE CIRCLE CARD",
    ):
        need(leaked not in routine, f"Fresh exact source leaked into routine: {leaked}")
    for exact in (
        "LEFT PAPER WORK SPACE",
        "CLEAR PAPER ROUTE",
        "RIGHT PAPER WORK SPACE",
        "wide labelled edge strip outside",
        "no making",
        "one large neutral circle card",
        "one broad blank paper sheet",
        "STAYED",
        "SLIPPED",
        "NOT TRIED",
        "no pre-filled observed answer",
    ):
        need(exact.lower() in key.lower(), f"Worked check/hold phrase drift: {exact}")
    need(
        "no real table" in student.lower()
        and "adult" in key.lower()
        and "material result" in key.lower(),
        "Real-result/safety boundary absent",
    )
    print("PASS two distinct fresh checks and separate public process key")


def svg_values(stem: str, attribute: str) -> Counter[str]:
    """Count exact machine-readable source labels in one SVG."""
    top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
    ns = {"svg": "http://www.w3.org/2000/svg"}
    return Counter(
        node.get(attribute)
        for node in top.findall(".//svg:rect", ns)
        if node.get(attribute) is not None
    )


def svg_order(stem: str, attribute: str) -> list[str]:
    """Return source-bearing regions in printed 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 assets() -> None:
    """Check generated A4/selectable text, full alternatives and source geometry."""
    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),
        "Generator 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/text source mismatch: {stem}: {phrase}",
            )
    need(
        svg_values("fictional-design-helpers", "data-helper")
        == Counter(
            {
                "PAPER NOTICE STAND": 1,
                "RETURN DESK CARD": 1,
                "CLEAR TABLE PATH": 1,
            }
        )
        and svg_order("fictional-design-helpers", "data-helper")
        == ["PAPER NOTICE STAND", "RETURN DESK CARD", "CLEAR TABLE PATH"],
        "Product/service/environment source drift",
    )
    need(
        svg_order("safe-fold-trial", "data-step") == ["1 FOLD", "2 PLACE", "3 LET GO"]
        and svg_order("check-a-fresh-route", "data-source-slot")
        == ["LEFT", "ROUTE", "RIGHT"]
        and svg_order("check-b-fresh-carrier", "data-item")
        == ["LARGE CIRCLE CARD", "BROAD BLANK PAPER SHEET"]
        and svg_values("check-b-fresh-carrier", "data-criterion")
        == Counter({"SHORT SLOW TABLETOP SLIDE": 1}),
        "Fresh/step source geometry drift",
    )
    need(
        "untagged" in alternatives
        and "tactile" in alternatives.lower()
        and "not a real room" in alternatives.lower(),
        "Accessible source/boundary text absent",
    )
    print("PASS eight original A4 SVG/PDF pairs and source/criterion geometry")


def slug(title: str) -> str:
    """Convert a simple Markdown heading to a local fragment."""
    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:
    """Resolve local Markdown file and heading links 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:
    """Freeze 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-01-02",
        "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 all offline checks and optionally freeze SHA-256 file hashes."""
    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 live material or child trial")


if __name__ == "__main__":
    main()
