# SPDX-License-Identifier: Apache-2.0
"""Read-only source/teaching/print/helper/hash checks; explicit manifest renewal."""

from __future__ import annotations

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

from openpyxl import load_workbook
from PIL import ImageFont

ROOT = Path(__file__).resolve().parent
STUDIO = ROOT.parents[4]
SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
ROWS = {"AC9TDIFK01": 20098, "AC9TDIFK02": 20105, "AC9TDIFP01": 20111}
EXACT = {
    "AC9TDIFK01": "recognise and explore digital systems (hardware and software) for a purpose",
    "AC9TDIFK02": "represent data as objects, pictures and symbols",
    "AC9TDIFP01": "identify some data that is personal and owned by them",
}
STEMS = (
    "practice-plan",
    "activity-key",
    "own-strip",
    "match-forms",
    "own-data-choice",
    "reader-note",
    "check-g",
    "check-h",
)
NS = {"s": "http://www.w3.org/2000/svg"}


def need(ok, message):
    if not ok:
        raise AssertionError(message)


def read(name):
    return (ROOT / name).read_text()


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def state():
    return {
        str(p.relative_to(ROOT)): (digest(p), p.stat().st_size, p.stat().st_mtime_ns)
        for p in ROOT.rglob("*")
        if p.is_file() and not {"__pycache__", ".ruff_cache"}.intersection(p.parts)
    }


def sources():
    workbook = (
        STUDIO
        / "research/sources/acara-australian-curriculum-v9-download-2026-09-29.xlsx"
    )
    imported = json.loads((STUDIO / "data/frameworks/acara-v9.json").read_text())
    snap = json.loads(read("source-snapshot.json"))
    need(
        digest(workbook)
        == imported["source_sha256"]
        == snap["live_workbook_sha256"]
        == SHA,
        "workbook/import/source SHA drift",
    )
    need(
        snap["http_status"] == 200
        and snap["bytes"] == 2049917
        and snap["content_rows"] == ROWS,
        "live source/row receipt drift",
    )
    canonical = {
        r["code"]: r
        for r in imported["records"]
        if r.get("record_type") == "content_description" and r.get("code") in ROWS
    }
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", UserWarning)
        book = load_workbook(workbook, read_only=True, data_only=True)
    actual = {}
    for i, row in enumerate(book["Learning areas"].iter_rows(values_only=True), 1):
        if i in ROWS.values():
            actual[i] = row
        if i > max(ROWS.values()):
            break
    book.close()
    for code, number in ROWS.items():
        row = actual[number]
        record = canonical[code]
        need(
            row[:3] == ("Technologies", "Digital Technologies", "Foundation Year")
            and row[4] == code
            and " ".join(str(row[9]).split()) == EXACT[code],
            "actual workbook E/J row drift " + code,
        )
        need(
            record["source_row"] == number
            and record["plain_text"] == EXACT[code]
            and record["attributes"]["level"] == "Foundation Year",
            "canonical exact row drift " + code,
        )
        need(
            f"| {code} | {number} | {EXACT[code]} |" in read("CURRICULUM-CROSSWALK.md"),
            "exact crosswalk drift " + code,
        )
    need(
        "AC9TDI2K02" in read("CURRICULUM-CROSSWALK.md")
        and "unverified" in read("CURRICULUM-CROSSWALK.md"),
        "QCAA discrepancy/layout limit missing",
    )
    need(
        snap["actual_local_source_or_child_evidence_collected"] is False,
        "invented local evidence",
    )
    print(
        "PASS three exact actual workbook/import/crosswalk rows and fresh source receipt"
    )


def teaching():
    lessons = read("LESSONS.md")
    heads = list(re.finditer(r"^## Week (\d+) · Day (\d+)\b", lessons, re.MULTILINE))
    need([int(m[2]) for m in heads] == list(range(51, 61)), "ten daily starts missing")
    for i, m in enumerate(heads):
        body = lessons[
            m.end() : heads[i + 1].start() if i + 1 < len(heads) else len(lessons)
        ]
        minutes = [
            int(n)
            for n in re.findall(
                r"^\d+\. \*\*[^\n]*? · (\d+) min\.\*\*", body, re.MULTILINE
            )
        ]
        need(
            int(m[1]) == (int(m[2]) - 1) // 5 + 1 and minutes == [3, 4, 5, 7, 4, 2],
            "week/timing drift " + m[2],
        )
        need(
            "**Child task · 7 min.**" in body and any(code in body for code in ROWS),
            "practical task/code missing " + m[2],
        )
    cards = read("LEARNER-CARDS.md")
    heads = list(re.finditer(r"^## Day (\d+)\b", cards, re.MULTILINE))
    need([int(m[1]) for m in heads] == list(range(51, 61)), "route days missing")
    for i, m in enumerate(heads):
        body = cards[
            m.end() : heads[i + 1].start() if i + 1 < len(heads) else len(cards)
        ]
        need(all(f"**{r} ·" in body for r in "ABC"), "three routes missing " + m[1])
    need(
        len(
            re.findall(r"^\| (?:5[1-9]|60) \|", read("PRACTICE-SWAPS.md"), re.MULTILINE)
        )
        == 10,
        "twenty worked choices missing",
    )
    for name in ("FAMILY-OPTIONAL.md", "EXTENSIONS.md"):
        need(
            len(
                re.findall(
                    r"^## (?:After )?Day (?:5[1-9]|60)\b", read(name), re.MULTILINE
                )
            )
            == 10,
            "ten invitations missing " + name,
        )
    routine = "\n".join(
        read(n)
        for n in (
            "MATERIALS.md",
            "LEARNER-CARDS.md",
            "PRACTICE-SWAPS.md",
            "interactive/three-things.html",
            "interactive/PAPER-EQUIVALENT.md",
        )
    )
    need(
        "PLAN-G-11" not in routine and "PLAN-H-12" not in routine,
        "held source leaked into practice",
    )
    checks = read("STUDENT-CHECKS.md")
    key = read("teacher/KEY-AND-NEXT.md")
    need(
        "circle = REST; triangle = READ; square = MAKE" in checks
        and "circle = MAKE; triangle = REST; square = READ" in checks,
        "fresh key facts drift",
    )
    need(
        "| FIRST | MAKE |\n| NEXT | REST |\n| LAST | READ |" in checks
        and "| FIRST | REST |\n| NEXT | MAKE |\n| LAST | MAKE |" in checks,
        "fresh source order drift",
    )
    need(
        "square → circle → triangle" in key
        and "triangle → circle → circle" in key
        and "**LAST**" in key,
        "fresh separate keyed interpretation missing",
    )
    need(
        "not secure exams" in checks and "Public by URL" in key,
        "public first-response boundary missing",
    )
    need(
        "NOT OBSERVED" in read("DEVICE-GATE.md")
        and "actual physical objects" in read("CURRICULUM-CROSSWALK.md").lower(),
        "actual system/object boundary missing",
    )
    print(
        "PASS ten25m starts, thirty routes, twenty worked choices, ten home/supplements and fresh order/key separation"
    )


def assets():
    alternatives = read("print/TEXT-ALTERNATIVES.md")
    for stem in STEMS:
        top = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        need(
            top.get("width") == "210mm" and top.get("height") == "297mm",
            "SVG A4 drift " + stem,
        )
        info = subprocess.check_output(
            ["pdfinfo", str(ROOT / "print" / f"{stem}.pdf")], text=True
        )
        words = " ".join(
            subprocess.check_output(
                ["pdftotext", str(ROOT / "print" / f"{stem}.pdf"), "-"], text=True
            ).split()
        )
        need(
            re.search(r"Pages:\s+1\b", info) and "(A4)" in info,
            "actual PDF page drift " + stem,
        )
        for node in top.findall(".//s:text", NS):
            value = "".join(node.itertext())
            size = int(node.get("font-size"))
            suffix = "-Bold" if node.get("font-weight") == "700" else ""
            font = ImageFont.truetype(
                f"/usr/share/fonts/truetype/dejavu/DejaVuSans{suffix}.ttf", size
            )
            need(
                value in words and value in alternatives,
                "exact PDF/linear words drift " + stem + ":" + value,
            )
            x, y = float(node.get("x")), float(node.get("y"))
            need(
                x >= 35 and x + font.getlength(value) <= 759 and 35 <= y <= 1085,
                "actual page text overflow " + stem + ":" + value,
            )
        need(
            f"{stem}.svg" in alternatives and f"{stem}.pdf" in alternatives,
            "asset alternative link missing " + stem,
        )
    for stem, activities, keys in [
        ("practice-plan", ["READ", "MAKE", "REST"], None),
        ("check-g", ["MAKE", "REST", "READ"], ["REST", "READ", "MAKE"]),
        ("check-h", ["REST", "MAKE", "MAKE"], ["MAKE", "REST", "READ"]),
    ]:
        root = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        rows = root.findall(".//s:rect[@data-position]", NS)
        need(
            [r.get("data-position") for r in rows] == ["FIRST", "NEXT", "LAST"]
            and [r.get("data-activity") for r in rows] == activities,
            "drawn source records drift " + stem,
        )
        need(
            [g.get("data-picture") for g in root.findall(".//s:g[@data-picture]", NS)]
            == activities,
            "drawn source picture correspondence drift " + stem,
        )
        if keys is not None:
            need(
                [r.get("data-key") for r in root.findall(".//s:rect[@data-key]", NS)]
                == keys,
                "drawn fresh key drift " + stem,
            )
            need(
                [g.get("data-symbol") for g in root.findall(".//s:g[@data-symbol]", NS)]
                == ["circle", "triangle", "square"],
                "drawn key shapes drift " + stem,
            )
    for stem in ("own-strip", "match-forms", "reader-note"):
        root = ET.parse(ROOT / "print" / f"{stem}.svg").getroot()
        need(
            len(root.findall(".//s:rect[@data-blank]", NS)) == 3,
            "blank response spaces drift " + stem,
        )
    need(
        "untagged" in alternatives and "tactile" in alternatives,
        "PDF/access scope missing",
    )
    with tempfile.TemporaryDirectory(prefix="subjectnest-three-things-print-") as temp:
        subprocess.run(
            [sys.executable, str(ROOT / "print/generate_print.py"), "--output", temp],
            check=True,
            capture_output=True,
        )
        for stem in STEMS:
            for suffix in ("svg", "pdf"):
                need(
                    (Path(temp) / f"{stem}.{suffix}").read_bytes()
                    == (ROOT / "print" / f"{stem}.{suffix}").read_bytes(),
                    "temporary byte reproduction drift " + stem + "." + suffix,
                )
    print(
        "PASS eight actual A4 PDFs/exact words/bounds/blank fields/source geometry and16 genuine temporary byte rebuilds"
    )


def helper():
    html = read("interactive/three-things.html")
    need(
        len(re.findall(r'<select id="(?:first|next|last)"', html)) == 3
        and html.count('<option value="">Choose one</option>') == 3,
        "helper blank native choices missing",
    )
    need(
        all(html.count(f'<option value="{a}">') == 3 for a in ("READ", "MAKE", "REST")),
        "helper choice inventory drift",
    )
    for value in (
        "Circle means READ in this key.",
        "Triangle means MAKE in this key.",
        "Square means REST in this key.",
        "<noscript>",
        "PAPER-EQUIVALENT.md",
        'aria-live="polite"',
        "Clear our choices",
    ):
        need(value in html, "helper meaning/access missing " + value)
    scripts = "\n".join(re.findall(r"<script>(.*?)</script>", html, re.DOTALL))
    need(
        not re.search(
            r"\b(?:fetch|XMLHttpRequest|WebSocket|localStorage|sessionStorage|indexedDB|navigator|document\.cookie)\b",
            scripts,
        ),
        "outside network/storage API",
    )
    need(not re.search(r'(?:src|href)="https?://', html), "outside helper dependency")
    need(
        "completed routine" in html
        and "actual object representation" in html
        and "no system" not in html,
        "helper proposal/evidence scope missing",
    )
    print(
        "PASS helper native blank controls/exact keys/no held source/network/storage and complete noJS paper markup"
    )


def slug(value):
    return re.sub(r"[^\w -]", "", re.sub(r"[*_]", "", value).strip().lower()).replace(
        " ", "-"
    )


def links(bootstrap=False):
    count = 0
    for path in ROOT.rglob("*.md"):
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", path.read_text()):
            if url.startswith(("https://", "http://", "mailto:")):
                continue
            filename, marker, anchor = unquote(url).partition("#")
            target = (path.parent / filename).resolve() if filename else path
            if bootstrap and target == ROOT / "manifest.json":
                count += 1
                continue
            need(
                target.is_file(),
                "broken local link " + str(path.relative_to(ROOT)) + ":" + url,
            )
            if marker and target.suffix == ".md":
                need(
                    anchor
                    in {
                        slug(h)
                        for h in re.findall(
                            r"^#+ (.+)$", target.read_text(), re.MULTILINE
                        )
                    },
                    "broken heading link " + url,
                )
            count += 1
    print(f"PASS {count} local file/heading links")


def manifest():
    return {
        "schema": "subjectnest-authored-pack-manifest-v1",
        "pack": "foundation/digital-technologies/term-2/weeks-11-12",
        "created_at": "2026-09-30",
        "review_status": "author_desk_checked_pending_independent_educator_child_local_access_review",
        "curriculum_source_sha256": SHA,
        "curriculum_codes_partial_conditional": sorted(ROWS),
        "files": {
            str(p.relative_to(ROOT)): {"sha256": digest(p), "bytes": p.stat().st_size}
            for p in sorted(ROOT.rglob("*"))
            if p.is_file()
            and p.name != "manifest.json"
            and not {"__pycache__", ".ruff_cache"}.intersection(p.parts)
        },
    }


def negatives():
    global ROOT
    original = ROOT
    cases = [
        (
            "crosswalk wording",
            "CURRICULUM-CROSSWALK.md",
            "represent data as objects, pictures and symbols",
            "represent data only as pictures",
            sources,
            "exact crosswalk",
        ),
        (
            "fresh source order",
            "STUDENT-CHECKS.md",
            "| LAST | MAKE |",
            "| LAST | READ |",
            teaching,
            "fresh source order",
        ),
        (
            "held leak",
            "MATERIALS.md",
            "One small plan",
            "One small plan PLAN-G-11",
            teaching,
            "held source leaked",
        ),
        ("timing", "LESSONS.md", "· 7 min.**", "· 9 min.**", teaching, "week/timing"),
        (
            "source illustration",
            "print/check-h.svg",
            'data-activity="MAKE"',
            'data-activity="READ"',
            assets,
            "drawn source records",
        ),
        (
            "actual page overflow",
            "print/own-strip.svg",
            'x="42" y="65"',
            'x="780" y="65"',
            assets,
            "actual page text overflow",
        ),
        (
            "network API",
            "interactive/three-things.html",
            "const keys=",
            'fetch("https://example.invalid"); const keys=',
            helper,
            "outside network/storage",
        ),
    ]
    for label, name, old, new, operation, expected in cases:
        with tempfile.TemporaryDirectory(
            prefix="subjectnest-three-things-fault-"
        ) as temp:
            shutil.copytree(original, Path(temp) / "pack")
            ROOT = Path(temp) / "pack"
            try:
                path = ROOT / name
                text = path.read_text()
                need(old in text, "fault target missing " + label)
                path.write_text(text.replace(old, new, 1))
                try:
                    operation()
                except AssertionError as error:
                    need(
                        expected in str(error),
                        "wrong rejection " + label + ":" + str(error),
                    )
                else:
                    raise AssertionError("accepted fault " + label)
            finally:
                ROOT = original
        print("PASS temporary fault rejected: " + label)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-manifest", action="store_true")
    parser.add_argument("--negative-checks", action="store_true")
    args = parser.parse_args()
    need(
        not (args.write_manifest and args.negative_checks),
        "renewal/fault modes must be separate",
    )
    before = state()
    sources()
    teaching()
    assets()
    helper()
    links(args.write_manifest)
    expected = manifest()
    if args.write_manifest:
        (ROOT / "manifest.json").write_text(json.dumps(expected, indent=2) + "\n")
        print(f"WROTE {len(expected['files'])} resource hashes")
    else:
        need(
            json.loads(read("manifest.json")) == expected, "SHA manifest missing/stale"
        )
        if args.negative_checks:
            negatives()
        need(state() == before, "read-only audit altered authored bytes/size/mtime")
        print(
            f"PASS {len(expected['files'])} hashes; all authored bytes/size/mtime preserved"
        )
    print(
        "PACK PASS · software/author desk review only; no human learner or classroom trial"
    )


if __name__ == "__main__":
    main()
