# SPDX-License-Identifier: Apache-2.0
"""Verify this play pack's integrity, useful structure and actual reproduction.

Checks are authoring evidence, not proof of learner access or effectiveness.
The default is read-only; --refresh-manifest is a deliberate authored write.
"""

from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import re
import sys
import tempfile
import xml.etree.ElementTree as ET
from html.parser import HTMLParser
from pathlib import Path
from types import ModuleType

from pypdf import PdfReader

HERE = Path(__file__).resolve().parent
CACHE_PARTS = {"__pycache__", ".ruff_cache", ".pytest_cache"}
FONT_SHA = "63d3ba759d12804c5b31a9d5940d855c1820d1f5999e6b0872eb1c7ff045fbc9"
PRIMARY = "https://www.acecqa.gov.au/"
SOURCES = (
    PRIMARY + "nqf/national-law-regulations/approved-learning-frameworks",
    PRIMARY + "belonging-being-becoming-early-years-learning-framework",
    PRIMARY
    + "belonging-being-becoming-early-years-learning-framework/eylf-learning-outcomes",
    PRIMARY
    + "belonging-being-becoming-early-years-learning-framework/eylf-learning-outcomes/eylf-learning-outcome-4",
    PRIMARY
    + "belonging-being-becoming-early-years-learning-framework/eylf-learning-outcomes/eylf-learning-outcome-5",
    PRIMARY + "belonging-being-becoming-early-years-learning-framework/eylf-practices",
    PRIMARY + "information-sheet-eylf-practices-play-based-learning-and-intentionality",
)
SLUGS = (
    "01-a-place-for-a-visitor",
    "02-a-visitor-with-a-job",
    "03-make-a-way-through",
    "04-leave-a-little-message",
    "05-a-small-world-problem",
    "06-sort-for-a-purpose",
    "07-the-place-changes",
    "08-a-guide-for-a-new-visitor",
    "09-two-ways-to-play",
    "10-keep-change-or-start-again",
)


def require(condition: bool, message: str) -> None:
    """Reject a concrete failed gate even when Python assertions are disabled."""
    if not condition:
        raise ValueError(message)


def resources() -> list[Path]:
    """List actual pack resources outside caches and the manifest."""
    return sorted(
        p
        for p in HERE.rglob("*")
        if p.is_file()
        and p.name != "manifest.json"
        and not CACHE_PARTS.intersection(p.relative_to(HERE).parts)
    )


def sha(path: Path) -> str:
    """Return the SHA-256 of the exact stored bytes."""
    return hashlib.sha256(path.read_bytes()).hexdigest()


def state() -> dict:
    """Capture exact authored bytes, sizes and modification times."""
    return {
        str(p.relative_to(HERE)): (sha(p), p.stat().st_size, p.stat().st_mtime_ns)
        for p in HERE.rglob("*")
        if p.is_file() and not CACHE_PARTS.intersection(p.relative_to(HERE).parts)
    }


def refresh() -> None:
    """Deliberately bind reviewed resources and their separate rights."""

    def rights(path: Path) -> str:
        """Identify the licence for this original or upstream resource role."""
        if path.name == "DEJAVU-FONT-LICENSE.txt":
            return "Separate upstream font notice; see this exact file and RIGHTS.md"
        if path.suffix in {".py", ".cjs"} or path.name == "CODE-LICENSE.txt":
            return "Apache-2.0"
        if path.suffix == ".html":
            return "Apache-2.0 code; CC-BY-4.0 original embedded content/artwork"
        return "CC-BY-4.0 original learning materials/commentary"

    payload = {
        "title": "Small world, big ideas",
        "status": "original authoring draft; human/local outcome review open",
        "primary_sources": list(SOURCES),
        "files": {
            str(p.relative_to(HERE)): {
                "sha256": sha(p),
                "bytes": p.stat().st_size,
                "rights": rights(p),
            }
            for p in resources()
        },
    }
    (HERE / "manifest.json").write_text(json.dumps(payload, indent=2) + "\n")


class Links(HTMLParser):
    def __init__(self) -> None:
        """Prepare a read-only collection of local document targets."""
        super().__init__()
        self.targets: list[str] = []

    def handle_starttag(self, tag: str, attrs: list[tuple]) -> None:
        """Collect actual links and assets from a document tag."""
        values = dict(attrs)
        for name in ("href", "src"):
            if name in values:
                self.targets.append(values[name])


def check_links() -> int:
    """Resolve actual local files and fragments inside the pack."""
    count = 0
    for path in resources():
        if path.suffix not in {".md", ".html"}:
            continue
        text = path.read_text()
        links = re.findall(r"\]\(([^)]+)\)", text) if path.suffix == ".md" else []
        if path.suffix == ".html":
            parser = Links()
            parser.feed(text)
            links += parser.targets
        for link in links:
            if link.startswith(("https://", "http://", "#", "mailto:")):
                continue
            target = (path.parent / link.split("#", 1)[0]).resolve()
            require(target.is_relative_to(HERE), f"Link escapes pack: {link}")
            require(target.is_file(), f"Broken local link in {path.name}: {link}")
            if "#" in link:
                fragment = link.split("#", 1)[1]
                target_text = target.read_text()
                ids = set(re.findall(r"id=[\"\']([^\"\']+)[\"\']", target_text))
                for heading in re.findall(r"^#+ (.+)$", target_text, re.MULTILINE):
                    ids.add(re.sub(r"[^\w\s-]", "", heading.lower()).replace(" ", "-"))
                require(
                    fragment in ids, f"Broken local fragment in {path.name}: {link}"
                )
            count += 1
    return count


def check_model(data: dict) -> None:
    """Check concrete play entries and important evidence limits."""
    starts = data.get("starts", [])
    require(len(starts) == 10, "Need ten complete play starts")
    require([s.get("id") for s in starts] == list(range(1, 11)), "Start IDs changed")
    require(tuple(s.get("slug") for s in starts) == SLUGS, "Start slug mismatch")
    for start in starts:
        number = start["id"]
        for key in (
            "title",
            "opening",
            "invitation",
            "aim",
            "ready",
            "notice",
            "later",
            "extra",
            "aid",
        ):
            require(bool(start.get(key)), f"Start {number}: missing {key}")
        require(
            len(start["opening"].split()) <= 12, f"Start {number}: opening too long"
        )
        require(len(start.get("steps", [])) == 4, f"Start {number}: four loose steps")
        cursor = 0
        for step in start["steps"]:
            match = re.match(r"(\d+)–(\d+) minutes:", step)
            require(bool(match), f"Start {number}: range clock format")
            first, last = map(int, match.groups())
            require(
                first == cursor and last > first, f"Start {number}: clock gap/overlap"
            )
            cursor = last
        require(cursor == 10, f"Start {number}: loose clock not ten minutes")
        routes = start.get("routes", [])
        require(
            len(routes) == 3 and len(set(routes)) == 3,
            f"Start {number}: three distinct routes",
        )
        for route, label in zip(
            routes,
            (
                "Draw or arrange:",
                "Tell, sign or point:",
                "Small start or no materials:",
            ),
        ):
            require(
                route.startswith(label), f"Start {number}: missing concrete route mode"
            )
            require(len(route.split()) >= 35, f"Start {number}: thin route")
        examples = start.get("examples", [])
        require(
            len(examples) == 2 and len(set(examples)) == 2,
            f"Start {number}: two distinct examples",
        )
        require(
            all(len(x.split()) >= 30 for x in examples), f"Start {number}: thin example"
        )
    require(
        "fictional visitor's reply is not a real user review" in starts[7]["steps"][2],
        "Guide must distinguish fictional and real feedback",
    )
    require(
        "different from observed benefit for a person" in starts[4]["notice"],
        "Plan must remain distinct from observed benefit",
    )
    require("adult's support" in starts[8]["notice"], "Keep adult support attributed")
    require(
        "no catch-up or reward for doing all ten" in starts[9]["routes"][2],
        "No completion/reward gate",
    )


def check_prints(builder: ModuleType) -> int:
    """Inspect actual vector bounds, PDF page sizes and text."""
    for stem, (title, instruction, description) in builder.AIDS.items():
        svg = ET.fromstring((HERE / "print" / f"{stem}.svg").read_bytes())
        require(
            svg.get("width") == "210mm" and svg.get("height") == "297mm",
            f"A4 SVG dimensions: {stem}",
        )
        require(svg.get("viewBox") == "0 0 794 1123", f"SVG viewBox: {stem}")
        require(
            svg.find("{http://www.w3.org/2000/svg}desc").text == description,
            f"Print description: {stem}",
        )
        for elem in svg.iter():
            name = elem.tag.rsplit("}", 1)[-1]
            if name == "rect":
                x, y = float(elem.get("x", 0)), float(elem.get("y", 0))
                w, h = float(elem.get("width", 0)), float(elem.get("height", 0))
                require(
                    x >= 0
                    and y >= 0
                    and w > 0
                    and h > 0
                    and x + w <= 794
                    and y + h <= 1123,
                    f"Print box bounds: {stem}",
                )
            if name == "text":
                require(
                    0 < float(elem.get("x")) < 794 and 0 < float(elem.get("y")) < 1123,
                    f"Text position: {stem}",
                )
        reader = PdfReader(HERE / "print" / f"{stem}.pdf")
        require(len(reader.pages) == 1, f"One-page PDF: {stem}")
        page = reader.pages[0]
        require(
            abs(float(page.mediabox.width) - 595.27559) < 0.02
            and abs(float(page.mediabox.height) - 841.88976) < 0.02,
            f"A4 PDF dimensions: {stem}",
        )
        text = " ".join(page.extract_text().split())
        require(
            title in text and instruction in text and "CC BY 4.0" in text,
            f"Selectable PDF text: {stem}",
        )
    return len(builder.AIDS) * 2


def check_helper() -> None:
    """Check built no-script, privacy and interaction provisions."""
    text = (HERE / "digital" / "index.html").read_text()
    require(
        "__CARD_DATA__" not in text and "__ALL_STARTS__" not in text, "Unbuilt helper"
    )
    require(len(re.findall(r'data-group="', text)) == 9, "Nine helper choice controls")
    require(
        len(re.findall(r'disabled data-group="', text)) == 9,
        "No-script choices must be disabled",
    )
    require(
        "<noscript>" in text and "All ten complete cards" in text,
        "No-script route missing",
    )
    require(
        'id="sceneSummary"' in text and 'aria-live="polite"' in text,
        "Live scene description missing",
    )
    require(
        "same button again" in (HERE / "README.md").read_text(),
        "Clear behaviour not explained",
    )
    require(
        '<meta name="viewport"' in text and "min-height:44px" in text,
        "Responsive control provision missing",
    )
    for forbidden in (
        "localStorage",
        "sessionStorage",
        "fetch(",
        "XMLHttpRequest",
        "document.cookie",
        "navigator.mediaDevices",
        "<iframe",
        "<audio",
        "setInterval(",
    ):
        require(
            forbidden not in text,
            f"Unexpected helper collection/outside/media API: {forbidden}",
        )


def verify() -> dict:
    """Run real artifact checks without changing authored files."""
    before = state()
    data = json.loads((HERE / "play-path.json").read_text())
    check_model(data)
    notes = (HERE / "SOURCE-NOTES.md").read_text()
    require(all(url in notes for url in SOURCES), "Primary source URL/locator changed")
    require(
        "Checked 30 September 2026" in notes and "V2.0 (2022)" in notes,
        "Source date/version changed",
    )
    require(
        "No whole-outcome coverage" in notes and "No remote PDF byte hash" in notes,
        "Framework/source boundary missing",
    )
    require(
        not re.search(r"\bAC9[A-Z0-9]+\b", notes),
        "Preschool pack must not claim school-code coverage",
    )
    require(
        sha(HERE / "print" / "DEJAVU-FONT-LICENSE.txt") == FONT_SHA,
        "Upstream font licence bytes changed",
    )
    require(
        "Apache License" in (HERE / "CODE-LICENSE.txt").read_text(),
        "Code licence missing",
    )
    spec = importlib.util.spec_from_file_location(
        "small_world_builder", HERE / "build_resources.py"
    )
    builder = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(builder)
    prints = check_prints(builder)
    check_helper()
    links = check_links()
    manifest = json.loads((HERE / "manifest.json").read_text())
    actual = {str(p.relative_to(HERE)): p for p in resources()}
    require(
        all(not p.is_symlink() for p in actual.values()), "Unexpected symlink resource"
    )
    require(
        set(actual) == set(manifest["files"]),
        "Manifest membership changed/unlisted file",
    )
    for name, path in actual.items():
        item = manifest["files"][name]
        require(
            sha(path) == item["sha256"] and path.stat().st_size == item["bytes"],
            f"Manifest bytes/hash: {name}",
        )
    with tempfile.TemporaryDirectory(prefix="subjectnest-small-world-verify-") as tmp:
        target = Path(tmp)
        outputs = builder.generate(target)
        for output in outputs:
            require(
                output.read_bytes() == (HERE / output.relative_to(target)).read_bytes(),
                f"Genuine reproduction drift: {output.relative_to(target)}",
            )
    require(
        before == state(), "Read-only verification altered authored bytes/size/mtime"
    )
    return {
        "status": "PASS",
        "play_starts": 10,
        "routes": 30,
        "worked_contexts": 20,
        "later_starts": 10,
        "extras": 10,
        "print_outputs": prints,
        "genuine_rebuilt_outputs": len(outputs),
        "local_links": links,
        "manifest_entries": len(actual),
        "human_or_material_trials": False,
        "framework_complete": False,
    }


def main() -> int:
    """Run the explicit authored or read-only verification mode."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--refresh-manifest", action="store_true")
    args = parser.parse_args()
    if args.refresh_manifest:
        refresh()
        print("Deliberately refreshed this pack's manifest")
        return 0
    try:
        print(json.dumps(verify(), indent=2))
        return 0
    except (ValueError, KeyError, OSError, TypeError) as exc:
        print(f"FAIL: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
