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

import argparse
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 = {"AC9MFN03": 17160, "AC9MFM01": 17187, "AC9MFST01": 17211}
STEMS = (
    "feature-sort-mat", "count-compare-board", "align-ends-mat", "practice-strips",
    "transfer-station", "balance-observation", "teacher-held-checks",
    "practice-observation-161", "practice-observation-162",
    "practice-observation-163", "practice-observation-164",
    "fresh-observation-165-a", "fresh-observation-165-b",
)
CARDS = {
    "practice-observation-161": Counter({("round", "0"): 7, ("pointed", "0"): 4}),
    "practice-observation-162": Counter({("oval", "1"): 5, ("oval", "0"): 4}),
    "practice-observation-163": Counter({("smooth", "0"): 8, ("jagged", "0"): 4}),
    "practice-observation-164": Counter({("oval", "1"): 2, ("oval", "0"): 4,
                                         ("spear", "1"): 1, ("spear", "0"): 3}),
    "fresh-observation-165-a": Counter({("round", "0"): 3, ("narrow", "0"): 4}),
    "fresh-observation-165-b": Counter({("round", "0"): 2, ("narrow", "0"): 4}),
}


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


def read(name: str) -> str:
    return (ROOT / name).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,
         "Pinned official workbook byte hash changed")
    need(data["source_sha256"] == snap["workbook_sha256"] == SOURCE_SHA,
         "Workbook/import source hash mismatch")
    need(snap["content_rows"] == ROWS, "Snapshot source 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}
    need(set(records) == set(ROWS), "Missing exact ACARA content record")
    cross = read("CURRICULUM-CROSSWALK.md")
    for code, row_num in ROWS.items():
        row = records[code]
        need(row["source_row"] == row_num and row["attributes"]["level"] == "Foundation Year"
             and row["attributes"]["learning_area"] == "Mathematics",
             f"Wrong official row, level or area: {code}")
        pattern = (rf"^\| {code} \| {row_num} \| Mathematics · Foundation Year \| "
                   rf"{re.escape(row['plain_text'])} \|")
        need(re.search(pattern, cross, re.MULTILINE) is not None,
             f"Exact official wording absent from crosswalk: {code}")
    need("partial" in cross.lower() and "Duration is not sampled" in cross,
         "Partial scope/duration boundary absent")
    print("PASS three 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(161, 171)),
         "Need Days 161–170 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)]
        week = 33 if day <= 165 else 34
        need(int(mark.group(1)) == week, 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"Day {day} 25-minute routine drift: {times}")
        expected_codes = ({"AC9MFST01", "AC9MFN03"} if day <= 165 else {"AC9MFM01"})
        codes = set(re.findall(r"\bAC9MF(?:N|M|ST)\d\d\b", body))
        need(codes == expected_codes, f"Day {day} code drift: {codes}")
        need("Evidence check" in body or "Reason check" in body or "Boundary" in body,
             f"Day {day} reasoning checkpoint absent")
    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(161, 171)),
         "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)} access routes or same target absent")
    swaps = read("PRACTICE-SWAPS.md")
    for day in range(161, 171):
        need(re.search(rf"^\| {day} \| \*\*[^|]+ \| \*\*[^|]+ \|", swaps, re.MULTILINE),
             f"Day {day} lacks two worked optional swaps")
    need("after-check" in cards.lower() and "not fixed learning styles" in cards,
         "Choice/check/access boundary absent")
    print("PASS ten distinct timed lessons, thirty same-target routes and twenty worked swaps")


def checks() -> None:
    checks = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    practice = read("MATERIALS.md") + read("PRACTICE-SWAPS.md")
    need(len(re.findall(r"^## Check [AB] · Day (?:165|170)", checks, re.MULTILINE)) == 2,
         "Two fresh checks absent")
    need("13" not in checks and "95 mm" not in checks and "145 mm" not in checks
         and "15 A4" not in checks, "Learner check has teacher-only counts/lengths")
    need(all(s in key for s in ("**13**", "**5** ROUND", "**8** NARROW",
                                "**95 mm**", "**145 mm**", "**3 A4 sheets**",
                                "**15 A4 sheets**")), "Teacher check setup/key drift")
    need("13 fictional" not in practice and "95 mm" not in practice
         and "15 A4" not in practice, "Fresh check cases reused in routine practice")
    need(7 + 4 == 11 and 5 + 4 == 9 and 8 + 4 == 12
         and 2 + 4 + 1 + 3 == 10 and 5 + 8 == 13 and 145 - 95 == 50 and 15 - 3 == 12,
         "Independent count/length/mass spot checks failed")
    for body in (checks, key):
        need("public" in body.lower() and "not secure exams" in body.lower(),
             "Public key / nonsecure boundary absent")
    need("actual water" in checks and "actual pan movement" in checks
         and "preflight" in key.lower() and "do not score" in key.lower(),
         "Physical direct-comparison validity boundary absent")
    need("first child-controlled" in checks and "content help" in checks,
         "First-response and access-support boundary absent")
    print("PASS two fresh public checks, worked keys, arithmetic and direct-action validity")


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"
        top = ET.parse(svg).getroot()
        need(top.get("width") == "210mm" and top.get("height") == "297mm",
             f"{stem} is not A4 SVG")
        need(top.find("s:title", ns) is not None and top.find("s:desc", ns) is not None,
             f"{stem} SVG title/description absent")
        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) is not None and "(A4)" in info,
             f"{stem} PDF pages/size wrong")
        need("CC BY 4.0" in body and len(body.split()) > 17,
             f"{stem} PDF selectable text/rights absent")
        need(f"{stem}.svg" in alt and f"{stem}.pdf" in alt,
             f"{stem} full-text alternative absent")
        if stem in CARDS:
            groups = top.findall(".//s:g", ns)
            found = Counter((g.get("data-kind"), g.get("data-flower"))
                            for g in groups if g.get("data-kind") is not None)
            need(found == CARDS[stem], f"{stem} drawn-card recipe/count drift: {found}")
    strip_svg = ET.parse(ROOT / "print/practice-strips.svg").getroot()
    widths = [(int(r.get("data-length-mm")), float(r.get("width")))
              for r in strip_svg.findall(".//s:rect", ns)
              if r.get("data-length-mm") is not None]
    need([n for n, _ in widths] == [80, 125, 110, 110, 75, 115]
         and all(abs(w - n * 794 / 210) < .02 for n, w in widths),
         "Practice strip physical preparation lengths drift")
    need(("Tactile" in alt or "tactile" in alt) and "untagged" in alt,
         "Print access limits absent")
    print(f"PASS {len(STEMS)} original single-page A4 SVG/PDF aids and exact visual card data")


def slug(value: str) -> str:
    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:
    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
            if allow_manifest_bootstrap and target == ROOT / "manifest.json":
                total += 1
                continue
            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-33-34",
        "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()
    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()
