# SPDX-License-Identifier: Apache-2.0
"""Read-only by default: source, lesson, link, A4, SHA and regeneration audit."""

from __future__ import annotations

import contextlib
import io
import argparse
import html
from zipfile import ZipFile
import hashlib
import json
import re
import shutil
import struct
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
from urllib.parse import unquote

from pypdf import PdfReader

ROOT = Path(__file__).resolve().parent
STUDIO = ROOT.parents[4]
SOURCE_SHA = "db446882d2c00cf7c085a03e250e2442fda6c44011fc114680c46c1dc7a822c3"
ROWS = {'AC9SFU03':18532,'AC9SFH01':18542,'AC9SFI01':18550,'AC9SFI02':18556,'AC9SFI03':18562,'AC9SFI04':18569,'AC9SFI05':18574}
OTHER = {'AC9SFU01':18517,'AC9SFU02':18525}
DESCRIPTIONS = {'AC9SFU03':'recognise that objects can be composed of different materials and describe the observable properties of those materials','AC9SFH01':'explore the ways people make and use observations and questions to learn about the natural world','AC9SFI01':'pose questions and make predictions based on experiences','AC9SFI02':'engage in investigations safely and make observations using their senses','AC9SFI03':'represent observations in provided templates and identify patterns with guidance','AC9SFI04':'compare observations with predictions with guidance','AC9SFI05':'share questions, predictions, observations and ideas with others'}
URLS=('https://www.qcaa.qld.edu.au/p-10/aciq/version-9/learning-areas/p-10-science','https://www.qcaa.qld.edu.au/downloads/aciqv9/science/curriculum/ac9_science_prep_as_cd_alignment.pdf','https://www.qcaa.qld.edu.au/downloads/aciqv9/science/assessment/ac9_science_tc_prep.pdf')
STEMS=('setup','before-press-release','parts-material','notebook-35','shared-try','your-question','fresh-al','fresh-am')
def need(ok: object,message: str)->None:
    if not ok: raise AssertionError(message)
def read(name: str)->str:return (ROOT/name).read_text(encoding='utf-8')
def source()->None:
    workbook=STUDIO/'research/sources/acara-australian-curriculum-v9-download-2026-09-29.xlsx'
    imported=json.loads((STUDIO/'data/frameworks/acara-v9.json').read_text())
    snapshot=json.loads(read('source-snapshot.json'));cross=read('CURRICULUM-CROSSWALK.md');rights=read('SOURCE-RIGHTS.md')
    need(hashlib.sha256(workbook.read_bytes()).hexdigest()==imported['source_sha256']==snapshot['workbook_sha256']==SOURCE_SHA,'Workbook/import/snapshot SHA drift')
    need(snapshot['selected_content_rows']==ROWS and snapshot['other_foundation_rows_checked_not_claimed']==OTHER,'Exact Science row drift')
    need(snapshot['queensland_urls']==list(URLS) and all(u in cross and u in rights for u in URLS),'QCAA primary URL drift')
    records={r['code']:r for r in imported['records'] if r.get('code') in set(ROWS)|set(OTHER)}
    for c,row in {**ROWS,**OTHER}.items():
        r=records[c];need(r['source_row']==row and r['attributes']['level']=='Foundation Year' and r['attributes']['learning_area']=='Science' and r['attributes']['subject']=='Science','Canonical level/subject/row drift '+c)
        if c in ROWS:need(r['plain_text']==DESCRIPTIONS[c] and DESCRIPTIONS[c] in cross,'Exact wording drift '+c)
    with ZipFile(workbook) as z:
        ns={'s':'http://schemas.openxmlformats.org/spreadsheetml/2006/main'}
        strings=[''.join(x.itertext()) for x in ET.fromstring(z.read('xl/sharedStrings.xml')).findall('s:si',ns)];sheet=ET.fromstring(z.read('xl/worksheets/sheet1.xml'))
        for c,row in {**ROWS,**OTHER}.items():
            cells={}
            for a in sheet.find(f's:sheetData/s:row[@r="{row}"]',ns):
                v=a.find('s:v',ns)
                if v is not None:cells[re.sub('[0-9]','',a.get('r'))]=strings[int(v.text)] if a.get('t')=='s' else v.text
            desc=re.sub(r'\s+',' ',html.unescape(re.sub('<[^>]+>','',cells['J']))).strip()
            need(cells['E']==c and desc==records[c]['plain_text'],'Actual workbook E/J mismatch '+c)
    receipts=json.loads(read('source-fetch-receipts.json'))['receipts']
    need(len(receipts)==4 and receipts[0]['http_status']==200 and receipts[0]['bytes']==2049917 and receipts[0]['sha256']==SOURCE_SHA,'Fresh workbook receipt drift')
    need([r['url'] for r in receipts[1:]]==list(URLS) and all(r['http_status']==403 for r in receipts[1:]),'Blocked QCAA direct receipts drift')
    need(all(s in cross for s in ['230388','230153-02','three-page','Current PDF bytes/layout unverified','not claimed']),'QCAA version/current-byte/claim limits absent')
    need('October 2023' in cross and '403' in rights and 'Current PDF bytes/layout unverified' in snapshot['qcaa_status'],'Source version/fetch boundary absent')
    need('no actual local result supplied' in read('ASSESSMENT.md').lower() and 'physical setups/results are proposals or explicitly fictional reports' in read('SUPPLIED-SOURCES.md'),'Source fiction falsely actual')
    for path,word in [('term-4/week-35-a-fair-try.md','proposal'),('term-4/week-36-teach-the-pattern.md','fictional')]:need(word in (STUDIO/'content/foundation'/path).read_text(),'Integrated source boundary drift')
    print('PASS actual workbook XML E/J + canonical nine Foundation rows, seven exact selected descriptions, current primary versions/receipts and fictional source limits')

def pedagogy()->None:
    lessons=read('LESSONS.md');blocks=re.split(r'(?=^## Week \d+ · Day \d+ ·)',lessons,flags=re.M)[1:];days=[]
    for b in blocks:
        d=int(re.search(r'^## Week \d+ · Day (\d+) ·',b).group(1));days.append(d)
        minutes=[int(v) for v in re.findall(r'^\d\. \*\*[^*]+ · (\d+) min\.\*\*',b,re.M)]
        need(minutes==[3,4,7,5,4,2] and sum(minutes)==25,'Daily timing drift '+str(d));need('**Goal/codes:**' in b and '**Prepare:**' in b,'Runnable goal/preparation absent')
    need(days==list(range(171,181)),'Expected ten Days171–180')
    cards=read('LEARNER-CARDS.md')
    for d in days:
        m=re.search(rf'^## Day {d} .*?(?=^## Day |\Z)',cards,re.M|re.S);need(m and all(re.search(rf'\*\*{r} ·',m.group()) for r in 'ABC'),'Three switchable day routes missing')
    for name in ['FAMILY-OPTIONAL.md','EXTENSIONS.md']:need([int(d) for d in re.findall(r'^## Day (\d+) ·',read(name),re.M)]==days,'Ten home/extension invitations missing')
    practice=read('OPTIONAL-PRACTICE.md');need([int(d) for d in re.findall(r'^\| (\d+) \|',practice,re.M)]==days and practice.count('AFTER first check capture only:')==4,'Twenty worked choices/check holds missing')
    need(len(set(re.findall(r'\*\*([^*]+)\.\*\*',practice)))==20,'Worked domains repeated/missing')
    checks=read('ASSESSMENT.md');key=read('TEACHER-KEY.md');material=read('MATERIALS.md')
    need(all('## Check '+c in checks and '## Check '+c in key for c in ['AL','AM']),'Held checks/key missing')
    need('Possible response' not in checks and 'Possible response' in key,'Teacher answer leaked/absent')
    need(all(v in checks for v in ['not timed finishes','term grade','Later short components','as low after letting go as while pressed','as the hand left','After a short conversation','No prior predictions recorded','Material is **UNKNOWN**','no single correct wait']),'Fresh AL/AM source/criteria drift')
    need('| C | After a short conversation | Looks like its before outline |' in checks and '| A | As the hand left | Taller than while pressed, lower than before |' in checks and '| B | As the hand left | Taller than while pressed, lower than before |' in checks,'AM time/source relation drift')
    need(all(v in key for v in ['Reported release differs from prediction','Observation time differs','no prior predictions recorded','No actual observer/test supplied','not every possible material']),'Held fixed reasoning/key drift')
    need(all(v in material for v in ['already approved locally','not a safety guarantee','Nobody presses alongside a child’s tactile access','self-controlled gentle palm press','No required result or fixed number of repeats','no full duplicated individual logs','only if actually checked','not the child’s own visual observation','a fold is not automatically another material','SFU03 partial','Different observation times, actions or forms do not isolate material']),'Safety/low-burden/access/comparison/composition boundary drift')
    before=lessons.split('## Week 35 · Day 175',1)[0]
    need(not any(x in before+cards.split('## Day 175',1)[0]+read('READ-ALOUD.md') for x in ['polyester cloth T','same folded cloth N','PAD-CHECK-AL-35','PAD-CHECK-AM-36']),'Held source leaked before first response')
    need('spontaneous child question' in lessons and 'No digital helper supplied' in read('README.md'),'Child question/simple no-helper choice absent')
    need(read('README.md').index('## Start here')<read('README.md').index('## Adult sources') and 'unmeasured' in read('README.md'),'Practical start/prep status absent')
    need('## Parent/carer pass' in read('QA-RUN-THROUGH.md') and '## Funder/reviewer pass' in read('QA-RUN-THROUGH.md'),'Four-role critique absent')
    print('PASS ten optional25-minute scripts, thirty switchable routes, twenty distinct worked domains, ten home+ten extensions, held AL/AM/open source criteria and low-burden/one-actor/access limits')

def assets()->None:
    alternatives=read('print/TEXT-ALTERNATIVES.md');ns={'s':'http://www.w3.org/2000/svg'}
    need(len(list((ROOT/'print').glob('*.svg')))==len(list((ROOT/'print').glob('*.pdf')))==8,'Eight A4 pairs absent/extra')
    for stem in STEMS:
        svg=ROOT/'print'/(stem+'.svg');pdf=ROOT/'print'/(stem+'.pdf');xml=ET.parse(svg).getroot()
        need(xml.get('width')=='210mm' and xml.get('height')=='297mm' and xml.get('viewBox')=='0 0 794 1123','A4 SVG geometry drift '+stem)
        need(xml.find('s:title',ns) is not None and xml.find('s:desc',ns) is not None,'SVG title/desc missing')
        words=[n.text or '' for n in xml.findall('s:text',ns)];pages=PdfReader(pdf).pages
        need(len(pages)==1 and abs(float(pages[0].mediabox.width)-595.28)<1 and abs(float(pages[0].mediabox.height)-841.89)<1,'One-page A4 PDF absent')
        need(re.sub(r'\s+','',pages[0].extract_text())==re.sub(r'\s+','',''.join(words)),'Whole SVG/PDF wording mismatch '+stem)
        bbox=ET.fromstring(subprocess.run(['pdftotext','-bbox',str(pdf),'-'],check=True,capture_output=True,text=True).stdout)
        for n in bbox.iter():
            if n.tag.endswith('}word'):need(0<=float(n.get('xMin'))<float(n.get('xMax'))<=595.3 and 0<=float(n.get('yMin'))<float(n.get('yMax'))<=841.9,'Clipped actual PDF word '+stem)
        section=alternatives.split('## '+stem+'\n',1)[1].split('\n## ',1)[0];exact=section.split('**Exact visible wording in reading order:**',1)[1]
        need([l[2:] for l in exact.splitlines() if l.startswith('- ')]==words,'Exact alternative reading order drift '+stem)
        need(f'({stem}.pdf)' in section and f'({stem}.svg)' in section and 'Tactile' in section,'Complete accessible pair link missing')
    need('not tagged' in alternatives and 'no object/print/touch/screen' in alternatives,'Linear/no-contact alternative boundary absent')
    def parts(stem):return {n.get('data-part'):n for n in ET.parse(ROOT/'print'/(stem+'.svg')).getroot() if n.get('data-part')}
    def size(p):return float(p.get('width')),float(p.get('height'))
    al=parts('fresh-al');am=parts('fresh-am');note=parts('notebook-35');seq=parts('before-press-release');share=parts('shared-try')
    for p,prefix in [(al,'AL'),(note,'NOTE')]:
        before=size(p[prefix+' BEFORE side']);press=size(p[prefix+' PRESS side']);release=size(p[prefix+' RELEASE side'])
        need(before==(170,100) and press==(210,48) and release==(190,72),'Source qualitative before/press/partial-release geometry drift '+prefix)
        bottoms=[float(p[prefix+' '+s+' side'].get('y'))+float(p[prefix+' '+s+' side'].get('height')) for s in ['BEFORE','PRESS','RELEASE']]
        need(len(set(bottoms))==1,'Common qualitative base drift '+prefix)
        need(prefix+' PRESS sole pressing palm' in p and prefix+' BEFORE sole pressing palm' not in p and prefix+' RELEASE sole pressing palm' not in p,'Single press stage/actor drift')
    for row in 'AB':need(size(am['AM '+row+' BEFORE side'])==(150,70) and size(am['AM '+row+' PRESS side'])==(184,33) and size(am['AM '+row+' RELEASE side'])==(166,49),'AM comparable qualitative rows drift')
    need(size(am['AM C BEFORE side'])==size(am['AM C RELEASE side'])==(150,70),'AM C release does not match before outline')
    need('SEQUENCE RELEASE side' not in seq and seq['SEQUENCE RELEASE panel'].get('fill')=='white' and 'NO RESULT' in read('print/before-press-release.svg'),'Proposed release falsely supplies result')
    need(share['SHARED blank BEFORE → PRESS → RELEASE / OR NOT TRIED'].get('fill')=='white' and not any(' side' in n for n in share),'Shared actual stages prefilled')
    need('AS HAND LEFT' in read('print/fresh-am.svg') and 'AFTER A SHORT CONVERSATION' in read('print/fresh-am.svg') and 'MATERIAL UNKNOWN' in read('print/fresh-am.svg'),'AM printed time/material drift')
    print('PASS eight actual searchable A4 PDFs, whole words/bounds/exact alternatives, AL/NOTE intermediate source geometry, AM identical/different-time outlines, sole press symbols and blank real-result spaces')

def negative_checks()->None:
    global ROOT
    original=ROOT
    probes=[
        ('wrong selected row','source-snapshot.json',lambda s:s.replace('"AC9SFU03": 18532','"AC9SFU03": 18525'),source),
        ('fresh answer leak','ASSESSMENT.md',lambda s:s+'\nPossible response: all soft objects return\n',pedagogy),
        ('AM later time falsely same','ASSESSMENT.md',lambda s:s.replace('| C | After a short conversation |','| C | As the hand left |'),pedagogy),
        ('AL release art falsely remains pressed','print/fresh-al.svg',lambda s:s.replace('height="72"','height="48"'),assets),
        ('AM C release outline falsely wider','print/fresh-am.svg',lambda s:s.replace('width="150" height="70" rx="24" fill="#e7efea" stroke="#233d4d" stroke-width="3" data-part="AM C RELEASE side"','width="166" height="70" rx="24" fill="#e7efea" stroke="#233d4d" stroke-width="3" data-part="AM C RELEASE side"'),assets),
        ('report falsely child direct vision','MATERIALS.md',lambda s:s.replace('not the child’s own visual observation','the child’s own visual observation'),pedagogy),
        ('fold falsely new material','MATERIALS.md',lambda s:s.replace('a fold is not automatically another material','a fold is automatically another material'),pedagogy),
    ]
    for label,file,mutate,check in probes:
        with tempfile.TemporaryDirectory(prefix='subjectnest-science-negative-') as tmp:
            ROOT=Path(tmp)/'pack';shutil.copytree(original,ROOT,ignore=shutil.ignore_patterns('__pycache__'));target=ROOT/file;before=target.read_text();after=mutate(before);need(before!=after,'Invalid probe '+label);target.write_text(after)
            try:
                try:
                    with contextlib.redirect_stdout(io.StringIO()):check()
                except AssertionError:pass
                else:raise AssertionError('Corruption accepted '+label)
            finally:ROOT=original
    print('PASS seven temporary-copy rejections: exact row, answer leak, false observation time, AL relation art, AM same-outline art, false direct report, false material claim')

def regeneration() -> None:
    with tempfile.TemporaryDirectory(prefix="subjectnest-science-35-36-") as tmp:
        temp = Path(tmp)
        shutil.copy2(ROOT / "print/generate_print.py", temp / "generate_print.py")
        subprocess.run([sys.executable, str(temp / "generate_print.py")], check=True, capture_output=True)
        for stem in STEMS:
            for ext in ("svg", "pdf"):
                name = f"{stem}.{ext}"
                need(hashlib.sha256((temp / name).read_bytes()).digest() == hashlib.sha256((ROOT / "print" / name).read_bytes()).digest(), f"Nondeterministic or stale asset: {name}")
    print("PASS deterministic regeneration of all sixteen A4 assets")


def slug(title: str) -> str:
    title = re.sub(r"\[([^]]+)\]\([^)]+\)", r"\1", title)
    title = re.sub(r"[*_]", "", title).strip().lower()
    return re.sub(r"[^\w -]", "", title).replace(" ", "-")


def links(*, bootstrap: bool) -> None:
    count = 0
    for path in ROOT.rglob("*.md"):
        for url in re.findall(r"(?<!!)\[[^]]+\]\(([^)]+)\)", path.read_text(encoding="utf-8")):
            if url.startswith(("https://", "http://", "mailto:")):
                continue
            name, marker, anchor = unquote(url).partition("#")
            target = (path.parent / name).resolve() if name else path
            if bootstrap and target == ROOT / "manifest.json":
                count += 1
                continue
            need(target.is_file(), f"Broken local link: {path.relative_to(ROOT)} -> {url}")
            if marker and target.suffix == ".md":
                headings = [slug(value) for value in re.findall(r"^#{1,6} (.+)$", target.read_text(encoding="utf-8"), re.MULTILINE)]
                need(anchor in headings, f"Broken heading anchor: {url}")
            count += 1
    print(f"PASS {count} local links and heading anchors")


def manifest_data() -> dict:
    files = sorted(path for path in ROOT.rglob("*") if path.is_file() and path.name != "manifest.json" and "__pycache__" not in path.parts)
    return {
        "schema": "subjectnest-authored-pack-manifest-v1",
        "pack": "foundation/science/term-4/weeks-35-36",
        "created_at": "2026-09-30",
        "pack_version": "0.1.0-draft",
        "review_status": "author_desk_checked_pending_school_child_accessibility_and_cultural_review",
        "curriculum_source_sha256": SOURCE_SHA,
        "curriculum_codes_partial_conditional": sorted(ROWS),
        "other_foundation_codes_checked_not_claimed": sorted(OTHER),
        "files": {str(path.relative_to(ROOT)): {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size} for path in files},
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--write-manifest", action="store_true", help="One-time authoring update; default is read-only")
    args = parser.parse_args()
    for path in ROOT.rglob("*"):
        if path.suffix in {".md", ".py", ".js", ".cjs", ".html", ".json"} and path.name != "manifest.json":
            need(("/" + "home/") not in path.read_text(encoding="utf-8"), "Private workstation path in public artifact: " + str(path.relative_to(ROOT)))
    source()
    pedagogy()
    assets()
    regeneration()
    negative_checks()
    links(bootstrap=args.write_manifest)
    expected = manifest_data()
    manifest = ROOT / "manifest.json"
    if args.write_manifest:
        manifest.write_text(json.dumps(expected, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(f"WROTE SHA-256 manifest for {len(expected['files'])} files")
    else:
        need(json.loads(manifest.read_text(encoding="utf-8")) == expected, "SHA-256 manifest missing or stale")
        print(f"PASS SHA-256 manifest for {len(expected['files'])} files")
    print("PACK PASS · author desk QA only; no child or classroom trial")


if __name__ == "__main__":
    main()
