#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Fail closed on local Year 11 bridge scope, arithmetic, assets and receipts."""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from decimal import Decimal
from pathlib import Path
from urllib.parse import unquote

ROOT = Path(__file__).resolve().parent
MANIFEST = ROOT / "MANIFEST.sha256"
DAYS = list(range(21, 31))
STEMS = ("wage-layers", "base-comparison", "change-ladder",
         "currency-direction", "share-measures")
QCAA = "https://www.qcaa.qld.edu.au"


def need(condition: bool, message: str) -> None:
    if not condition:
        raise SystemExit(f"FAIL: {message}")


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


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


def sections(name: str) -> dict[int, str]:
    data = read(name)
    marks = list(re.finditer(r"^## Day (\d+) · (.+)$", data, re.MULTILINE))
    need([int(m.group(1)) for m in marks] == DAYS,
         f"{name}: expected Days 21–30 exactly in order")
    return {int(mark.group(1)): data[mark.end(): marks[i + 1].start()
                                      if i + 1 < len(marks) else len(data)]
            for i, mark in enumerate(marks)}


def source_snapshot() -> None:
    s = json.loads(read("source-snapshot.json"))
    en, gm, qce, asic = (s[k] for k in
                         ("english", "general_mathematics", "qce_handbook", "asic_moneysmart"))
    need(s["checked_on"] == "2026-09-29" and s["jurisdiction"] == "Queensland, Australia",
         "source date/jurisdiction drift")
    need(en["title"] == "English 2025 v1.3" and en["publication"] == "January 2026" and
         en["unit"] == "Unit 1: Perspectives and texts" and
         en["pdf_url"] == QCAA + "/downloads/senior-qce/syllabuses/snr_english_25_syll.pdf" and
         en["landing_url"] == QCAA + "/senior/senior-subjects/syllabuses/english/english" and
         en["printed_pages"] == {"overview": 17, "objectives": 18,
                                 "texts_and_analysis": 19, "responding_and_creating": 20,
                                 "unit_1_2_reporting": 16},
         "QCAA English version/location drift")
    need(gm["title"] == "General Mathematics 2025 v1.3" and
         gm["publication"] == "January 2026" and
         gm["unit"] == "Unit 1: Money, measurement, algebra and linear equations" and
         gm["topic"] == "Topic 1: Consumer arithmetic" and
         gm["subtopic"] == "Applications of rates, percentages and use of spreadsheets" and
         gm["printed_subject_matter_page"] == 15 and
         gm["notional_subtopic_hours"] == 14 and
         gm["relevant_bullets"] == [2, 4, 6, 7, 8] and
         gm["pdf_url"] == QCAA + "/downloads/senior-qce/syllabuses/snr_maths_general_25_syll.pdf" and
         gm["landing_url"] == QCAA + "/senior/senior-subjects/syllabuses/mathematics/general-mathematics",
         "QCAA Mathematics version/location drift")
    need(qce["url"] == QCAA + "/senior/certificates-and-qualifications/qce-qcia-handbook/4-qld-curriculum/4.1-syllabuses" and
         qce["general_unit_notional_hours"] == 55 and
         asic["url"] == "https://moneysmart.gov.au/how-to-invest/choose-your-investments",
         "QCE/ASIC source drift")
    crosswalk, ledger = read("CURRICULUM-CROSSWALK.md"), read("SOURCE-AND-RIGHTS.md")
    for marker in ("2025 v1.3", "29 September 2026", "350 minutes", "14 notional hours",
                   "55 notional hours", "school decides", "not secure exams",
                   "not claimed", "partial"):
        need(marker.lower() in crosswalk.lower(), f"crosswalk missing boundary: {marker}")
    for entry in (en, gm):
        need(entry["pdf_url"] in crosswalk and entry["pdf_url"] in ledger,
             f"QCAA source missing from crosswalk/ledger: {entry['title']}")
    need(qce["url"] in crosswalk and asic["url"] in ledger,
         "QCE/ASIC authority not cited")
    need(re.search(r"\bAC9[A-Z0-9]{5,}\b", crosswalk) is None,
         "invented Year 11 AC9 content code")
    need("not redistributed" in ledger.lower() and
         "not redistributed or hashed" in read("source-snapshot.json").lower(),
         "source byte/live freshness limit missing")


def lesson_routes() -> dict[int, str]:
    sources, lessons, learners = (sections(name) for name in
                                  ("SOURCE-CARDS.md", "LESSONS.md", "LEARNER.md"))
    for day in DAYS:
        need(all(marker in sources[day] for marker in
                 ("**Public", "**Desk note", "**Inquiry:**")),
             f"Day {day}: complete original source pair missing")
        need(all(marker in lessons[day] for marker in
                 ("**0–4", "**4–9", "**9–16", "**16–27", "**27–32", "**32–35")),
             f"Day {day}: timed 35-minute script incomplete")
        routes = re.findall(r"^- \*\*([ABC]) · ([^*]+):\*\* (.+)$",
                            learners[day], re.MULTILINE)
        need([route[0] for route in routes] == ["A", "B", "C"] and
             all(len(route[2]) >= 90 for route in routes) and
             "**Optional extra / paper home:**" in learners[day],
             f"Day {day}: three substantive switchable routes/home task absent")
    intro = read("LEARNER.md").split("## Day 21")[0].lower()
    need(all(part in intro for part in
             ("same three-part response", "not fixed learning-style", "read-aloud")),
         "shared goal/access/reading boundaries absent")
    return sources


def checks() -> None:
    page, key = read("STUDENT-CHECKS.md"), read("teacher/KEY-AND-NEXT.md")
    need(re.findall(r"^## Check ([AB]) · Day (\d+) ·", page, re.MULTILINE) ==
         [("A", "25"), ("B", "30")], "two named fresh checks absent")
    need("## Check A worked response" in key and "## Check B worked response" in key,
         "separate worked check key absent")
    for doc in (page, key):
        need("publicly accessible" in doc.lower() and "not secure exams" in doc.lower(),
             "public-check security boundary absent")
    practice = "\n".join(read(name) for name in
                         ("SOURCE-CARDS.md", "LESSONS.md", "LEARNER.md"))
    for distinct in ("Weather Play", "paper weather-vane cards", "Nookline Papers",
                     "Nookline's one-panel"):
        need(distinct.lower() not in practice.lower(), f"fresh check leaked: {distinct}")
    for token in ("$60 − $45 = $15", "$15 ÷ $45", "$15 ÷ $60", "33⅓%", "25%",
                  "$25 ÷ $2.50 = 10", "$0.50 ÷ $25", "2%"):
        need(token in key, f"fresh check worked value absent: {token}")


def arithmetic(sources: dict[int, str]) -> None:
    d = Decimal
    calculations = {
        21: (7 * d("26"), d("1.5") * 26, 2 * d("1.5") * 26,
             7 * d("26") + 2 * d("1.5") * 26 + 15),
        22: (4 * d("30"), d("1.25") * 30, 3 * d("1.25") * 30,
             4 * d("30") + 3 * d("1.25") * 30 + 10),
        23: (d("70") - 56, (d("70") - 56) / 56 * 100,
             (d("70") - 56) / 70 * 100),
        24: (d("120") - 96, (d("120") - 96) / 96 * 100,
             (d("120") - 96) / 120 * 100, d("120") * d("0.75")),
        25: (40 * d("8"), d("125") + 45 + 100,
             40 * d("8") - d("125") - 45 - 100),
        26: (d("60") * d("0.20"), d("60") * d("0.80"),
             (d("60") - 48) / 48 * 100, d("48") * d("1.20")),
        27: (d("240") * d("0.70"), d("84") / d("0.70")),
        28: (d("0.90") * 40, d("0.90") / d("30") * 100),
        29: (d("36") / 3, d("48") / 4),
        30: (d("20") / 2, d("0.60") / d("20") * 100),
    }
    answers = {
        21: ("182", "39", "78", "275"),
        22: ("120", "37.50", "112.50", "242.50"),
        23: ("14", "25", "20"),
        24: ("24", "25", "20", "90"),
        25: ("320", "270", "50"),
        26: ("12", "48", "25", "57.60"),
        27: ("168", "120"),
        28: ("36", "3"),
        29: ("12", "12"),
        30: ("10", "3"),
    }
    source_tokens = {
        21: ("7 regular hours at $26/hour", "2 additional hours at 1.5 times", "$15 allowance once"),
        22: ("4 regular hours at $30/hour", "3 additional hours at 1.25 times", "one $10 allowance"),
        23: ("$56 stated production cost", "$70 stated sale price"),
        24: ("$96", "$120"),
        25: ("40 paper puzzles at $8", "$125 stock", "$45 table fee", "$100 adult help"),
        26: ("$60", "20% of $60", "$48"),
        27: ("AUD $1 = USD $0.70", "AUD $240 to USD", "USD $84 to AUD"),
        28: ("$30 per share", "$0.90 per share", "40 shares"),
        29: ("$36 price per share", "$3 annual earnings per share",
             "$48 price per share", "$4 annual earnings per share"),
        30: ("$20 price", "$2 annual earnings per share", "$0.60 per share"),
    }
    key = read("teacher/KEY-AND-NEXT.md")
    rows = {int(day): result for day, result, _ in
            re.findall(r"^\| (\d\d) \| (.+) \| (.+) \|$", key, re.MULTILINE)}
    need(sorted(rows) == DAYS, "ten daily worked-key rows absent")
    for day in DAYS:
        need(calculations[day] == tuple(d(x) for x in answers[day]),
             f"Day {day}: independently computed value drift")
        need(all(token in sources[day] for token in source_tokens[day]),
             f"Day {day}: source givens drift")
        for value in set(answers[day]):
            need(re.search(rf"(?<!\d){re.escape(value)}(?!\d)",
                           rows[day].replace(",", "")) is not None,
                 f"Day {day}: worked key missing {value}")
    need(d("60") - 45 == d("15") and d("15") / 45 * 100 == d("100") / 3 and
         d("15") / 60 * 100 == d("25") and d("25") / d("2.50") == d("10") and
         d("0.50") / 25 * 100 == d("2"), "fresh check arithmetic drift")


def assets() -> None:
    alternatives = read("print/TEXT-ALTERNATIVES.md")
    need(alternatives.count("**Tactile route:**") == 5 and all(
        title in alternatives for title in
        ("## Wage-layers mat", "## Base-comparison mat", "## Change-ladder mat",
         "## Currency-direction mat", "## Share-measures mat")),
        "five full text/tactile routes absent")
    ns = {"s": "http://www.w3.org/2000/svg"}
    for stem in STEMS:
        svg, pdf = (ROOT / "print" / f"{stem}.{suffix}" for suffix in ("svg", "pdf"))
        art = ET.parse(svg).getroot()
        need(art.attrib.get("width") == "210mm" and art.attrib.get("height") == "297mm" and
             art.attrib.get("viewBox") == "0 0 210 297" and
             art.attrib.get("role") == "img" and
             art.attrib.get("aria-labelledby") == "title desc" and
             art.find("s:title", ns) is not None and art.find("s:desc", ns) is not None,
             f"{stem}: A4 SVG geometry/access labels")
        info = subprocess.check_output(["pdfinfo", str(pdf)], text=True)
        words = subprocess.check_output(["pdftotext", str(pdf), "-"], text=True)
        fonts = subprocess.check_output(["pdffonts", str(pdf)], text=True)
        need("(A4)" in info and re.search(r"Pages:\s+1\b", info) is not None and
             "SubjectNest" in words and "CC BY 4.0" in words and len(words) > 190 and
             "DejaVuSans" in fonts, f"{stem}: PDF size/text/font")
    with tempfile.TemporaryDirectory(prefix="subjectnest-y11-bridge-") as temp:
        subprocess.run([sys.executable, str(ROOT / "print/generate_print.py"),
                        "--output-dir", temp], check=True, capture_output=True, text=True)
        for stem in STEMS:
            for suffix in ("svg", "pdf"):
                name = f"{stem}.{suffix}"
                need(sha(Path(temp) / name) == sha(ROOT / "print" / name),
                     f"{name}: print regeneration drift")
    html = read("interactive/change-base-lab.html")
    need(all(marker in html for marker in
             ('id="model-form"', 'id="result"', 'aria-live="polite"',
              'max="10000"', 'max="99.99"', 'const reversePercent = 100 * dollarGap / changed')),
         "offline lab model/access markers absent")
    need(not any(marker in html for marker in
                 ("fetch(", "XMLHttpRequest", "localStorage", "sessionStorage",
                  "sendBeacon", "<script src=", '<link rel="stylesheet"')),
         "offline lab depends on network/storage")
    paper = read("interactive/TEXT-ROUTE.md")
    need(all(marker in paper for marker in
             ("$96", "$120", "$90", "$60", "$48", "$57.60", "**Tactile route:**")),
         "complete paper equivalent missing")


def slug(heading: str) -> str:
    heading = re.sub(r"<[^>]+>", "", heading)
    return re.sub(r"[^a-z0-9_-]", "", heading.lower().strip().replace(" ", "-"))


def links_and_rights() -> int:
    count = 0
    for path in ROOT.rglob("*.md"):
        source = path.read_text(encoding="utf-8")
        need("CC BY 4.0" in source, f"{path.name}: original rights notice absent")
        for raw in re.findall(r"\[[^]]+\]\(([^)]+)\)", source):
            if raw.startswith(("https://", "http://", "mailto:")):
                continue
            rel, _, anchor = unquote(raw).partition("#")
            target = (path.parent / rel).resolve() if rel else path
            need(target.exists(), f"{path.name}: broken link {raw}")
            if anchor and target.suffix.lower() == ".md":
                headings = [slug(m.group(1)) for m in re.finditer(
                    r"^#{1,6} (.+)$", target.read_text(encoding="utf-8"), re.MULTILINE)]
                need(anchor in headings, f"{path.name}: broken fragment {raw}")
            count += 1
    html = read("interactive/change-base-lab.html")
    for raw in re.findall(r'href="([^"]+)"', html):
        if raw.startswith(("https://", "http://", "#")):
            continue
        rel, _, anchor = raw.partition("#")
        target = (ROOT / "interactive" / rel).resolve()
        need(target.exists(), f"HTML broken local link {raw}")
        if anchor and target.suffix.lower() == ".md":
            headings = [slug(m.group(1)) for m in re.finditer(
                r"^#{1,6} (.+)$", target.read_text(encoding="utf-8"), re.MULTILINE)]
            need(anchor in headings, f"HTML broken fragment {raw}")
        count += 1
    return count


def files() -> list[Path]:
    return sorted((p for p in ROOT.rglob("*") if p.is_file() and p != MANIFEST and
                   "__pycache__" not in p.parts and ".ruff_cache" not in p.parts),
                  key=lambda p: p.relative_to(ROOT).as_posix())


def manifest() -> str:
    return "".join(f"{sha(p)}  {p.relative_to(ROOT).as_posix()}\n" for p in files())


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-manifest", action="store_true")
    args = parser.parse_args()
    source_snapshot()
    sources = lesson_routes()
    checks()
    arithmetic(sources)
    assets()
    links = links_and_rights()
    expected = manifest()
    if args.write_manifest:
        MANIFEST.write_text(expected, encoding="utf-8")
    else:
        need(MANIFEST.read_text(encoding="utf-8") == expected, "SHA manifest drift")
    print(f"PASS: 10 × 35-minute scripts, 10 original source pairs, 30 switchable routes, "
          f"10 optional home tasks, 2 public fresh checks/keys, 5 A4 SVG/PDF/text aids, "
          f"offline lab/paper route, {links} local links, {len(files())} hashed files")


if __name__ == "__main__":
    main()
