#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Fail-closed local receipt for Foundation maths Term 4 Weeks 31–32."""
from __future__ import annotations

import argparse
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"
ROWS = {"AC9MFN01": 17151, "AC9MFN03": 17160,
        "AC9MFN04": 17166, "AC9MFN05": 17171}
EXPECTED_CODES = {
    151: {"AC9MFN01"}, 152: {"AC9MFN01"},
    153: {"AC9MFN01", "AC9MFN03"},
    154: {"AC9MFN01", "AC9MFN03"},
    155: {"AC9MFN01", "AC9MFN03"},
    156: {"AC9MFN04", "AC9MFN05"},
    157: {"AC9MFN04", "AC9MFN05"},
    158: {"AC9MFN04", "AC9MFN05"},
    159: {"AC9MFN05"},
    160: {"AC9MFN04", "AC9MFN05"},
}
STEMS = ("zero-to-twenty-track", "count-once-mat", "part-whole-tray",
         "change-story-mat", "teacher-held-checks")


def need(test: bool, message: str) -> None:
    if not test:
        raise AssertionError(message)


def read(path: str) -> str:
    return (ROOT / path).read_text(encoding="utf-8")


def source() -> None:
    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"))
    snap = json.loads(read("source-snapshot.json"))
    need(hashlib.sha256(workbook.read_bytes()).hexdigest() == SOURCE_SHA,
         "Official workbook byte hash changed")
    need(data["source_sha256"] == snap["workbook_sha256"] == SOURCE_SHA,
         "Pinned import/source hash mismatch")
    need(snap["content_rows"] == ROWS, "Snapshot row mapping drift")
    records = {r["code"]: r for r in data["records"]
               if r.get("record_type") == "content_description" and r.get("code") in ROWS}
    cross = read("CURRICULUM-CROSSWALK.md")
    for code, row_num in ROWS.items():
        row = records[code]
        exact = row["plain_text"]
        need(row["source_row"] == row_num and row["attributes"]["level"] == "Foundation Year"
             and row["attributes"]["learning_area"] == "Mathematics",
             f"Wrong ACARA row, level or area: {code}")
        pattern = rf"^\| {code} \| {row_num} \| Mathematics · Foundation Year \| {re.escape(exact)} \|"
        need(re.search(pattern, cross, re.MULTILINE) is not None,
             f"Exact official wording/row absent in crosswalk: {code}")
    need("partial" in cross.lower() and "not a complete Foundation year" in cross,
         "Crosswalk coverage boundary absent")
    print("PASS four exact ACARA v9 Foundation Mathematics rows and partial boundaries")


def pedagogy() -> None:
    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(151, 161)),
         "Need Days 151–160 once and in order")
    for i, mark in enumerate(marks):
        day = int(mark.group(2))
        body = lessons[mark.end():marks[i + 1].start() if i + 1 < len(marks) else len(lessons)]
        need(int(mark.group(1)) == 31 if day <= 155 else int(mark.group(1)) == 32,
             f"Wrong week for Day {day}")
        times = [int(x) for x in re.findall(r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*", body, re.MULTILINE)]
        need(times == [3, 4, 5, 7, 4, 2] and sum(times) == 25,
             f"Wrong 25-minute routine at Day {day}: {times}")
        codes = set(re.findall(r"\bAC9MFN\d\d\b", body))
        need(codes == EXPECTED_CODES[day], f"Day {day} code drift: {codes}")
        need("Evidence check" in body or "Boundary" in body or "Reason check" in body,
             f"Day {day} lacks reasoning checkpoint")
    cards = read("LEARNER-CARDS.md")
    card_marks = list(re.finditer(r"^## Day (\d+)\b", cards, re.MULTILINE))
    need([int(m.group(1)) for m in card_marks] == list(range(151, 161)),
         "Need ten clean learner cards")
    for i, mark in enumerate(card_marks):
        body = cards[mark.end():card_marks[i + 1].start()
                     if i + 1 < len(card_marks) else len(cards)]
        need(all(f"**{route} ·" in body for route in "ABC") and "**Same target:**" in body,
             f"Day {mark.group(1)} routes/target incomplete")
    swaps = read("PRACTICE-SWAPS.md")
    for day in range(151, 161):
        need(re.search(rf"^\| {day} \| \*\*[^|]+ \| \*\*[^|]+ \|", swaps, re.MULTILINE) is not None,
             f"Day {day} lacks two worked swaps")
    need("after-check" in cards.lower() and "learning styles" in cards,
         "Choice/access boundary missing")
    print("PASS ten distinct timed lessons, 30 learner routes and 20 worked optional swaps")


def fresh_checks() -> None:
    checks, key, practice = read("STUDENT-CHECKS.md"), read("teacher/KEY-AND-NEXT.md"), \
        read("MATERIALS.md") + read("PRACTICE-SWAPS.md")
    need(len(re.findall(r"^## Check [AB] · Day (?:155|160)", checks, re.MULTILINE)) == 2,
         "Two fresh checks missing")
    need("12 identical" not in checks and "19 identical" not in checks,
         "Check A counts leak in learner-facing copy")
    need("**12** identical" in key and "**19**" in key and "seven B cards" in key,
         "Check A teacher counts/key wrong")
    need("**11, 12, 13**" in key and "**12** is between" in key,
         "Check A order key wrong")
    need(6 + 3 == 9 and 8 - 5 == 3, "Independent check B arithmetic failed")
    need("start **6**" in key and "end **9**" in key and
         "start **8**" in key and "end **3**" in key,
         "Check B worked result drift")
    need("12 versus 19" not in practice and "6 add 3" not in practice and
         "8 take 5" not in practice, "Fresh cases copied into practice")
    need(all("public" in x.lower() and "not secure exams" in x.lower()
             for x in (checks, key)), "Public check/key security boundary missing")
    need("first child-controlled" in checks and "content help" in checks,
         "First-response/access safeguard missing")
    print("PASS two fresh public checks and independently verified answer arithmetic")


def assets() -> None:
    ns = {"s": "http://www.w3.org/2000/svg"}
    alt = read("print/TEXT-ALTERNATIVES.md")
    for stem in STEMS:
        svg, pdf = ROOT / "print" / f"{stem}.svg", ROOT / "print" / f"{stem}.pdf"
        tree = ET.parse(svg)
        top = tree.getroot()
        need(top.get("width") == "210mm" and top.get("height") == "297mm",
             f"{stem} SVG is not A4")
        need(top.find("s:title", ns) is not None and top.find("s:desc", ns) is not None,
             f"{stem} SVG lacks title/description")
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        body = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        need("Pages:           1" in info and "(A4)" in info,
             f"{stem} PDF size/pages wrong")
        need("CC BY 4.0" in body and len(body.split()) > 20,
             f"{stem} PDF text/rights absent")
        need(f"{stem}.svg" in alt and f"{stem}.pdf" in alt,
             f"{stem} full-text aid absent")
    need("Tactile" in alt and "untagged" in alt,
         "Print accessibility limits absent")
    print("PASS five original single-page A4 SVG/PDF and text/tactile aid descriptions")


def slug(text: str) -> str:
    text = re.sub(r"\[[^]]+\]\([^)]+\)", "", text)
    text = re.sub(r"[`*_]", "", text).strip().lower()
    text = re.sub(r"[^\w -]", "", text)
    return text.replace(" ", "-")


def links() -> int:
    total = 0
    for path in ROOT.rglob("*.md"):
        content = path.read_text(encoding="utf-8")
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", content):
            if url.startswith(("https://", "http://", "mailto:")):
                continue
            name, marker, anchor = unquote(url).partition("#")
            target = (path.parent / name).resolve() if name else path
            need(target.is_file(), f"Broken local link in {path}: {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 in {path}: {url}")
            total += 1
    print(f"PASS {total} local file and heading links")
    return total


def manifest_data() -> dict:
    files = sorted(p for p in ROOT.rglob("*")
                   if p.is_file() and p.name != "manifest.json" and "__pycache__" not in p.parts)
    return {
        "schema": "subjectnest-authored-pack-manifest-v1",
        "pack": "foundation/mathematics/term-4/weeks-31-32",
        "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(p.relative_to(ROOT)): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(),
                                            "bytes": p.stat().st_size} for p in files},
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-manifest", action="store_true")
    args = parser.parse_args()
    source()
    pedagogy()
    fresh_checks()
    assets()
    links()
    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 manifest missing or stale")
        print(f"PASS SHA-256 manifest for {len(expected['files'])} files")
    print("PACK PASS · offline authored artifacts; no live classroom/access validation")


if __name__ == "__main__":
    main()
