#!/usr/bin/env python3
"""Read-only checks for Year 3 mathematics Days 11–20.

After a reviewed edit, --write-manifest refreshes the hashes intentionally.
"""

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"
WORKBOOK_SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
CODES = {
    "AC9M3SP02": (17504, "interpret and create two-dimensional representations of familiar environments, locating key landmarks and objects relative to each other"),
    "AC9M3N01": (17412, "recognise, represent and order natural numbers using naming and writing conventions for numerals beyond 10 000"),
    "AC9M3N03": (17423, "add and subtract two- and three-digit numbers using place value to partition, rearrange and regroup numbers to assist in calculations without a calculator"),
}
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/held-out-map-b-text.md",
    "print/FONT-RIGHTS.md", "print/dejavu-font-copyright.txt",
    "print/courtyard-map.svg", "print/courtyard-map.pdf",
    "print/held-out-map-b.svg", "print/held-out-map-b.pdf",
    "print/place-exchange.svg", "print/place-exchange.pdf",
}


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


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


def current_manifest() -> str:
    paths = sorted(p for p in ROOT.rglob("*") if p.is_file() and p.name != "MANIFEST.sha256"
                   and "__pycache__" not in p.parts and not p.name.endswith("-preview.png"))
    return "".join(f"{sha(p)}  {p.relative_to(ROOT).as_posix()}\n" for p in paths)


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


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


def check_curriculum() -> None:
    data = json.loads(ACARA.read_text(encoding="utf-8"))
    check(data["source_sha256"] == WORKBOOK_SHA, "pinned ACARA workbook changed")
    descriptions = [r for r in data["records"] if r.get("record_type") == "content_description"]
    crosswalk = (ROOT / "CURRICULUM-CROSSWALK.md").read_text(encoding="utf-8")
    for code, (row, wording) in CODES.items():
        records = [r for r in descriptions if r.get("code") == code]
        check(len(records) == 1, f"missing/duplicate official code {code}")
        record = records[0]
        check(record["source_row"] == row and record["attributes"]["level"] == "Year 3"
              and record["attributes"]["learning_area"] == "Mathematics", f"wrong official scope {code}")
        check(record["plain_text"] == wording and code in crosswalk and wording in crosswalk,
              f"crosswalk wording drift {code}")
    linked_codes = set(re.findall(r"AC9M\d+[A-Z]+\d+", crosswalk + (ROOT / "LESSONS.md").read_text(encoding="utf-8")))
    check(linked_codes == set(CODES), f"unverified or missing curriculum code: {linked_codes ^ 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")
    days = [int(x) for x in re.findall(r"^### Day (\d+) ·", lessons, re.M)]
    check(days == list(range(11, 21)), "ten ordered Day 11–20 scripts required")
    for day in range(11, 21):
        lesson = re.search(rf"^### Day {day} ·.*?(?=^### Day |^## Week |\Z)", lessons, re.M | re.S)
        check(lesson is not None, f"missing lesson Day {day}")
        times = [int(x) for x in re.findall(r"\*\*[^*]+ · (\d+)\.\*\*", lesson.group())]
        check(times == [2, 5, 6, 6, 4, 2] and sum(times) == 25,
              f"Day {day} has wrong lesson phases: {times}")
        card = re.search(rf"^## Day {day} ·.*?(?=^## Day |^## Week |\Z)", cards, re.M | re.S)
        check(card is not None, f"missing practice cards Day {day}")
        options = re.findall(r"^- \*\*([ABC]) ·", card.group(), re.M)
        check(options == ["A", "B", "C"], f"Day {day} needs A/B/C practice choices")
    for day in (15, 20):
        check_block = re.search(rf"^## Day {day} ·.*?(?=^## Day |\Z)", checks, re.M | re.S)
        check(check_block is not None, f"missing held-out Day {day}")
        check(re.findall(r"^([1-4])\. ", check_block.group(), re.M) == ["1", "2", "3", "4"],
              f"Day {day} requires four check items")
    check("TEACHER-KEY" not in cards + checks, "student copy links to staff answer key")
    for unseen in ("41 205", "41 250", "624", "187"):
        check(unseen not in cards, f"held-out number leaked into practice: {unseen}")
    check("Map B" not in cards and "makers' fair" not in cards.lower(), "held-out map leaked into practice")
    check("Day 15 held-out check" in key and "Day 20 held-out check" in key, "staff keys missing")


def check_maths_and_maps() -> None:
    # Recompute all fixed-answer three-digit calculations, including checks and examples.
    operations = {
        "347+286": 633, "268+175": 443, "156+278": 434,
        "234+189": 423, "367+158": 525, "408+176": 584,
        "705-368": 337, "602-157": 445, "503-268": 235,
        "804-376": 428, "730-284": 446, "612-275": 337,
        "421-176": 245, "624-187": 437, "508-179": 329,
    }
    for expression, answer in operations.items():
        left, operator, right = re.fullmatch(r"(\d+)([+-])(\d+)", expression).groups()
        result = int(left) + int(right) if operator == "+" else int(left) - int(right)
        check(result == answer, f"arithmetic error in {expression}")
    # Read the authored arithmetic equations as well as the independent fixtures.
    for name in ("LESSONS.md", "TEACHER-KEY.md", "RUN-THROUGH.md"):
        body = (ROOT / name).read_text(encoding="utf-8")
        for left, symbol, right, written in re.findall(r"(?<!\d)(\d{3})\s*([+−-])\s*(\d{3})\s*=\s*(\d{3})(?!\d)", body):
            actual = int(left) + int(right) if symbol == "+" else int(left) - int(right)
            check(actual == int(written), f"incorrect written equation in {name}: {left}{symbol}{right}={written}")
    # First tuple is standard TT/Th/H/T/O; later tuple(s) must conserve value.
    groups = {
        24306: [(2, 4, 3, 0, 6), (1, 14, 3, 0, 6)],
        32105: [(3, 2, 1, 0, 5), (2, 12, 1, 0, 5)],
        30507: [(3, 0, 5, 0, 7), (2, 10, 5, 0, 7)],
        30570: [(3, 0, 5, 7, 0)],
        43020: [(4, 3, 0, 2, 0)],
        21408: [(2, 1, 4, 0, 8), (1, 11, 4, 0, 8)],
        35071: [(3, 5, 0, 7, 1), (2, 15, 0, 7, 1)],
        18205: [(1, 8, 2, 0, 5), (0, 18, 2, 0, 5)],
        40205: [(4, 0, 2, 0, 5)],
        40250: [(4, 0, 2, 5, 0)],
        42005: [(4, 2, 0, 0, 5)],
        50080: [(5, 0, 0, 8, 0)],
        50008: [(5, 0, 0, 0, 8)],
        60012: [(6, 0, 0, 1, 2)],
        60120: [(6, 0, 1, 2, 0)],
        42050: [(4, 2, 0, 5, 0), (3, 12, 0, 5, 0)],
        42500: [(4, 2, 5, 0, 0)],
        52610: [(5, 2, 6, 1, 0), (4, 12, 6, 1, 0)],
        38407: [(3, 8, 4, 0, 7), (2, 18, 4, 0, 7)],
        38470: [(3, 8, 4, 7, 0)],
        41205: [(4, 1, 2, 0, 5), (3, 11, 2, 0, 5)],
        41250: [(4, 1, 2, 5, 0)],
    }
    for total, forms in groups.items():
        for tt, th, h, t, one in forms:
            check(tt * 10000 + th * 1000 + h * 100 + t * 10 + one == total,
                  f"five-place grouping error for {total}")
    map_a = {
        "A1": "entrance", "C1": "notice board", "E1": "water point",
        "B2": "tool shed", "D2": "shade tree",
        "A3": "garden bed", "C3": "meeting mat", "E3": "art wall",
        "B4": "compost", "D4": "seats",
        "A5": "exit", "C5": "storage", "E5": "recycling",
    }
    map_b = {
        "B1": "welcome", "D1": "water", "A2": "quiet area", "C2": "demo", "E2": "help",
        "B3": "book stall", "D3": "art table", "A4": "recycling", "C4": "rest", "E4": "garden",
        "B5": "exit", "D5": "storage",
    }
    for map_name, mapping in (("A", map_a), ("B", map_b)):
        check(len(mapping) == len(set(mapping)), f"duplicate map {map_name} locations")
        for cell in mapping:
            check(cell[0] in "ABCDE" and cell[1] in "12345", f"invalid {map_name} cell {cell}")
    def delta(a: str, b: str) -> tuple[int, int]:
        return ord(b[0]) - ord(a[0]), int(b[1]) - int(a[1])
    check(delta("C3", "D2") == (1, -1), "shade tree/mat relation")
    check(delta("C5", "B4") == (-1, -1), "compost/storage relation")
    check(delta("D2", "D4") == (0, 2), "shade tree/seats relation")
    check(delta("B3", "D3") == (2, 0), "book stall/art table relation")
    check(delta("C2", "A2") == (-2, 0), "demo/quiet area relation")
    check(map_a["D4"] == "seats" and map_b["C2"] == "demo", "map source drift")


def check_assets() -> None:
    for stem in ("courtyard-map", "held-out-map-b", "place-exchange"):
        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} 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 and "A4" in info,
              f"{stem} PDF is not one A4 page")
    alt = (ROOT / "print/TEXT-ALTERNATIVES.md").read_text(encoding="utf-8")
    for phrase in ("A1 entrance", "E5 recycling", "TT · ten-thousands", "What stayed the same?"):
        check(phrase in alt, f"text alternative missing: {phrase}")
    held_out_alt = (ROOT / "print/held-out-map-b-text.md").read_text(encoding="utf-8")
    for phrase in ("C2 demo", "D3 art table", "blank 3 × 3 grid"):
        check(phrase in held_out_alt, f"held-out text alternative missing: {phrase}")
    check("C2 demo" not in alt, "held-out Map B content leaked into practice alternative")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--write-manifest", action="store_true", help="intentionally refresh reviewed hashes")
    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_and_maps()
    check_assets()
    expected = current_manifest()
    manifest = ROOT / "MANIFEST.sha256"
    if args.write_manifest:
        manifest.write_text(expected, encoding="utf-8")
        print("wrote MANIFEST.sha256")
    else:
        check(manifest.is_file(), "manifest missing; use --write-manifest after review")
        check(manifest.read_text(encoding="utf-8") == expected, "manifest mismatch")
    print("PASS: Year 3 source codes, ten 25-minute lessons, 30 choices, fresh checks, maths/maps, links, A4 aids and hashes")


if __name__ == "__main__":
    main()
