#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Read-only curriculum, lesson, arithmetic, print, link and hash checks.

Only explicit --write-crosswalk or --write-manifest updates derived files.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from fractions import Fraction
from pathlib import Path
from urllib.parse import unquote

ROOT = Path(__file__).resolve().parent
ACARA = ROOT.parents[4] / "data/frameworks/acara-v9.json"
SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
CODES = {
    "AC9M4N01": (17541, "recognise and extend the application of place value to tenths and hundredths and use the conventions of decimal notation to name and represent decimals"),
    "AC9M4N03": (17553, "find equivalent representations of fractions using related denominators and make connections between fractions and decimal notation"),
}
REQUIRED = {
    "README.md", "LESSONS.md", "STUDENT-CARDS.md", "STUDENT-CHECKS.md",
    "TEACHER-KEY.md", "CURRICULUM-CROSSWALK.md", "SOURCES-AND-REVIEW.md",
    "RUN-THROUGH.md", "CODE-LICENSE.txt", "verify_pack.py",
    "print/generate_print.py", "print/TEXT-ALTERNATIVES.md",
    "print/FONT-RIGHTS.md", "print/dejavu-font-copyright.txt",
    "print/decimal-line.svg", "print/decimal-line.pdf",
    "print/equal-whole.svg", "print/equal-whole.pdf",
}


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


def source_records() -> tuple[dict, dict[str, dict]]:
    data = json.loads(ACARA.read_text(encoding="utf-8"))
    require(data["source_sha256"] == SHA, "ACARA workbook SHA drift")
    require(data["framework"] == "Australian Curriculum Version 9.0", "Framework drift")
    require("australiancurriculum.edu.au" in data["source_url"], "Official URL drift")
    records = {r["code"]: r for r in data["records"] if r.get("record_type") == "content_description"}
    for code, (row, wording) in CODES.items():
        r = records[code]
        require((r["source_row"], r["plain_text"]) == (row, wording), f"{code}: source row or wording drift")
        a = r["attributes"]
        require((a["learning_area"], a["level"]) == ("Mathematics", "Year 4"), f"{code}: wrong area/year")
    return data, records


def crosswalk(data: dict, records: dict[str, dict]) -> str:
    out = [
        "# Australian Curriculum v9 crosswalk · Year 4 maths Weeks 3–4",
        "",
        "These are **partial content-description links**, not a complete Year 4 curriculum or achievement-standard judgement. Week 3 names/places tenths and hundredths (`AC9M4N01`); Week 4 makes exact equivalent fraction and decimal representations (`AC9M4N03`), using place-value notation where appropriate (`AC9M4N01`). In every comparison the unit whole is fixed and its parts are equal. A two-week encounter does not establish full mastery.",
        "",
        f"Source: [ACARA official v9 workbook]({data['source_url']}), retrieved {data['retrieved_at']}; source SHA-256 `{data['source_sha256']}`. Text below is the pinned import's content-description wording with whitespace normalised; the original workbook row is retained for audit.",
        "",
        "| Official code | Source row | Official level | Official subject | Official content description |",
        "| --- | ---: | --- | --- | --- |",
    ]
    for code in CODES:
        r = records[code]
        a = r["attributes"]
        cells = [code, str(r["source_row"]), a["level"], a["subject"], " ".join(r["plain_text"].split())]
        out.append("| " + " | ".join(cell.replace("|", "\\|") for cell in cells) + " |")
    out += [
        "",
        "© Australian Curriculum, Assessment and Reporting Authority (ACARA) 2010 to present, unless otherwise indicated. Downloaded from the Australian Curriculum website (accessed 29 September 2026) and modified for plain-text display. Curriculum material is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). [Terms and exclusions](https://www.australiancurriculum.edu.au/copyright-and-terms-of-use). ACARA does not endorse SubjectNest; SubjectNest is not affiliated with, sponsored or approved by ACARA.",
        "",
        "This is a dated source snapshot, not live synchronisation. Check the current official source and local state/territory implementation before reissue. See [sources, rights and review](SOURCES-AND-REVIEW.md).",
        "",
    ]
    return "\n".join(out)


def content_checks() -> None:
    for path in REQUIRED:
        require((ROOT / path).is_file(), f"Required file missing: {path}")
    lessons = (ROOT / "LESSONS.md").read_text(encoding="utf-8")
    cards = (ROOT / "STUDENT-CARDS.md").read_text(encoding="utf-8")
    checks = (ROOT / "STUDENT-CHECKS.md").read_text(encoding="utf-8")
    key = (ROOT / "TEACHER-KEY.md").read_text(encoding="utf-8")
    lesson_days = [int(x) for x in re.findall(r"^### Day (\d+) ·", lessons, flags=re.MULTILINE)]
    card_days = [int(x) for x in re.findall(r"^## Day (\d+) ·", cards, flags=re.MULTILINE)]
    require(lesson_days == card_days == list(range(11, 21)), "Days 11–20 need one script and three choices each")
    for stage, minutes in (("Invite", 2), ("Model", 5), ("Guided", 6), ("Choice", 6), ("Exit", 4), ("Note", 2)):
        # Day 15/20 use a combined ten-minute check with different phase names.
        expected = 8 if stage in ("Choice", "Exit") else 10
        if stage in ("Guided", "Model", "Invite", "Note"):
            expected = 10
        pat = rf"^\d\. \*\*{stage} · {minutes} min\.\*\*"
        require(len(re.findall(pat, lessons, flags=re.MULTILINE)) == expected, f"{stage}: timing/phase count")
    require(len(re.findall(r"^4\. \*\*Independent check · 6 min\.\*\*", lessons, flags=re.MULTILINE)) == 2, "Two held-out phase 4s")
    require(len(re.findall(r"^5\. \*\*Check exit · 4 min\.\*\*", lessons, flags=re.MULTILINE)) == 2, "Two held-out phase 5s")
    sections = re.split(r"^## Day \d+ ·", cards, flags=re.MULTILINE)[1:]
    require(len(sections) == 10, "Ten card sections")
    for day, section in enumerate(sections, 11):
        choices = re.findall(r"^- \*\*([ABC]) ·", section, flags=re.MULTILINE)
        require(choices == ["A", "B", "C"], f"Day {day}: exactly three distinct practice routes")
        for choice in "ABC":
            require(re.search(rf"^\| D{day}-{choice}(?:, \*\*later only\*\*)? \|", key, flags=re.MULTILINE), f"D{day}-{choice}: staff answer missing")
    require(len(re.findall(r"^## Day (?:15|20) ·", checks, flags=re.MULTILINE)) == 2, "Two fresh check sections")
    require(len(re.findall(r"^\d\. \*\*", checks, flags=re.MULTILINE)) == 8, "Each check needs four learner items")
    require(len(re.findall(r"^\| [1-4] \|", key, flags=re.MULTILINE)) == 8, "Held-out key needs eight worked items")
    require("TEACHER-KEY.md" not in cards + checks and "staff key" not in cards.lower() + checks.lower(), "Learner pages leak teacher key")
    pre_day15 = cards.split("## Day 15 ·", 1)[0] + lessons.split("### Day 15 ·", 1)[0]
    for value in ("0.36", "1.07", "0.68"):
        require(value not in pre_day15, f"Day 15 unseen value leaked before check: {value}")
    pre_day20 = cards.split("## Day 20 ·", 1)[0] + lessons.split("### Day 20 ·", 1)[0]
    require("2/5" not in pre_day20, "Day 20 unseen fraction leaked before check")
    alternatives = (ROOT / "print/TEXT-ALTERNATIVES.md").read_text(encoding="utf-8")
    require("1.07" not in alternatives and "0.36" not in alternatives and "0.68" not in alternatives, "Blank aid text leaks held-out value")
    codes = set(re.findall(r"AC9M4[A-Z0-9]+", lessons + (ROOT / "README.md").read_text(encoding="utf-8")))
    require(codes == set(CODES), f"Unverified/missing claimed maths code {codes ^ set(CODES)}")


def arithmetic_checks() -> None:
    key = (ROOT / "TEACHER-KEY.md").read_text(encoding="utf-8")
    for left, right, difference in [
        ("1.4", "1.04", "0.36"), ("1.9", "1.09", "0.81"),
        ("0.70", "0.68", "0.02"), ("0.50", "0.49", "0.01"),
        ("0.25", "0.20", "0.05"), ("0.75", "0.60", "0.15"),
        ("0.80", "0.79", "0.01"), ("0.50", "0.40", "0.10"),
    ]:
        require(Fraction(left) - Fraction(right) == Fraction(difference), f"Math audit: {left}-{right}")
        require(difference in key, f"Staff key lacks audited difference {difference}")
    for a, b in [(Fraction(1, 2), Fraction(5, 10)), (Fraction(1, 2), Fraction(50, 100)),
                 (Fraction(1, 4), Fraction(25, 100)), (Fraction(3, 4), Fraction(75, 100)),
                 (Fraction(1, 5), Fraction(2, 10)), (Fraction(3, 5), Fraction(6, 10)),
                 (Fraction(2, 5), Fraction(4, 10)), (Fraction(2, 5), Fraction(40, 100))]:
        require(a == b, f"Fraction audit: {a} != {b}")
    require(Fraction(1, 2) - Fraction(2, 5) == Fraction(1, 10), "Held-out difference drift")
    require(Fraction(3, 4) - Fraction(3, 5) == Fraction(3, 20), "Practice difference drift")
    for expected in ("0.36", "0.02", "4/10", "40/100", "0.10", "0.15", "0.81"):
        require(expected in key, f"Staff key missing worked exact value {expected}")


def check_links() -> int:
    count = 0
    for doc in ROOT.rglob("*.md"):
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", doc.read_text(encoding="utf-8")):
            if url.startswith(("http://", "https://", "mailto:", "#")):
                continue
            target = (doc.parent / unquote(url.split("#", 1)[0])).resolve()
            require(target.is_file(), f"Broken link: {doc.relative_to(ROOT)} -> {url}")
            count += 1
    return count


def check_print() -> None:
    subprocess.run([sys.executable, str(ROOT / "print/generate_print.py")], check=True, capture_output=True, text=True)
    ns = {"svg": "http://www.w3.org/2000/svg"}
    for name in ("decimal-line", "equal-whole"):
        svg = ROOT / "print" / f"{name}.svg"
        pdf = svg.with_suffix(".pdf")
        r = ET.parse(svg).getroot()
        require(r.attrib.get("width") == "210mm" and r.attrib.get("height") == "297mm", f"{name}: SVG not A4")
        require(r.find("svg:title", ns) is not None and r.find("svg:desc", ns) is not None, f"{name}: missing access description")
        info = subprocess.run(["pdfinfo", str(pdf)], check=True, capture_output=True, text=True).stdout
        require("(A4)" in info and "Pages:           1" in info, f"{name}: PDF not one A4 page")
        text = subprocess.run(["pdftotext", str(pdf), "-"], check=True, capture_output=True, text=True).stdout
        require(len(text) > 250 and "CC BY 4.0" in text, f"{name}: PDF text/credit missing")
        fonts = subprocess.run(["pdffonts", str(pdf)], check=True, capture_output=True, text=True).stdout
        require("DejaVu" in fonts, f"{name}: embedded font subset missing")
    alt = (ROOT / "print/TEXT-ALTERNATIVES.md").read_text(encoding="utf-8")
    require("eleven tick positions" in alt and "five equal-sized square wholes" in alt.lower(), "Text route not complete")
    require("dejavu-font-copyright.txt" in (ROOT / "print/FONT-RIGHTS.md").read_text(encoding="utf-8"), "Font rights missing")


def manifest_text() -> str:
    files = sorted(p for p in ROOT.rglob("*") if p.is_file() and p.name != "MANIFEST.sha256" and "__pycache__" not in p.parts)
    return "".join(f"{hashlib.sha256(p.read_bytes()).hexdigest()}  {p.relative_to(ROOT)}\n" for p in files)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-crosswalk", action="store_true")
    parser.add_argument("--write-manifest", action="store_true")
    args = parser.parse_args()
    data, records = source_records()
    exact = crosswalk(data, records)
    crosswalk_file = ROOT / "CURRICULUM-CROSSWALK.md"
    if args.write_crosswalk:
        crosswalk_file.write_text(exact, encoding="utf-8")
    require(crosswalk_file.read_text(encoding="utf-8") == exact, "Crosswalk does not match pinned official source")
    content_checks()
    arithmetic_checks()
    links = check_links()
    check_print()
    expected = manifest_text()
    manifest = ROOT / "MANIFEST.sha256"
    if args.write_manifest:
        manifest.write_text(expected, encoding="utf-8")
    require(manifest.read_text(encoding="utf-8") == expected, "Hash manifest mismatch")
    print(f"PASS: ten 25-minute lessons · 30 learner routes · 2 held-out checks · 2 exact Year 4 codes · 2 SVG/PDF aids · {links} local links · {len(expected.splitlines())} hashes")


if __name__ == "__main__":
    try:
        main()
    except (AssertionError, KeyError, FileNotFoundError, subprocess.CalledProcessError) as exc:
        print(f"FAIL: {exc}", file=sys.stderr)
        raise SystemExit(1)
