# SPDX-License-Identifier: Apache-2.0
"""Fail-closed offline receipt for Foundation HPE Term 1 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"
QCAA_URL = (
    "https://www.qcaa.qld.edu.au/p-10/aciq/version-9/"
    "learning-areas/p-10-health-and-physical-education"
)
QCAA_ALIGNMENT_URL = (
    "https://www.qcaa.qld.edu.au/downloads/aciqv9/"
    "health-and-physical-education/curriculum/ac9_hpe_prep_as_cd_alignment.pdf"
)
ROWS = {
    "AC9HPFP01": 3162,
    "AC9HPFP02": 3170,
    "AC9HPFP03": 3175,
    "AC9HPFP04": 3182,
    "AC9HPFP05": 3187,
    "AC9HPFP06": 3193,
    "AC9HPFM01": 3201,
    "AC9HPFM02": 3208,
    "AC9HPFM03": 3215,
    "AC9HPFM04": 3221,
}
PERSONAL_PARTIAL = {"AC9HPFP02", "AC9HPFP04"}
MOVEMENT_CONDITIONAL = {"AC9HPFM01", "AC9HPFM02", "AC9HPFM04"}
NOT_CLAIMED = set(ROWS) - PERSONAL_PARTIAL - MOVEMENT_CONDITIONAL
EXPECTED_CODES = {
    1: {"AC9HPFP02", "AC9HPFP04"},
    2: {"AC9HPFP04", "AC9HPFM02"},
    3: {"AC9HPFM01", "AC9HPFM02", "AC9HPFM04"},
    4: {"AC9HPFP02", "AC9HPFM04"},
    5: {"AC9HPFP02", "AC9HPFP04"},
    6: {"AC9HPFP02", "AC9HPFP04"},
    7: {"AC9HPFP04", "AC9HPFM02"},
    8: {"AC9HPFP02", "AC9HPFM01", "AC9HPFM04"},
    9: {"AC9HPFP02", "AC9HPFP04"},
    10: {
        "AC9HPFP02",
        "AC9HPFP04",
        "AC9HPFM01",
        "AC9HPFM02",
        "AC9HPFM04",
    },
}
STEMS = (
    "permission-dialogue-cards",
    "one-at-a-time-play-mat",
    "fair-rule-cards",
    "stop-pause-choice-board",
    "original-play-signs",
    "source-vs-action-mat",
    "check-a-fresh-studio",
    "check-b-fresh-gallery",
)
PRINTED_SOURCE_PHRASES = {
    "permission-dialogue-cards": (
        "May I use the BIG SQUARE card?",
        "Not now; I am still using it.",
        "Okay. I will choose the BLANK CARD.",
        "May I put my card on the shared board?",
        "Yes, there is space.",
    ),
    "one-at-a-time-play-mat": (
        (
            "One maker moves a large soft object in one lane at a time; "
            "another waits outside that lane. A stop signal ends the turn."
        ),
    ),
    "fair-rule-cards": (
        "I waited at PAUSE SPACE until the lane was clear. Then I had one turn.",
        "I moved my paper marker into the lane while Mina's turn was still happening.",
    ),
    "stop-pause-choice-board": (
        "Pause, please. I do not want to use that shared spot.",
        "Okay. The adult stops the paper marker.",
        "The adult offers a different blank spot.",
    ),
    "original-play-signs": (
        "I want to try the paper lane. Which card tells me to ask first?",
    ),
    "check-a-fresh-studio": (
        "May I use the CLASS RIBBON card?",
        "Not now; it is in use.",
        "May I put my card on the OPEN BOARD?",
        "Yes, the OPEN BOARD has space.",
        (
            "Only one paper maker uses WORK MAT at a time. "
            "The next maker waits at WAIT PAD until WORK MAT is empty."
        ),
    ),
    "check-b-fresh-gallery": (
        "May I try the shared ROUND LANE?",
        "Not now; one paper turn is happening.",
        "The lane is clear. You may try if you want.",
    ),
}
WEEK_SPLIT_DAY = 5
LESSON_MINUTES = 25
MIN_PDF_WORDS = 18


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


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


def source() -> None:
    """Verify original official workbook hash, ten exact rows and QCAA links."""
    workbook = (
        STUDIO
        / "research/sources/acara-australian-curriculum-v9-download-2026-09-29.xlsx"
    )
    data = json.loads(
        (STUDIO / "data/frameworks/acara-v9.json").read_text(encoding="utf-8")
    )
    snapshot = json.loads(read("source-snapshot.json"))
    need(
        hashlib.sha256(workbook.read_bytes()).hexdigest() == SOURCE_SHA,
        "Official ACARA workbook SHA drift",
    )
    need(
        data["source_sha256"] == snapshot["workbook_sha256"] == SOURCE_SHA,
        "Official workbook/import SHA disagreement",
    )
    need(snapshot["content_rows"] == ROWS, "Foundation HPE row pin drift")
    need(
        snapshot["queensland_prep_entry_point_url"] == QCAA_URL
        and snapshot["queensland_prep_alignment_url"] == QCAA_ALIGNMENT_URL,
        "QCAA Prep link pin drift",
    )
    need(
        set(snapshot["baseline_partial_personal_social_codes"]) == PERSONAL_PARTIAL
        and set(snapshot["conditional_actual_movement_codes"]) == MOVEMENT_CONDITIONAL
        and set(snapshot["not_claimed_in_this_fortnight"]) == NOT_CLAIMED,
        "Claim/hold accounting drift",
    )
    crosswalk = read("CURRICULUM-CROSSWALK.md")
    need(
        QCAA_URL in crosswalk
        and QCAA_ALIGNMENT_URL in crosswalk
        and "not observed" in crosswalk.lower(),
        "Queensland/evidence boundary absent",
    )
    records = {
        record["code"]: record
        for record in data["records"]
        if record.get("record_type") == "content_description"
        and record.get("code") in ROWS
    }
    need(set(records) == set(ROWS), "Missing official Foundation HPE row")
    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"] == "Health and Physical Education"
            and attrs["subject"] == "Health and Physical Education",
            f"Wrong level/area/row: {code}",
        )
        pattern = (
            rf"^\| {code} \| {row} \| "
            rf"Health and Physical Education · Foundation Year \| "
            rf"{re.escape(record['plain_text'])} \|"
        )
        need(
            re.search(pattern, crosswalk, re.MULTILINE),
            f"Verbatim crosswalk wording absent: {code}",
        )
        if code in NOT_CLAIMED:
            need(
                re.search(
                    rf"^\| {code} \|.*\*\*Not claimed here\.\*\*",
                    crosswalk,
                    re.MULTILINE,
                ),
                f"Unclaimed HPE row not marked: {code}",
            )
    print("PASS ten exact official ACARA v9 Foundation HPE rows and QCAA Prep links")


def pedagogy() -> None:
    """Check ten 25-minute scripts, 30 routes and 20 worked context 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(1, 11)),
        "Teacher 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} week/time drift: {times}",
        )
        codes = set(re.findall(r"\bAC9HPF(?:P|M)\d\d\b", body))
        need(codes == EXPECTED_CODES[day], f"Day {day} code drift: {codes}")
        need(
            "check" in body.lower() and "record" in body.lower(),
            f"Day {day} lacks an evidence/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(1, 11)),
        "Learner day 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")
    for day in range(1, 11):
        need(
            re.search(rf"^\| {day} \| \*\*[^|]+ \| \*\*[^|]+ \|", swaps, re.MULTILINE),
            f"Two worked swaps absent on Day {day}",
        )
    need(
        "not fixed learning styles" in cards
        and "not observed" in cards.lower()
        and "stop" in cards.lower(),
        "Choice/safety/evidence boundary absent",
    )
    print("PASS ten 25-minute scripts, 30 switchable routes and 20 worked swaps")


def checks() -> None:
    """Keep new public checks and printed/key values distinct from practice."""
    student = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    practice = read("PRACTICE-SWAPS.md") + read("LEARNER-CARDS.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"),
        "Learner check links directly to public teacher key",
    )
    need(
        "not secure exams" in student
        and "public by URL" in key
        and "first child-controlled" in student,
        "Public formative/first-response boundary absent",
    )
    for phrase in (
        "Nela: “May I use the CLASS RIBBON card?”",
        "Oru: “May I put my card on the OPEN BOARD?”",
        "**WAIT PAD**",
        "ROUND LANE",
        "Eli asks:",
        "physical action",
        "**not observed**",
    ):
        need(phrase in key, f"Worked key source/evidence phrase drift: {phrase}")
    for phrase in (
        "Paper Badge Studio",
        "CLASS RIBBON",
        "OPEN BOARD",
        "Paper Patch Gallery",
        "ROUND LANE",
        "OUTSIDE WAIT",
    ):
        need(
            phrase not in practice, f"Fresh check source leaked into routine: {phrase}"
        )
    print("PASS two distinct public formative checks, separate key and physical hold")


def svg_values(stem: str, attribute: str) -> Counter:
    """Count source-bearing rect metadata in a generated SVG."""
    top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
    namespace = {"svg": "http://www.w3.org/2000/svg"}
    return Counter(
        item.get(attribute)
        for item in top.findall(".//svg:rect", namespace)
        if item.get(attribute) is not None
    )


def assets() -> None:
    """Check A4 pairs, linear source agreement and fresh check geometry."""
    alternatives = read("print/TEXT-ALTERNATIVES.md")
    tree = ast.parse(read("print/generate_print.py"))
    page_map = 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(page_map, ast.Dict)
        and {key.value for key in page_map.keys if isinstance(key, ast.Constant)}
        == set(STEMS),
        "Print generator inventory drift",
    )
    namespace = {"svg": "http://www.w3.org/2000/svg"}
    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", namespace) is not None
            and top.find("svg:desc", namespace) is not None,
            f"A4 SVG/metadata drift: {stem}",
        )
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        body = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        need(
            re.search(r"Pages:\s+1\b", info)
            and "(A4)" in info
            and "CC BY 4.0" in body
            and len(body.split()) >= MIN_PDF_WORDS,
            f"A4 PDF/selectable-text drift: {stem}",
        )
        need(
            f"{stem}.svg" in alternatives and f"{stem}.pdf" in alternatives,
            f"Full linear/tactile alternative absent: {stem}",
        )
        linear_pdf = " ".join(body.lower().split())
        linear_alt = " ".join(alternatives.lower().split())
        for phrase in PRINTED_SOURCE_PHRASES.get(stem, ()):
            need(
                phrase.lower() in linear_pdf and phrase.lower() in linear_alt,
                f"Printed source/text alternative drift: {stem}: {phrase}",
            )
    need(
        svg_values("one-at-a-time-play-mat", "data-lane")
        == Counter({"LEFT LANE": 1, "RIGHT LANE": 1}),
        "Main two-lane geometry drift",
    )
    need(
        svg_values("one-at-a-time-play-mat", "data-zone")
        == Counter(
            {
                "LEFT LANE:START": 1,
                "LEFT LANE:FINISH": 1,
                "RIGHT LANE:START": 1,
                "RIGHT LANE:FINISH": 1,
                "PAUSE SPACE": 1,
            }
        ),
        "Main start/finish/pause geometry drift",
    )
    need(
        svg_values("check-a-fresh-studio", "data-zone")
        == Counter({"WORK MAT": 1, "WAIT PAD": 1})
        and svg_values("check-a-fresh-studio", "data-occupied")
        == Counter({"FIRST MAKER": 1}),
        "Check A occupied/wait layout drift",
    )
    need(
        svg_values("check-b-fresh-gallery", "data-lane") == Counter({"ROUND LANE": 1})
        and svg_values("check-b-fresh-gallery", "data-zone")
        == Counter({"START": 1, "FINISH": 1, "OUTSIDE WAIT": 1}),
        "Check B lane/outside wait layout drift",
    )
    need(
        "untagged" in alternatives and "tactile" in alternatives.lower(),
        "Print access boundary absent",
    )
    print("PASS eight original A4 SVG/PDF pairs and exact source/zone geometry")


def slug(value: str) -> str:
    """Resolve a basic local Markdown heading anchor."""
    value = re.sub(r"\[[^]]+\]\([^)]+\)", "", value)
    value = re.sub(r"[*_]", "", value).strip().lower()
    value = re.sub(r"[^\w -]", "", value)
    return value.replace(" ", "-")


def links(*, allow_manifest_bootstrap: bool = False) -> int:
    """Check local Markdown paths and heading anchors 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(title)
                    for title 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 each authored file except the manifest itself."""
    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/hpe/term-1/weeks-01-02",
        "created_at": "2026-09-29",
        "review_status": (
            "author_desk_checked_pending_teacher_child_"
            "local_safety_accessibility_review"
        ),
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial": sorted(PERSONAL_PARTIAL),
        "curriculum_codes_conditional_actual_movement": sorted(MOVEMENT_CONDITIONAL),
        "curriculum_codes_not_claimed": sorted(NOT_CLAIMED),
        "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 the 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 child/movement validation")


if __name__ == "__main__":
    main()
