#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Verify released Queensland Year 11 pathways and their local hash receipt.

Run from anywhere: python3 products/curriculum-studio/content/year-11/verify_pack.py
After a reviewed edit, regenerate the receipt with --write-manifest.
This checks our files, not QCAA approval, live legal rates or classroom outcomes.
"""

from __future__ import annotations

import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import unquote
from xml.etree import ElementTree

ROOT = Path(__file__).resolve().parent
MANIFEST = ROOT / "manifest.json"
BASE = ROOT / "term-1/weeks-01-02"
CHILD_PACKS = (
    ROOT / "english/term-1/weeks-03-04",
    ROOT / "mathematics/term-1/weeks-03-04",
    ROOT / "integrated/term-1/weeks-03-04",
    ROOT / "english/term-1/weeks-05-06",
    ROOT / "mathematics/term-1/weeks-05-06",
    ROOT / "integrated/term-1/weeks-05-06",
    ROOT / "biology/term-1/weeks-01-02",
    ROOT / "business/term-1/weeks-01-02",
    ROOT / "accounting/term-1/weeks-01-02",
    ROOT / "ancient-history/term-1/weeks-01-02",
    ROOT / "chemistry/term-1/weeks-01-02",
    ROOT / "design/term-1/weeks-01-02",
    ROOT / "digital-solutions/term-1/weeks-01-02",
    ROOT / "economics/term-1/weeks-01-02",
    ROOT / "geography/term-1/weeks-01-02",
    ROOT / "health/term-1/weeks-01-02",
    ROOT / "legal-studies/term-1/weeks-01-02",
    ROOT / "modern-history/term-1/weeks-01-02",
    ROOT / "mathematical-methods/term-1/weeks-01-02",
    ROOT / "specialist-mathematics/term-1/weeks-01-02",
    ROOT / "visual-art/term-1/weeks-01-02",
    ROOT / "music/term-1/weeks-01-02",
    ROOT / "drama/term-1/weeks-01-02",
    ROOT / "dance/term-1/weeks-01-02",
    ROOT / "physics/term-1/weeks-01-02",
    ROOT / "essential-english/term-1/weeks-01-02",
    ROOT / "essential-mathematics/term-1/weeks-01-02",
)
QCAA_ENGLISH = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_english_25_syll.pdf"
QCAA_MATHS = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_maths_general_25_syll.pdf"
QCAA_BIOLOGY = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_biology_25_syll.pdf"
QCAA_BUSINESS = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_business_25_syll.pdf"
QCAA_ACCOUNTING = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_accounting_25_syll.pdf"
QCAA_ANCIENT_HISTORY = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_ancient_history_25_syll.pdf"
QCAA_CHEMISTRY = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_chemistry_25_syll.pdf"
QCAA_DESIGN = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_design_25_syll.pdf"
QCAA_DIGITAL_SOLUTIONS = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_digital_solutions_25_syll.pdf"
QCAA_ECONOMICS = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_economics_25_syll.pdf"
QCAA_GEOGRAPHY = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_geography_25_syll.pdf"
QCAA_HEALTH = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_health_25_syll.pdf"
QCAA_LEGAL_STUDIES = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_legal_25_syll.pdf"
QCAA_MODERN_HISTORY = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_modern_history_25_syll.pdf"
QCAA_METHODS = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_maths_methods_25_syll.pdf"
QCAA_SPECIALIST = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_maths_specialist_25_syll.pdf"
QCAA_VISUAL_ART = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_visual_art_25_syll.pdf"
QCAA_MUSIC = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_music_25_syll.pdf"
QCAA_DRAMA = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_drama_25_syll.pdf"
QCAA_DANCE = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_dance_25_syll.pdf"
QCAA_PHYSICS = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_physics_25_syll.pdf"
QCAA_ESSENTIAL_ENGLISH = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_ess_english_25_app_syll.pdf"
QCAA_ESSENTIAL_MATHS = "https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_ess_maths_25_app_syll.pdf"
QCAA_HANDBOOK = "https://www.qcaa.qld.edu.au/senior/certificates-and-qualifications/qce-qcia-handbook/4-qld-curriculum/4.1-syllabuses"
LESSONS = {
    BASE / "english/LESSONS.md": (list(range(1, 11)), 25),
    BASE / "mathematics/LESSONS.md": (list(range(1, 11)), 25),
    BASE / "integrated/week-01.md": (list(range(1, 6)), 35),
    BASE / "integrated/week-02.md": (list(range(1, 6)), 35),
}


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def assets() -> 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 and ".pytest_cache" not in p.parts
        and (p.parent == ROOT or p.is_relative_to(BASE)
             or any(p.is_relative_to(pack) for pack in CHILD_PACKS))
    )


def rights(path: Path) -> str:
    if "DEJAVU" in path.name.upper():
        return "Third-party DejaVu font licence/copyright notice; excluded from SubjectNest original-content grant"
    if path.name == "CODE-LICENSE.txt":
        return "Apache-2.0 notice for original SubjectNest code"
    if path.name == "FONT-RIGHTS.md":
        return "Original font-rights explanation with separate third-party DejaVu terms"
    if path.suffix == ".pdf":
        return "Original SubjectNest visual design CC BY 4.0; embedded DejaVu font subsets under separate FONT-RIGHTS.md licence"
    if path.suffix in {".py", ".js"}:
        return "Original SubjectNest code under Apache-2.0, subject to the pack code notice"
    if path.suffix == ".html":
        return "Original SubjectNest interactive presentation CC BY 4.0; embedded code under the pack code notice"
    if path.suffix == ".json":
        return "Original source/provenance metadata; official QCAA facts are attributed separately"
    if path.suffix == ".xlsx":
        return "Original SubjectNest offline worksheet under CC BY 4.0; fictional data and visible formulas"
    if path.suffix == ".wav":
        return "Original SubjectNest generated sound demonstration CC BY 4.0; exact text score supplied separately"
    return "Original SubjectNest material CC BY 4.0; QCAA syllabus material is linked/paraphrased and retains QCAA rights"


def expected_manifest() -> dict:
    return {
        "pack": "SubjectNest Queensland Year 11 General English, Mathematics and optional integrated six-week starter with twenty-one separate subject pathway starters",
        "version": "0.19.0-draft",
        "status": "General English/Mathematics Weeks 1-6, optional integrated Weeks 1-6; twenty-one separate subject pathway starters Weeks 1-2; later weeks plan-only; local subject, access, privacy, cultural and assessment review pending",
        "publisher": "NeuroForgeIO Pty Ltd",
        "source_reviewed_at": "2026-09-29",
        "jurisdiction": "Queensland, Australia only",
        "pathways": ["QCAA General English 2025 v1.3", "QCAA General Mathematics 2025 v1.3", "QCAA General Accounting 2025 v1.4", "QCAA General Ancient History 2025 v1.4", "QCAA General Business 2025 v1.3", "QCAA General Biology 2025 v1.3", "QCAA General Chemistry 2025 v1.3", "QCAA General Design 2025 v1.3", "QCAA General Digital Solutions 2025 v1.4", "QCAA General Economics 2025 v1.4", "QCAA General Geography 2025 v1.3", "QCAA General Health 2025 v1.3", "QCAA General Legal Studies 2025 v1.3", "QCAA General Modern History 2025 v1.4", "QCAA General Physics 2025 v1.3", "QCAA General Mathematical Methods 2025 v1.3", "QCAA General Specialist Mathematics 2025 v1.4", "QCAA General Visual Art 2025 v1.3", "QCAA General Music 2025 v1.3", "QCAA General Drama 2025 v1.3", "QCAA General Dance 2025 v1.3", "QCAA Applied Essential English 2025 v1.3", "QCAA Applied Essential Mathematics 2025 v1.3"],
        "source_refs": {
            "ACC-U1-T1": {
                "official_title": "Introduction to accounting",
                "url": QCAA_ACCOUNTING,
                "location": "Unit 1 Topic 1, printed page 12; 250-minute fictional starter only, Topic 2 and full unit open",
            },
            "ENG-U1": {
                "official_title": "Perspectives and texts",
                "url": QCAA_ENGLISH,
                "location": "Unit 1, PDF pages 19-22; subject content is paraphrased, not reproduced",
            },
            "GM-U1-T1": {
                "official_title": "Consumer arithmetic",
                "url": QCAA_MATHS,
                "location": "Unit 1 Topic 1, PDF page 17; selected introductory content only",
            },
            "QCE-SCOPE": {
                "official_title": "Syllabuses",
                "url": QCAA_HANDBOOK,
                "location": "QCE handbook section 4.1; unit duration and sequencing boundary",
            },
            "AH-U1-T1": {
                "official_title": "Digging up the past",
                "url": QCAA_ANCIENT_HISTORY,
                "location": "Unit 1 Topic 1, printed pages 15-17; Budj Bim Ancient Australia site example only, Topic 2 and full unit open",
            },
            "BUS-U1-T1": {
                "official_title": "Fundamentals of business",
                "url": QCAA_BUSINESS,
                "location": "Unit 1 Topic 1, printed pages 13-15; fictional practice, authentic cases still required",
            },
            "BIO-U1-T1": {
                "official_title": "Cells as the basis of life",
                "url": QCAA_BIOLOGY,
                "location": "Unit 1 Topic 1, printed pages 20-22; selected model-based concepts only",
            },
            "CHEM-U1-T1": {
                "official_title": "Properties and structure of atoms",
                "url": QCAA_CHEMISTRY,
                "location": "Unit 1 Topic 1, printed pages 20-22; selected atomic and isotope concepts only",
            },
            "DES-U1-T1": {
                "official_title": "Designing for others",
                "url": QCAA_DESIGN,
                "location": "Unit 1 Topic 1, printed pages 13-17; fictional briefs, real stakeholder work still required",
            },
            "DS-U1-T1": {
                "official_title": "Understanding digital problems",
                "url": QCAA_DIGITAL_SOLUTIONS,
                "location": "Unit 1 Topic 1, printed pages 15-16; QCAA 2025 v1.4, selected web-application context",
            },
            "ECON-U1-T1": {
                "official_title": "The basic economic problem",
                "url": QCAA_ECONOMICS,
                "location": "Unit 1 Topic 1, printed pages 14-15; QCAA 2025 v1.4, fictional frontier practice with dated real-data capsules; current context and real-system comparison open",
            },
            "GEO-U1-T1": {
                "official_title": "Natural hazard zones",
                "url": QCAA_GEOGRAPHY,
                "location": "Unit 1 Topic 1, printed pages 14-16; invented map practice only, mandatory real two-location case study open",
            },
            "HEALTH-U1-S1": {
                "official_title": "Define and understand resilience as a personal health resource",
                "url": QCAA_HEALTH,
                "location": "Unit 1 Stage 1, printed pages 12-16; no numbered Topic 1, no personal health assessment",
            },
            "LEGAL-U1-T1": {
                "official_title": "Legal foundations",
                "url": QCAA_LEGAL_STUDIES,
                "location": "Unit 1 Topic 1, printed pages 11-12; fictional legal scenarios, current enacted law and community partnership still needed",
            },
            "MH-U1-T4": {
                "official_title": "American Revolution, 1763–1783",
                "url": QCAA_MODERN_HISTORY,
                "location": "Unit 1 optional Topic 4, printed pages 15-17; school topic choice, First Nations and Asia course requirements open",
            },
            "PHYS-U1-T1": {
                "official_title": "Heating processes",
                "url": QCAA_PHYSICS,
                "location": "Unit 1 Topic 1, printed pages 19-21; selected heating-process models only",
            },
            "EE-U1": {
                "official_title": "Language that works",
                "url": QCAA_ESSENTIAL_ENGLISH,
                "location": "Unit 1, PDF pages 17-20 / printed pages 15-18; no numbered Topic 1; selected content only",
            },
            "SM-U1-T1": {
                "official_title": "Combinatorics",
                "url": QCAA_SPECIALIST,
                "location": "Unit 1 Topic 1, printed pages 16-17; first 4-hour counting-techniques subtopic only; permutations/combinations open",
            },
            "VA-U1": {
                "official_title": "Art as lens",
                "url": QCAA_VISUAL_ART,
                "location": "Unit 1, printed pages 22-26; Developing, Researching, Reflecting and Resolving sampled; no numbered Topic 1; full folio and unit open",
            },
            "MUS-U1": {
                "official_title": "Designs",
                "url": QCAA_MUSIC,
                "location": "Unit 1, printed pages 20-22; Area of study Designs sampled; no numbered Topic 1; varied repertoire, performance and unit open",
            },
            "DRA-U1": {
                "official_title": "Share",
                "url": QCAA_DRAMA,
                "location": "Unit 1, printed pages 21-23; inquiry Area of study sampled; no numbered Topic 1; published theatre, cultural contexts, acting and unit open",
            },
            "DAN-U1": {
                "official_title": "Moving bodies",
                "url": QCAA_DANCE,
                "location": "Unit 1, printed pages 22-26; inquiry Area of study sampled; no numbered Topic 1; authentic repertoire, genre history, technique, performance and unit open",
            },
            "MM-U1-T1": {
                "official_title": "Surds and quadratic functions",
                "url": QCAA_METHODS,
                "location": "Unit 1 Topic 1, printed pages 15-16; partial surd and quadratic introduction",
            },
            "EM-U1-T1-R": {
                "official_title": "Ratios",
                "url": QCAA_ESSENTIAL_MATHS,
                "location": "Unit 1 Topic 1, PDF page 17 / printed page 15; selected ratio content only",
            },
        },
        "rights_note": "Original lesson/assessment/text/data/SVG design CC BY 4.0, with attribution, licence link and change notice. PDF embedded DejaVu subsets retain separate Bitstream Vera licence in FONT-RIGHTS.md. QCAA documents retain QCAA rights. No third-party images, audio or translations supplied.",
        "files": [
            {"path": p.relative_to(ROOT).as_posix(), "sha256": digest(p.read_bytes()), "rights": rights(p)}
            for p in assets()
        ],
    }


def check_lessons() -> int:
    count = 0
    for path, (days, total) in LESSONS.items():
        content = path.read_text(encoding="utf-8")
        headers = list(re.finditer(r"^## Day (\d+)\b", content, re.MULTILINE))
        found = [int(h.group(1)) for h in headers]
        assert found == days, f"{path}: day order {found}, expected {days}"
        for i, header in enumerate(headers):
            end = headers[i + 1].start() if i + 1 < len(headers) else len(content)
            block = content[header.end():end]
            steps = [int(x) for x in re.findall(r"^\d+\. \*\*[^\n*]*? · (\d+) min\.\*\*", block, re.MULTILINE)]
            assert len(steps) == 6 and sum(steps) == total, (path, found[i], steps)
            assert "**Exit ·" in block, (path, found[i], "missing keyed exit")
            if total == 25:
                assert "**Success:**" in block, (path, found[i], "missing success")
            count += 1
    return count


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


def check_links() -> int:
    count = 0
    for path in (asset for asset in assets() if asset.suffix == ".md"):
        for target in re.findall(r"\[[^\]]+\]\(([^)]+)\)", path.read_text(encoding="utf-8")):
            if re.match(r"^[a-z]+://", target) or target.startswith("mailto:"):
                continue
            local, _, anchor = unquote(target).partition("#")
            resolved = (path.parent / local).resolve() if local else path.resolve()
            assert resolved.is_file() and ROOT in resolved.parents, f"broken/out-of-pack link {path}: {target}"
            if anchor and resolved.suffix.lower() == ".md":
                headings = re.findall(r"^#{1,6} +(.+?) *$", resolved.read_text(encoding="utf-8"), re.MULTILINE)
                assert anchor in {heading_slug(h) for h in headings}, f"broken anchor {path}: {target}"
            count += 1
    return count


def check_source_scope() -> None:
    ledger = (ROOT / "SOURCES-AND-REVIEW.md").read_text(encoding="utf-8")
    intro = (ROOT / "README.md").read_text(encoding="utf-8")
    all_text = "\n".join(p.read_text(encoding="utf-8") for p in assets() if p.suffix == ".md")
    for token in (QCAA_ENGLISH, QCAA_MATHS, QCAA_ACCOUNTING, QCAA_ANCIENT_HISTORY, QCAA_BUSINESS, QCAA_BIOLOGY, QCAA_CHEMISTRY, QCAA_DESIGN, QCAA_DIGITAL_SOLUTIONS, QCAA_ECONOMICS, QCAA_GEOGRAPHY, QCAA_HEALTH, QCAA_LEGAL_STUDIES, QCAA_MODERN_HISTORY, QCAA_PHYSICS, QCAA_METHODS, QCAA_SPECIALIST, QCAA_VISUAL_ART, QCAA_MUSIC, QCAA_DRAMA, QCAA_DANCE, QCAA_ESSENTIAL_ENGLISH, QCAA_ESSENTIAL_MATHS,
                  QCAA_HANDBOOK, "2025 v1.3", "ENG-U1", "GM-U1-T1"):
        assert token in ledger, f"source ledger missing {token}"
    assert "a generic national Year 11 course" in intro
    assert "Weeks 7–40" in (ROOT / "year-sequence.md").read_text(encoding="utf-8")
    assert not re.search(r"\bAC9[A-Z0-9]{5,}\b", all_text), "senior AC9 code claim found"
    assert "not official QCAA codes" in ledger
    assert "No translations" in (BASE / "README.md").read_text(encoding="utf-8")


def check_learner_release() -> None:
    english = BASE / "english"
    maths = BASE / "mathematics"
    everyday_english = (english / "LEARNER-TEXTS.md").read_text(encoding="utf-8")
    everyday_maths = (maths / "LEARNER-CARDS.md").read_text(encoding="utf-8")
    for folder in (english, maths):
        for day in ("05", "10"):
            card = folder / f"DAY-{day}-CHECK.md"
            assert card.is_file() and "Learner copy" in card.read_text(encoding="utf-8"), card
    # Day 10 must use material not rehearsed in either everyday learner set.
    assert "Story Shelf" not in everyday_english + everyday_maths
    assert "Story Shelf" in (english / "DAY-10-CHECK.md").read_text(encoding="utf-8")
    assert "Text D" not in everyday_english
    assert "$28" not in everyday_maths and "Art Relay" not in everyday_maths
    for forbidden in ("Teacher source note", "**Key:**", "Model annotation"):
        assert forbidden not in everyday_english + everyday_maths, forbidden
    assert "Teacher copy" in (english / "MATERIALS.md").read_text(encoding="utf-8")
    assert "Teacher copy" in (maths / "MATERIALS.md").read_text(encoding="utf-8")


def check_print() -> tuple[int, int]:
    folder = BASE / "print"
    svgs = sorted(folder.glob("*.svg"))
    pdfs = sorted(folder.glob("*.pdf"))
    assert len(svgs) == len(pdfs) == 3, "expected exactly three paired visual aids"
    assert "Bitstream Vera" in (folder / "FONT-RIGHTS.md").read_text(encoding="utf-8")
    alternatives = (folder / "TEXT-ALTERNATIVES.md").read_text(encoding="utf-8")
    for phrase in ("$120", "$189.80", "$191", "unknown", "private diary"):
        assert phrase in alternatives, f"text equivalent missing {phrase}"
    for svg in svgs:
        tree = ElementTree.parse(svg)
        root = tree.getroot()
        ns = "{http://www.w3.org/2000/svg}"
        assert (root.get("width"), root.get("height")) == ("297mm", "210mm"), svg
        assert root.get("viewBox") and root.get("role") == "img", svg
        assert root.get("aria-labelledby") == "title desc", svg
        assert root.find(f"{ns}title") is not None and root.find(f"{ns}desc") is not None, svg
        assert "CC BY 4.0" in svg.read_text(encoding="utf-8"), svg
        pdf = svg.with_suffix(".pdf")
        assert pdf.read_bytes().startswith(b"%PDF"), pdf
        info = subprocess.run(["pdfinfo", str(pdf)], check=True, capture_output=True, text=True).stdout
        assert re.search(r"^Pages:\s+1\s*$", info, re.MULTILINE), pdf
        assert "Page size:       841.89 x 595.276 pts (A4)" in info, pdf
        fonts = subprocess.run(["pdffonts", str(pdf)], check=True, capture_output=True, text=True).stdout
        assert "DejaVuSans" in fonts and "yes yes yes" in fonts, pdf
    return len(svgs), len(pdfs)


def check_calculations() -> None:
    assert 24 * 1.25 == 30 and 5 * 24 == 4 * 30 == 120
    assert 24 * 1.5 == 36 and 7 * 24 + 2 * 36 == 240
    assert 7 * 24 + 1 * 36 == 204 and 7 * 24 + 3 * 36 == 276
    assert 3 * 28 == 84 and 4 * (28 * 1.25) == 140 and 140 - 84 == 56
    assert 80 * .15 == 12 and 80 - 12 == 68
    assert 45 * .2 == 9 and 45 - 9 == 36
    assert 18.40 / 8 == 2.3 and 27 / 12 == 2.25
    assert round(3 * 18.40, 2) == 55.20 and 2 * 27 == 54
    assert round(3 * 18.40 - 2 * 27, 2) == 1.20
    assert round(27 * 1.10, 2) == 29.70 and round(2 * 29.70 - 55.20, 2) == 4.20
    assert 120 + 70 + 45 == 235 and 480 - 235 == 245
    assert round(480 - 235 - 55.20, 2) == 189.80 and 480 - 235 - 54 == 191
    assert 31.50 / 15 == 2.10 and 22 / 10 == 2.20
    assert 3 * 22 == 66 and 2 * 31.50 == 63
    assert round(31.50 * 1.10, 2) == 34.65 and round(2 * 34.65, 2) == 69.30
    assert round(13.20 / 6, 2) == 2.20 and round(19.35 / 9, 2) == 2.15
    assert round(19.35 - 13.20, 2) == 6.15
    assert round(3 * 13.20, 2) == 39.60 and round(2 * 19.35, 2) == 38.70
    assert round(39.60 - 38.70, 2) == .90


def check_clean_files() -> None:
    for path in assets():
        assert path.name == ".gitignore" or path.suffix in {".md", ".py", ".svg", ".pdf", ".json", ".txt", ".xlsx", ".html", ".js", ".sha256", ".wav"}, f"unexpected asset {path}"
        if path.name == ".gitignore" or path.suffix in {".md", ".py", ".svg", ".json", ".html", ".js", ".sha256"}:
            data = path.read_text(encoding="utf-8")
            assert not any(line.rstrip(" \t") != line for line in data.splitlines()), f"trailing whitespace {path}"


def check_child_packs() -> None:
    for pack in CHILD_PACKS:
        verifier = pack / "verify_pack.py"
        assert verifier.is_file(), f"missing subject pack verifier: {verifier}"
        result = subprocess.run([sys.executable, str(verifier)], cwd=pack,
                                capture_output=True, text=True, check=False)
        assert result.returncode == 0, f"subject pack failed: {pack.relative_to(ROOT)}: {result.stdout} {result.stderr}"


def main() -> None:
    lessons = check_lessons()
    links = check_links()
    check_source_scope()
    check_learner_release()
    svgs, pdfs = check_print()
    check_calculations()
    check_clean_files()
    check_child_packs()
    expected = expected_manifest()
    if "--write-manifest" in sys.argv[1:]:
        MANIFEST.write_text(json.dumps(expected, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    else:
        actual = json.loads(MANIFEST.read_text(encoding="utf-8"))
        assert actual == expected, "manifest drift; review changes before --write-manifest"
    print(f"PASS: {lessons} opening timed lessons (20 subject + 10 optional integrated) plus {len(CHILD_PACKS)} verified subject/pathway packs, {links} internal links, {svgs} opening SVG/{pdfs} PDF aids, source/rights scope, arithmetic and {len(expected['files'])} file hashes")


if __name__ == "__main__":
    main()
