#!/usr/bin/env python3
"""Read-only integrity, source, structure and sample maths checks for this pack.

Run with --write-manifest only after an intentional edit. The normal path writes nothing.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
from urllib.parse import unquote

ROOT = Path(__file__).resolve().parent
ACARA = ROOT.parents[4] / "data/frameworks/acara-v9.json"
REQUIRED = {
    "README.md", "LESSONS.md", "STUDENT-CARDS.md", "STUDENT-CHECKS.md",
    "TEACHER-KEY.md", "CURRICULUM-CROSSWALK.md", "SOURCES-AND-REVIEW.md",
    "RUN-THROUGH.md", "verify_pack.py", "CODE-LICENSE.txt",
    "print/generate_print.py", "print/route-grid.svg", "print/route-grid.pdf",
    "print/exchange-mat.svg", "print/exchange-mat.pdf",
    "print/TEXT-ALTERNATIVES.md", "print/FONT-RIGHTS.md",
    "print/dejavu-font-copyright.txt",
}
CODES = {
    "AC9M2N01": (17309, "recognise, represent and order numbers to at least 1000 using physical and virtual materials, numerals and number lines"),
    "AC9M2N02": (17314, "partition, rearrange, regroup and rename two- and three-digit numbers using standard and non-standard groupings; recognise the role of a zero digit in place value notation"),
    "AC9M2SP02": (17391, "locate positions in two-dimensional representations of a familiar space; move positions by following directions and pathways"),
}


def check(condition: bool, message: str) -> None:
    if not condition:
        raise AssertionError(message)


def sha(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def manifest_lines() -> str:
    paths = 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"{sha(p)}  {p.relative_to(ROOT).as_posix()}\n" for p in paths)


def slug(head: str) -> str:
    head = re.sub(r"<[^>]+>", "", head).lower()
    head = re.sub(r"[^\w\- ]", "", head)
    return re.sub(r" +", "-", head.strip())


def check_links() -> None:
    pattern = re.compile(r"(?<!!)\[[^]]*\]\(([^)]+)\)")
    for doc in ROOT.rglob("*.md"):
        body = doc.read_text(encoding="utf-8")
        for raw in pattern.findall(body):
            target = unquote(raw.split()[0])
            if target.startswith(("https://", "http://", "mailto:")):
                continue
            path_part, _, fragment = target.partition("#")
            linked = (doc.parent / path_part).resolve() if path_part else doc
            check(linked.exists(), f"broken local link in {doc.relative_to(ROOT)}: {target}")
            if fragment and linked.suffix.lower() == ".md":
                linked_text = linked.read_text(encoding="utf-8")
                explicit = set(re.findall(r'<a id="([^"]+)"', linked_text))
                headings = {slug(h) for h in re.findall(r"^#{1,6} (.+)$", linked_text, re.M)}
                check(fragment in explicit | headings,
                      f"broken fragment in {doc.relative_to(ROOT)}: {target}")


def check_curriculum() -> None:
    data = json.loads(ACARA.read_text(encoding="utf-8"))
    check(data["source_sha256"] == "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3", "pinned ACARA workbook changed")
    records = [r for r in data["records"] if r.get("record_type") == "content_description"]
    cross = (ROOT / "CURRICULUM-CROSSWALK.md").read_text(encoding="utf-8")
    for code, (row, wording) in CODES.items():
        hit = [r for r in records if r.get("code") == code]
        check(len(hit) == 1, f"official code missing/duplicated: {code}")
        r = hit[0]
        check(r["source_row"] == row and r["attributes"]["level"] == "Year 2" and r["attributes"]["learning_area"] == "Mathematics", f"wrong level/source row: {code}")
        check(r["plain_text"] == wording and wording in cross and code in cross,
              f"wording drift in {code}")
    lessons = (ROOT / "LESSONS.md").read_text(encoding="utf-8")
    mentioned = set(re.findall(r"AC9M\d+[A-Z]+\d+", lessons + cross))
    check(mentioned == set(CODES), f"unverified/omitted mathematics codes: {mentioned ^ set(CODES)}")


def check_structure() -> None:
    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")
    sections = re.findall(r"^### Day (\d+) ·.*?(?=^### Day |^## Week |\Z)", lessons, re.M | re.S)
    check([int(x) for x in sections] == list(range(11, 21)), "need ten ordered Day 11–20 scripts")
    for day in range(11, 21):
        block_match = re.search(rf"^### Day {day} ·.*?(?=^### Day |^## Week |\Z)", lessons, re.M | re.S)
        check(block_match is not None, f"missing Day {day}")
        block = block_match.group()
        minutes = [int(x) for x in re.findall(r"\*\*[^*]+ · (\d+)\.\*\*", block)]
        check(minutes == [2, 5, 6, 6, 4, 2] and sum(minutes) == 25,
              f"Day {day} does not have six executable 25-minute phases: {minutes}")
        card_block = re.search(rf"^## Day {day} ·.*?(?=^## Day |^## Week |\Z)", cards, re.M | re.S)
        check(card_block is not None, f"missing Day {day} cards")
        labels = re.findall(r"^- \*\*([ABC]) ·", card_block.group(), re.M)
        check(labels == ["A", "B", "C"], f"Day {day} needs three distinct choices")
    for day in (15, 20):
        match = re.search(rf"^## Day {day} ·.*?(?=^## Day |\Z)", checks, re.M | re.S)
        check(match is not None, f"missing held-out Day {day}")
        check(re.findall(r"^([1-4])\. ", match.group(), re.M) == ["1", "2", "3", "4"], f"Day {day} needs four check prompts")
    check("TEACHER-KEY" not in cards + checks and "teacher answer" not in cards.lower() + checks.lower(),
          "learner copy leaks staff key")
    for unseen in ("608", "364", "680"):
        check(unseen not in cards, f"held-out number {unseen} leaked into practice")
    check("Map B" not in cards, "held-out map leaked into practice")
    check("Day 15 held-out check" in key and "Day 20 held-out check" in key,
          "teacher check keys missing")


def check_maths() -> None:
    # Recompute every fixed place-value answer and all held-out forms.
    groups = {
        158: [(1, 5, 8), (0, 15, 8)],
        231: [(2, 3, 1), (1, 13, 1)],
        246: [(2, 4, 6), (1, 14, 6)],
        278: [(2, 7, 8), (1, 17, 8), (2, 6, 18)],
        284: [(2, 8, 4), (1, 18, 4), (0, 28, 4)],
        324: [(3, 2, 4), (3, 1, 14), (2, 12, 4)],
        351: [(3, 5, 1), (2, 15, 1), (2, 14, 11)],
        364: [(3, 6, 4), (3, 5, 14)],
        372: [(3, 7, 2), (2, 17, 2), (3, 6, 12)],
        405: [(4, 0, 5), (3, 10, 5), (0, 40, 5)],
        407: [(4, 0, 7), (3, 10, 7), (0, 40, 7)],
        450: [(4, 5, 0), (4, 4, 10)],
        472: [(4, 7, 2), (4, 6, 12)],
        506: [(5, 0, 6), (4, 10, 6)],
        530: [(5, 3, 0), (5, 2, 10)],
        608: [(6, 0, 8), (5, 10, 8)],
        620: [(6, 2, 0), (5, 12, 0), (0, 62, 0)],
    }
    for total, forms in groups.items():
        for h, ten, one in forms:
            check(100 * h + 10 * ten + one == total,
                  f"arithmetic error: {total} versus {h}H {ten}T {one}O")
    check(680 > 608 and 680 // 100 == 608 // 100 and 680 // 10 % 10 > 608 // 10 % 10,
          "Day 20 comparison error")
    # Coordinates: column A=0, row 1=0. Traces must stay on the four-by-four grid.
    traces = [
        ("B2", "RR", "D2"), ("A3", "RU", "B2"), ("C2", "DL", "B3"),
        ("B2", "RD", "C3"), ("D3", "LLU", "B2"), ("A4", "UUR", "B2"),
        ("B3", "RR", "D3"), ("C1", "DDD", "C4"), ("D4", "LLUU", "B2"),
        ("C1", "R", "D1"), ("A1", "RR", "C1"), ("D4", "LL", "B4"),
        ("C3", "UR", "D2"), ("B3", "RRU", "D2"), ("A4", "UU", "A2"),
    ]
    moves = {"U": (0, -1), "D": (0, 1), "L": (-1, 0), "R": (1, 0)}
    for start, sequence, finish in traces:
        x, y = ord(start[0]) - 65, int(start[1]) - 1
        for step in sequence:
            dx, dy = moves[step]
            x, y = x + dx, y + dy
            check(0 <= x < 4 and 0 <= y < 4, f"off-grid route {start} {sequence}")
        got = f"{chr(65+x)}{y+1}"
        check(got == finish, f"route {start} {sequence}: expected {finish}, got {got}")


def check_assets() -> None:
    for stem in ("route-grid", "exchange-mat"):
        svg = (ROOT / "print" / f"{stem}.svg").read_text(encoding="utf-8")
        check('width="210mm" height="297mm"' in svg and "<title" in svg and "<desc" in svg,
              f"{stem} SVG lacks A4/accessibility metadata")
        info = subprocess.run(["pdfinfo", str(ROOT / "print" / f"{stem}.pdf")],
                              capture_output=True, text=True, check=True).stdout
        check(re.search(r"Pages:\s+1\b", info) is not None, f"{stem} PDF pages")
        check("A4" in info and "595" in info and "841" in info, f"{stem} PDF not A4")
    alt = (ROOT / "print/TEXT-ALTERNATIVES.md").read_text(encoding="utf-8")
    for phrase in ("A1 reading nook", "D4 recycling", "H · hundreds", "What stayed the same?"):
        check(phrase in alt, f"missing visual content in text alternative: {phrase}")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--write-manifest", action="store_true", help="intentionally refresh MANIFEST.sha256")
    args = parser.parse_args()
    missing = sorted(p for p in REQUIRED if not (ROOT / p).is_file())
    check(not missing, f"missing required files: {missing}")
    check_links()
    check_curriculum()
    check_structure()
    check_maths()
    check_assets()
    generated = manifest_lines()
    manifest = ROOT / "MANIFEST.sha256"
    if args.write_manifest:
        manifest.write_text(generated, encoding="utf-8")
        print("wrote MANIFEST.sha256")
    else:
        check(manifest.is_file(), "missing manifest; run --write-manifest after review")
        check(manifest.read_text(encoding="utf-8") == generated,
              "manifest mismatch; review edits, then refresh intentionally")
    print("PASS: exact Year 2 ACARA codes, 10 x 25-minute scripts, 30 choice cards, held-out checks, links, A4 aids, arithmetic/routes and file hashes")


if __name__ == "__main__":
    main()
