#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Read-only integrated Weeks 3–4 checks; flags refresh derived crosswalk/hashes."""
from __future__ import annotations

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

ROOT = Path(__file__).resolve().parent
GUIDES = (
    ROOT.parent / "week-03-story-card-stand.md",
    ROOT.parent / "week-04-two-choice-learning-guide.md",
)
IMPORT = ROOT.parents[3] / "data/frameworks/acara-v9.json"
SOURCE_SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
DESIGN = set("AC9TDE2K01 AC9TDE2K02 AC9TDE2P01 AC9TDE2P02 AC9TDE2P03 AC9TDE2P04".split())
DIGITAL = set("AC9TDI2K01 AC9TDI2K02 AC9TDI2P01 AC9TDI2P02 AC9TDI2P03 AC9TDI2P04 AC9TDI2P05 AC9TDI2P07".split())
CODES = DESIGN | DIGITAL
EVIDENCE = {
    "AC9TDE2K01": "Week 3 user need, reused materials, product purpose; sustainability partial",
    "AC9TDE2K02": "Week 3 material/fold/card movement tests; actual results local",
    "AC9TDE2P01": "Weeks 3–4 two stand ideas and screen plans; communication partial",
    "AC9TDE2P02": "Week 3 safe stand making with available materials",
    "AC9TDE2P03": "Weeks 3–4 evidence-led product evaluation; preference and sustainability partial",
    "AC9TDE2P04": "Week 3 cooperative safe making-step sequence",
    "AC9TDI2K01": "Week 4 actual approved input/device/display exploration only",
    "AC9TDI2K02": "Weeks 3–4 pictures, symbols, numbers and words in station cards",
    "AC9TDI2P01": "Week 4 fictional known-user choice problem; real user consultation pending",
    "AC9TDI2P02": "Week 4 two-branch, clear/no and return loop traced",
    "AC9TDI2P03": "Week 4 compare existing approved demo with identified user need",
    "AC9TDI2P04": "Week 4 actual approved tool create/locate/communicate only",
    "AC9TDI2P05": "Week 4 actual trusted-adult-guided digital sharing/collaboration only",
    "AC9TDI2P07": "Week 4 discussion that some apps/sites store personal data; no account",
}


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


def norm(s: str) -> str:
    return " ".join(s.split())


def curriculum() -> tuple[dict, dict[str, dict]]:
    require(IMPORT.is_file(), f"Pinned ACARA import missing: {IMPORT}")
    data = json.loads(IMPORT.read_text(encoding="utf-8"))
    require(data["source_sha256"] == SOURCE_SHA, "Official workbook SHA drift")
    require(data["framework"] == "Australian Curriculum Version 9.0", "Framework drift")
    require("ac-version-9" in data["source_url"], "Source URL drift")
    records = {r["code"]: r for r in data["records"] if r.get("record_type") == "content_description"}
    for code in CODES:
        require(code in records, f"Unknown official code {code}")
        a = records[code]["attributes"]
        require(a["learning_area"] == "Technologies" and a["level"] == "Years 1 and 2", f"Wrong learning area/band: {code}")
        expected_subject = "Design and Technologies" if code in DESIGN else "Digital Technologies"
        require(a["subject"] == expected_subject, f"Wrong Technologies subject: {code}")
    return data, records


def crosswalk(data: dict, records: dict[str, dict]) -> str:
    out = [
        "# ACARA v9 Technologies · Years 1 and 2 crosswalk for integrated Weeks 3–4",
        "",
        "These exact descriptions are **partial links**. Week 3's card stand is a physical designed-solution project; Week 4's two-choice guide is a digital-system/algorithm project. Paper models do not show actual digital-system exploration, tool creation/locating or guided digital sharing. Food/fibre, food preparation, account access and the full range of by-end-of-Year-2 opportunities remain outside this fortnight.",
        "",
        f"Source: [official ACARA Version 9.0 workbook]({data['source_url']}), accessed {data['retrieved_at']}; workbook SHA-256 `{data['source_sha256']}`. Source rows retained. Official wording below has whitespace normalised for plain-text display.",
        "",
        "| Subject | Code | Workbook row | Official level | Official content description | Pack evidence / boundary |",
        "|---|---|---:|---|---|---|",
    ]
    for code in sorted(CODES):
        r = records[code]
        a = r["attributes"]
        cells = [a["subject"], code, str(r["source_row"]), a["level"], norm(r["plain_text"]), EVIDENCE[code]]
        out.append("| " + " | ".join(x.replace("|", "\\|") for x 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/); see [ACARA terms](https://www.australiancurriculum.edu.au/copyright-and-terms-of-use). ACARA does not endorse SubjectNest; no affiliation, sponsorship or approval is claimed.",
        "",
        "The pinned import is a dated snapshot, not an automatic update. See [source, rights and review](SOURCES-AND-REVIEW.md).",
        "",
    ]
    return "\n".join(out)


def check_guides() -> None:
    combined = "\n".join(g.read_text(encoding="utf-8") for g in GUIDES)
    codes = set(re.findall(r"\bAC9T(?:DE|DI)2[A-Z0-9]+\b", combined))
    require(codes == CODES, f"Guide code mismatch: {codes ^ CODES}")
    for guide, expected_days in ((GUIDES[0], list(range(11, 16))), (GUIDES[1], list(range(16, 21)))):
        s = guide.read_text(encoding="utf-8")
        days = [int(x) for x in re.findall(r"^## Day (\d+)\b", s, re.M)]
        require(days == expected_days, f"{guide.name}: five expected days")
        sections = re.split(r"^## Day \d+\b", s, flags=re.M)[1:]
        for day, section in zip(expected_days, sections):
            timing = re.search(r"\*\*35 = ([^*]+?)\.\*\*", section)
            require(timing is not None, f"Day {day}: no 35-minute breakdown")
            minutes = [int(n) for n in re.findall(r"\b\d+\b", timing.group(1))]
            require(len(minutes) == 5 and sum(minutes) == 35, f"Day {day}: minutes {minutes}")
            phases = [int(n) for n in re.findall(r"^([1-5])\. \*\*", section, re.M)]
            require(phases == [1, 2, 3, 4, 5], f"Day {day}: five executable phases")
            require("**Home/access/extension:**" in section, f"Day {day}: home/access/extension missing")
        require("Larger assignment" in s and "rubric" in s.lower() and "transfer" in s.lower(), f"{guide.name}: assignment/rubric/transfer")
    require("actual approved" in GUIDES[1].read_text(encoding="utf-8").lower(), "Digital evidence condition missing")


def check_cards() -> None:
    cards = (ROOT / "LEARNER-CARDS.md").read_text(encoding="utf-8")
    days = [int(x) for x in re.findall(r"^## Card (\d+)\b", cards, re.M)]
    require(days == list(range(11, 21)), "Learner source Cards 11–20 missing")
    require(len(re.findall(r"^## Week [34] assignment", cards, re.M)) == 2, "Two learner assignments missing")
    require("TEACHER-KEY.md" not in cards and not re.search(r"answer key|teacher key", cards, re.I), "Learner key leakage")
    key = (ROOT / "TEACHER-KEY.md").read_text(encoding="utf-8")
    keyed = [int(x) for x in re.findall(r"^\| (\d+) \|", key, re.M)]
    require(keyed == list(range(11, 21)), "Teacher key Days 11–20 missing")
    for heading in ("## Week 3 assignment rubric", "## Week 4 assignment rubric"):
        require(heading in key, f"Missing rubric {heading}")
    require("Later unfamiliar recheck" in key and "next move" in key.lower(), "Transfer or exact next moves missing")
    # Trace all defined branch cases without assuming a particular digital UI.
    def trace(choice: str, clear: bool) -> tuple[str, str]:
        output = {"STORY": "story-card symbol", "NUMBER": "number-card symbol"}[choice]
        state = "END" if clear else "RETURN AND CHANGE CUE"
        return output, state
    require(trace("STORY", True) == ("story-card symbol", "END"), "STORY branch")
    require(trace("NUMBER", True) == ("number-card symbol", "END"), "NUMBER branch")
    require(trace("STORY", False)[1] == "RETURN AND CHANGE CUE", "Loop branch")
    for marker in ("choose STORY or NUMBER", "if no: return and change a cue", "if yes: END"):
        require(marker in cards, f"Learner algorithm piece absent: {marker}")


def slug(title: str) -> str:
    cleaned = "".join(ch for ch in title.lower() if ch in "- _" or ch.isalnum())
    return cleaned.replace(" ", "-")


def check_links() -> int:
    count = 0
    paths = [*GUIDES, *ROOT.rglob("*.md")]
    for path in paths:
        content = path.read_text(encoding="utf-8")
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", content):
            if url.startswith(("http://", "https://", "mailto:", "#")):
                continue
            target, _, fragment = url.partition("#")
            local = (path.parent / unquote(target)).resolve()
            require(local.is_file(), f"Broken link: {path.name} -> {url}")
            if fragment and local.suffix.lower() == ".md":
                headings = [slug(x) for x in re.findall(r"^#{1,6} (.+)$", local.read_text(encoding="utf-8"), re.M)]
                require(unquote(fragment) in headings, f"Broken heading anchor: {path.name} -> {url}")
            count += 1
    return count


def check_assets() -> None:
    ns = {"s": "http://www.w3.org/2000/svg"}
    alt = (ROOT / "print/TEXT-ALTERNATIVES.md").read_text(encoding="utf-8")
    for name, title in (("stand-planner", "Stand design and test"), ("choice-path", "Two-choice guide planner")):
        svg = ROOT / "print" / f"{name}.svg"
        pdf = svg.with_suffix(".pdf")
        require(svg.is_file() and pdf.is_file(), f"Missing {name} pair")
        root = ET.parse(svg).getroot()
        require(root.attrib["width"] == "210mm" and root.attrib["height"] == "297mm", f"{name}: not A4 SVG")
        require(root.find("s:title", ns) is not None and root.find("s:desc", ns) is not None, f"{name}: missing title/desc")
        info = subprocess.run(["pdfinfo", str(pdf)], capture_output=True, text=True, check=True).stdout
        require("(A4)" in info, f"{name}: not A4 PDF")
        extracted = subprocess.run(["pdftotext", str(pdf), "-"], capture_output=True, text=True, check=True).stdout
        require(title in extracted and "CC BY 4.0" in extracted, f"{name}: text/credit missing")
        fonts = subprocess.run(["pdffonts", str(pdf)], capture_output=True, text=True, check=True).stdout
        require("DejaVu" in fonts, f"{name}: embedded font missing")
        require(title.lower() in alt.lower(), f"{name}: text alternative heading missing")
    require("TEACHER-KEY.md" not in alt and not re.search(r"answer key", alt, re.I), "Print text leaks teacher key")


def manifest_text() -> str:
    files = list(ROOT.rglob("*")) + list(GUIDES)
    files = sorted(p for p in files if p.is_file() and p.name != "MANIFEST.sha256" and not p.name.startswith("_preview") and "__pycache__" not in p.parts)
    return "".join(f"{hashlib.sha256(p.read_bytes()).hexdigest()}  {os.path.relpath(p, 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 = curriculum()
    crosswalk_path = ROOT / "CURRICULUM-CROSSWALK.md"
    expected = crosswalk(data, records)
    if args.write_crosswalk:
        crosswalk_path.write_text(expected, encoding="utf-8")
    require(crosswalk_path.read_text(encoding="utf-8") == expected, "Official crosswalk drift")
    manifest = ROOT / "MANIFEST.sha256"
    if args.write_manifest:
        manifest.touch(exist_ok=True)
    check_guides()
    check_cards()
    links = check_links()
    check_assets()
    expected_hashes = manifest_text()
    if args.write_manifest:
        manifest.write_text(expected_hashes, encoding="utf-8")
    require(manifest.read_text(encoding="utf-8") == expected_hashes, "Hash manifest drift")
    print(f"PASS: 2 guides · 10 × 35-minute sessions · 10 source cards · 2 assignments/rubrics · {len(CODES)} official band codes · 2 A4 aids · {links} local links · {len(expected_hashes.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)
