# 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','parts','before-after','flat-crease','shared-try','your-question','fresh-aj','fresh-ak')
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','Screenshot attempts supplied no usable','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') 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-33-what-the-drawing-shows.md','No actual plant was observed'),('term-4/week-34-test-the-claim.md','strength not tested')]: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 daily goal/preparation absent')
    need(days==list(range(161,171)),'Expected ten Days161–170')
    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'),'Switchable routes absent '+str(d))
    for file in ['FAMILY-OPTIONAL.md','EXTENSIONS.md']:need([int(d) for d in re.findall(r'^## Day (\d+) ·',read(file),re.M)]==days,'Ten home/extension domains absent')
    practice=read('OPTIONAL-PRACTICE.md');need([int(d) for d in re.findall(r'^\| (\d+) \|',practice,re.M)]==days,'Twenty worked domains absent');need(practice.count('AFTER first check capture only:')==4,'Check-day examples not held')
    checks=read('ASSESSMENT.md');key=read('TEACHER-KEY.md')
    need(all('## Check '+c in checks and '## Check '+c in key for c in ['AJ','AK']),'Held sources/key missing')
    need('Possible response' not in checks and 'Possible response' in key,'Key example leaked/absent')
    need(all(s in checks for s in ['not timed finishes','term grade','later short components','different wide-gap marks','No prediction is recorded for either row','support marks and card place did not shift']),'Fresh condition/prediction/status facts drift')
    need(all(s in key for s in ['Form AND support gap differ','Neither prior prediction recorded','reported result differs from prediction','No actual observer/test supplied']),'Fresh fixed reasoning/key source drift')
    material=read('MATERIALS.md')
    need(all(s in material for s in ['already approved locally','not a safety guarantee','A slipped load/support is an interruption','If it already touches','same verified paper source/batch','only if actually checked','no full duplicated individual logs','No universal strength','not the child\'s own visual observation']),'Material, comparison, baseline or evidence boundary drift')
    need('## Start here' in read('README.md') and read('README.md').index('## Start here')<read('README.md').index('## Adult sources'),'Practical start must precede sources')
    need('unmeasured' in read('README.md') and re.search(r'no required result or fixed number of repeats',material,re.I) and 'spontaneous child question' in lessons,'Practical child question/prep/sample boundary missing')
    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 worked domains, ten home+ten extensions, held AJ/AK/open keys and low-burden/baseline/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 missing/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 alternative absent")
        words=[node.text or "" for node 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,"Searchable one-page A4 absent")
        need(re.sub(r"\s+","",pages[0].extract_text())==re.sub(r"\s+","","".join(words)),"Whole SVG/PDF wording drift: "+stem)
        bbox=ET.fromstring(subprocess.run(["pdftotext","-bbox",str(pdf),"-"],check=True,capture_output=True,text=True).stdout)
        for item in bbox.iter():
            if item.tag.endswith("}word"):need(0<=float(item.get("xMin"))<float(item.get("xMax"))<=595.3 and 0<=float(item.get("yMin"))<float(item.get("yMax"))<=841.9,"Clipped actual PDF wording")
        section=alternatives.split("## "+stem+"\n",1)[1].split("\n## ",1)[0];exact=section.split("**Exact visible wording in reading order:**",1)[1]
        need([line[2:] for line in exact.splitlines() if line.startswith("- ")]==words,"Exact alternative/order drift: "+stem)
        need(f"({stem}.pdf)" in section and f"({stem}.svg)" in section and "Tactile" in section,"Accessible pair link/route absent")
    need("not tagged" in alternatives and "no object/print/touch/screen" in alternatives.lower(),"Linear/nonvisual 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")}
    aj=parts('fresh-aj');ak=parts('fresh-ak')
    need(aj['AJ before roof open profile'].get('d')=='M100 322 L694 322' and 'AJ before one light card' not in aj,'AJ unloaded baseline/absence of load drift')
    need(aj['AJ after roof reported contact'].get('d')=='M100 656 L150 656 Q397 872 644 656 L694 656','AJ reported contact curve drift')
    need(aj['AJ after tray mat'].get('y')=='764' and aj['AJ after one light card'].get('y')=='743' and aj['AJ after one light card'].get('height')=='21','AJ card/mat contact relation drift')
    need(ak['AK flat narrow left support'].get('x')=='186' and ak['AK flat narrow right support'].get('x')=='510','AK narrow support relation drift')
    need(ak['AK creased wide left support'].get('x')=='101' and ak['AK creased wide right support'].get('x')=='595','AK different wide support relation drift')
    need(ak['AK creased wide roof reported contact'].get('d')=='M100 631 L150 631 Q397 847 644 631 L694 631','AK contact relation drift')
    need(ak['AK creased wide crease top-view inset'].get('y')=='551' and ak['AK creased wide crease mark one'].get('d')=='M235 567 L559 567','AK crease inset/mark drift')
    need(parts('before-after')['after blank observation'].get('fill')=='white' and parts('shared-try')['shared blank UNLOADED → AFTER / OR NOT TRIED'].get('fill')=='white','Actual after/shared result prefilled')
    need('NO PREDICTIONS RECORDED' in read('print/fresh-ak.svg') and 'NO SUPPORT / CARD SHIFT' in read('print/fresh-aj.svg'),'Fresh printed source facts drift')
    print('PASS eight actual searchable A4 pages, whole words/bounds/exact alternatives, blank actual records and substantive AJ/AK prop/contact/gap/inset relations')

def digital()->None:
    page=read('digital/index.html');js=read('digital/app.js');css=read('digital/style.css');guide=read('digital/README.md')
    need('Content-Security-Policy' in page and "default-src 'none'" in page and "connect-src 'none'" in page,'Offline CSP absent')
    need(all(f'id="{n}"' in page for n in ['form','gap','step','picture','description','short','status','reset']),'Tiny helper controls/words absent')
    need('aria-live="polite"' in page and ':focus-visible' in css and '.controls{display:none}' in css and '<noscript>' in page,'Keyboard/noJS scaffold absent')
    need(not re.search(r'\b(?:fetch|XMLHttpRequest|localStorage|sessionStorage|sendBeacon|WebSocket|serviceWorker)\b',js),'Outside connection/storage API')
    need('untried plan, not a result' in page and 'Nothing is stored or sent outside' in page and 'Closing or reloading clears choices' in page,'Source/privacy boundary absent')
    need('blank-actual-result' in js and 'no after state or result supplied' in js and 'Physical try NOT TRIED here.' in js and 'Child question and result are not supplied.' in js,'Helper falsely supplies actual trial/answer')
    need('not another material layer' in js and 'M70 183 L370 183' in js and "[55,321]" in js,'Helper proposed form/marks source drift')
    need(not any(s in page+js for s in ['ROOF-CHECK-AJ-33','ROOF-CHECK-AK-34','fresh-aj','fresh-ak','TEACHER-KEY','paper from batch J','paper from batch S']),'Held source/key leaked into helper')
    subprocess.run(['node','--check',str(ROOT/'digital/app.js')],check=True,capture_output=True)
    for name,width in [('desktop',1280),('mobile',390)]:
        b=(ROOT/'digital/previews'/(name+'.png')).read_bytes();need(b[:8]==b'\x89PNG\r\n\x1a\n' and struct.unpack('>I',b[16:20])[0]==width and f'previews/{name}.png' in guide,'Actual preview absent')
    need('48 cases' in guide and '1280, 390, 320' in guide and (ROOT/'digital/qa_browser.cjs').is_file(),'Current browser audit absent')
    print('PASS optional helper no simulator/results, no held leak/network/storage, complete noJS and actual current previews')

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: the result differs from prediction\n',pedagogy),
        ('AK gap falsely matched','ASSESSMENT.md',lambda s:s.replace('different wide-gap marks','same narrow-gap marks'),pedagogy),
        ('AJ contact drawn as open gap','print/fresh-aj.svg',lambda s:s.replace('Q397 872 644 656','Q397 690 644 656'),assets),
        ('outside API','digital/app.js',lambda s:s+"\nfetch('https://example.invalid');\n",digital),
        ('report falsely child visual observation','MATERIALS.md',lambda s:s.replace("not the child's own visual observation","the child's own visual observation"),pedagogy),
        ('helper falsely actual trial','digital/app.js',lambda s:s.replace('Physical try NOT TRIED here.','Physical try OBSERVED here.'),digital),
    ]
    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: source row, answer leak, false gap match, contact art, network API, false direct report, false actual helper trial')

def regeneration() -> None:
    with tempfile.TemporaryDirectory(prefix="subjectnest-science-33-34-") 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-33-34",
        "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()
    digital()
    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()
