# SPDX-License-Identifier: Apache-2.0
"""Fail-closed offline receipt for Foundation maths Term 4 Weeks 37-38."""

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 = {
    "AC9MFN01": 17151,
    "AC9MFN03": 17160,
    "AC9MFN05": 17171,
    "AC9MFST01": 17211,
}
STEMS = (
    "given-question-sort-mat",
    "one-to-one-compare-board",
    "first-check-now-mat",
    "start-change-end-mat",
    "two-question-picture-cards",
    "check-a-picture-sheet",
    "check-b-model-sheet",
    "blank-source-cards",
)
WEEK_SPLIT_DAY = 185
LESSON_MINUTES = 25
MIN_PDF_WORDS = 20
EXPECTED_CODES = {
    181: {"AC9MFST01", "AC9MFN03"},
    182: {"AC9MFST01", "AC9MFN03"},
    183: {"AC9MFST01", "AC9MFN03"},
    184: {"AC9MFST01", "AC9MFN03"},
    185: {"AC9MFST01", "AC9MFN03"},
    186: {"AC9MFN01", "AC9MFN03"},
    187: {"AC9MFN03"},
    188: {"AC9MFN05"},
    189: {"AC9MFN05"},
    190: {"AC9MFN03", "AC9MFN05"},
}


def need(ok: object, message: str) -> None:
    """Raise a precise failed condition."""
    if not ok:
        raise AssertionError(message)


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


def source() -> None:
    """Check exact source bytes, import metadata and official row wording."""
    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 workbook bytes drifted",
    )
    need(
        data["source_sha256"] == snapshot["workbook_sha256"] == SOURCE_SHA,
        "Source/import hash drift",
    )
    need(snapshot["content_rows"] == ROWS, "Pinned row map drift")
    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 required ACARA content row")
    crosswalk = read("CURRICULUM-CROSSWALK.md")
    for code, row_number in ROWS.items():
        record = records[code]
        attrs = record["attributes"]
        need(
            record["source_row"] == row_number
            and attrs["level"] == "Foundation Year"
            and attrs["learning_area"] == "Mathematics",
            f"Incorrect source row/level/area: {code}",
        )
        pattern = (
            rf"^\| {code} \| {row_number} \| Mathematics · Foundation Year \| "
            rf"{re.escape(record['plain_text'])} \|"
        )
        need(
            re.search(pattern, crosswalk, re.MULTILINE),
            f"Verbatim wording absent: {code}",
        )
    need(
        all(
            phrase in crosswalk
            for phrase in ("partial", "given", "to at least 20", "virtual")
        ),
        "Partial-coverage boundaries absent",
    )
    print("PASS four exact pinned ACARA v9 Foundation Mathematics rows and limits")


def pedagogy() -> None:
    """Check day sequence, six-phase times, same-target routes and 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(181, 191)),
        "Days 181-190 missing/order 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]
        need(
            int(mark.group(1)) == (37 if day <= WEEK_SPLIT_DAY else 38),
            f"Wrong week at Day {day}",
        )
        times = [
            int(value)
            for value in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*",
                body,
                re.MULTILINE,
            )
        ]
        need(
            times == [3, 4, 5, 7, 4, 2] and sum(times) == LESSON_MINUTES,
            f"Timing drift Day {day}: {times}",
        )
        codes = set(re.findall(r"\bAC9MF(?:N|ST)\d\d\b", body))
        need(codes == EXPECTED_CODES[day], f"Curriculum code drift Day {day}: {codes}")
        need("check" in body.lower(), f"Day {day} has no evidence/reason check")
    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(181, 191)),
        "Learner card 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 "**Same target:**" in body
            and "**Home" in body,
            f"Three routes/same target/home route missing Day {mark.group(1)}",
        )
    swaps = read("PRACTICE-SWAPS.md")
    for day in range(181, 191):
        need(
            re.search(rf"^\| {day} \| \*\*[^|]+ \| \*\*[^|]+ \|", swaps, re.MULTILINE),
            f"Two worked optional swaps missing Day {day}",
        )
    need(
        "not fixed learning styles" in cards and "After-check practice only" in cards,
        "Choice/check boundary absent",
    )
    print("PASS ten 25-minute scripts, 30 same-target routes and 20 worked swaps")


def checks() -> None:
    """Check fresh prompts, worked results and practice separation."""
    student = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    practice = read("MATERIALS.md") + read("PRACTICE-SWAPS.md")
    need(
        re.findall(r"^## Check ([AB]) · Day (\d+)\b", student, re.MULTILINE)
        == [("A", "185"), ("B", "190")],
        "Fresh check sequence drift",
    )
    need(
        "teacher/KEY-AND-NEXT.md" not in student,
        "Learner check links directly to teacher key",
    )
    need(
        "not secure exams" in student and "not a secure exam key" in key,
        "Public formative boundary missing",
    )
    need(
        "first child-controlled" in student and "confirmed" in student,
        "First response/confirmation boundary missing",
    )
    need(
        all(
            value in key
            for value in (
                "**9 ARC**",
                "**4 BAR**",
                "**13 total**",
                "**12 compact",
                "**8 more widely",
                "**4 + 5 = 9**",
            )
        ),
        "Fresh check worked values drifted",
    )
    need(
        "9 ARC" not in practice and "4 BAR" not in practice and "4 + 5" not in practice,
        "Fresh check values leaked to worked routine practice",
    )
    actual_results = [sum([9, 4]), 9 - 4, 12 - 8, 4 + 5, 5 + 3, 8 - 2]
    expected_results = [13, 5, 4, 9, 8, 6]
    need(actual_results == expected_results, "Independent arithmetic drift")
    print("PASS two fresh public checks, separate key, arithmetic and source boundary")


def assets() -> None:
    """Check eight physical A4 pages, PDF selectable text and exact data marks."""
    namespace = {"svg": "http://www.w3.org/2000/svg"}
    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),
        "Generator/page stem mismatch",
    )
    for stem in STEMS:
        svg_path = ROOT / "print" / f"{stem}.svg"
        pdf_path = ROOT / "print" / f"{stem}.pdf"
        top = ET.parse(svg_path).getroot()
        need(
            top.get("width") == "210mm" and top.get("height") == "297mm",
            f"Non-A4 SVG: {stem}",
        )
        need(
            top.find("svg:title", namespace) is not None
            and top.find("svg:desc", namespace) is not None,
            f"Missing SVG metadata: {stem}",
        )
        info = subprocess.check_output(["pdfinfo", str(pdf_path)], text=True)
        body = subprocess.check_output(["pdftotext", str(pdf_path), "-"], text=True)
        need(
            re.search(r"Pages:\s+1\b", info) and "(A4)" in info,
            f"Wrong PDF size/pages: {stem}",
        )
        need(
            "CC BY 4.0" in body and len(body.split()) >= MIN_PDF_WORDS,
            f"PDF text/rights absent: {stem}",
        )
        need(
            f"{stem}.svg" in alternatives and f"{stem}.pdf" in alternatives,
            f"Text alternative absent: {stem}",
        )
        if stem in {
            "two-question-picture-cards",
            "check-a-picture-sheet",
            "blank-source-cards",
        }:
            kinds = Counter(
                element.get("data-card-kind")
                for element in top.findall(".//svg:rect", namespace)
                if element.get("data-card-kind")
            )
            expected = {
                "two-question-picture-cards": Counter(
                    {"BUS+STAR": 2, "BUS+PLAIN": 4, "BIKE+STAR": 1, "BIKE+PLAIN": 3}
                ),
                "check-a-picture-sheet": Counter({"ARC": 9, "BAR": 4}),
                "blank-source-cards": Counter({"blank": 12}),
            }[stem]
            need(kinds == expected, f"Exact card source count drift: {stem} {kinds}")
            if stem in {"two-question-picture-cards", "check-a-picture-sheet"}:
                icon_kinds = Counter(
                    group.get("data-icon-kind")
                    for group in top.findall(".//svg:g", namespace)
                    if group.get("data-icon-kind")
                )
                icon_expected = (
                    Counter({"BUS": 6, "BIKE": 4})
                    if stem == "two-question-picture-cards"
                    else Counter({"ARC": 9, "BAR": 4})
                )
                need(icon_kinds == icon_expected, f"Picture/icon drift: {stem}")
            if stem == "two-question-picture-cards":
                mark_kinds = Counter(
                    group.get("data-mark-kind")
                    for group in top.findall(".//svg:g", namespace)
                    if group.get("data-mark-kind")
                )
                need(
                    mark_kinds == Counter({"STAR": 3, "PLAIN": 7}),
                    "Second attribute picture marks drift",
                )
        if stem == "check-b-model-sheet":
            circles = Counter(
                element.get("data-group")
                for element in top.findall(".//svg:circle", namespace)
                if element.get("data-group")
            )
            rectangles = Counter(
                element.get("data-group")
                for element in top.findall(".//svg:rect", namespace)
                if element.get("data-group")
            )
            need(
                circles == Counter({"A": 12, "B": 8}),
                f"Check B row marks drift: {circles}",
            )
            need(
                rectangles == Counter({"START": 4, "ADD": 5}),
                f"Check B add model drift: {rectangles}",
            )
            need(
                "DISPLAY CLAIM: 8" in body and "12" not in body,
                "Check B sheet prints wrong claim/answer",
            )
    need(
        "untagged" in alternatives and "tactile" in alternatives.lower(),
        "Print access boundary absent",
    )
    print("PASS eight original A4 SVG/PDF aids, selectable text and exact card sources")


def slug(value: str) -> str:
    """Approximate local Markdown heading anchors."""
    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:
    """Fail if a local file or Markdown heading target is absent."""
    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 {path.relative_to(ROOT)} -> {url}",
                )
            count += 1
    print(f"PASS {count} local file and heading links")
    return count


def manifest_data() -> dict:
    """Hash every authored asset 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/mathematics/term-4/weeks-37-38",
        "created_at": "2026-09-29",
        "review_status": (
            "author_desk_checked_pending_educator_child_"
            "accessibility_local_syllabus_review"
        ),
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes": 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 gates, optionally freezing exact authored 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 · authored offline artifacts; no live classroom/access validation")


if __name__ == "__main__":
    main()
