#!/usr/bin/env python3
"""Offline fail-closed checks for the original Essential Mathematics ratio starter."""
from __future__ import annotations

import hashlib
import json
import math
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from fractions import Fraction
from pathlib import Path
from urllib.parse import unquote

ROOT=Path(__file__).resolve().parent
STEMS=('ratio-order','part-whole','simplify-ladder','split-bar','scale-ribbon')
REQUIRED={
 'README.md','LESSONS.md','LEARNER-CARDS.md','PRACTICE-SWAPS.md','STUDENT-CHECKS.md',
 'SOURCE-AND-CROSSWALK.md','SOURCE-AND-RIGHTS.md','RUN-THROUGH.md','source-snapshot.json',
 'CODE-LICENSE.txt','ratio-lab.html','verify_pack.py','manifest.json','teacher/KEY-AND-NEXT.md',
 'print/TEXT-ALTERNATIVES.md','print/DEJAVU-FONT-LICENSE.txt','print/make_print.py',
}
REQUIRED.update(f'print/{stem}.{ext}' for stem in STEMS for ext in ('svg','pdf'))

def need(ok,msg):
 if not ok:raise AssertionError(msg)
def read(name):return (ROOT/name).read_text(encoding='utf8')
def cmd(*args):
 p=subprocess.run(args,capture_output=True,text=True,check=False)
 need(p.returncode==0,f'{args[0]} failed: {p.stderr.strip()}')
 return p.stdout

def source():
 s=json.loads(read('source-snapshot.json'))
 pin={
  'checked_on':'2026-09-29','pathway':'Applied (Essential) senior syllabus',
  'official_subject':'Essential Mathematics','official_syllabus':'Essential Mathematics 2025 v1.3',
  'unit':'Unit 1: Number, data and money','unit_contents_pdf_page':16,'unit_contents_printed_page':14,
  'fundamental_topic':'Fundamental topic: Calculations','topic':'Topic 1: Number',
  'first_subtopic':'Sub-topic: Ratios','ratios_notional_hours':7,'unit_notional_hours':55,
  'subject_matter_pdf_page':17,'subject_matter_printed_page':15,'ratio_bullet_range':'1-6',
 }
 for k,v in pin.items():need(s.get(k)==v,f'QCAA source snapshot drift: {k}')
 need(s['syllabus_url']=='https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_ess_maths_25_app_syll.pdf','source PDF URL')
 cross=read('SOURCE-AND-CROSSWALK.md')
 for i in range(1,7):need(f'Ratios bullet {i} ' in cross,f'ratio bullet {i} missing')
 for phrase in ('Essential Mathematics 2025 v1.3','PDF p. 17 / printed p. 15',
                '250 minutes = 4 hours 10 minutes','7-hour','[complex]',
                'not an official QCAA code','not taken by every Year 11 student'):
  need(phrase.lower() in cross.lower(),f'source boundary missing {phrase}')
 print('PASS QCAA Essential v1.3 snapshot and six ratio bullets with partial limits')

def pedagogy():
 lesson=read('LESSONS.md')
 days=[int(d) for d in re.findall(r'^## Day (\d+) ·',lesson,re.MULTILINE)]
 need(days==list(range(1,11)),f'day sequence {days}')
 for day,sec in zip(days,re.split(r'^## Day \d+ ·',lesson,flags=re.MULTILINE)[1:]):
  need(sec.count('**Routes:**')==1,f'Day {day} route count')
  route=re.search(r'\*\*Routes:\*\* ([^\n]+)',sec)
  need(route and route.group(1).count(';')>=2,f'Day {day} needs three routes')
  for tag in ('**Goal:**','**Alternative domains:**','**Optional/home:**','**Move:**'):
   need(tag in sec,f'Day {day} missing {tag}')
  timing=('25 = 2 + 3 + 12 + 5 + 3' if day==5 else '25 = 2 + 3 + 15 + 3 + 2' if day==10 else '25 = 2 + 5 + 6 + 7 + 5')
  need(timing in sec,f'Day {day} timing')
  starts=(('0–2:','2–5:','5–17:','17–22:','22–25:') if day==5 else ('0–2:','2–5:','5–20:','20–23:','23–25:') if day==10 else ('0–2:','2–7:','7–13:','13–20:','20–25:'))
  for start in starts:need(start in sec,f'Day {day} clock {start}')
 cards=read('LEARNER-CARDS.md')
 need(re.findall(r'^## Card ([A-G]) ·',cards,re.MULTILINE)==list('ABCDEFG'),'seven cards')
 swaps=read('PRACTICE-SWAPS.md')
 need([int(d) for d in re.findall(r'^## Day (\d+) ·',swaps,re.MULTILINE)]==days,'swap day headings')
 for day,sec in zip(days,re.split(r'^## Day \d+ ·',swaps,flags=re.MULTILINE)[1:]):
  need(len(re.findall(r'^- \*\*',sec,re.MULTILINE))==2,f'Day {day} needs two swaps')
 check=read('STUDENT-CHECKS.md');key=read('teacher/KEY-AND-NEXT.md')
 need(re.findall(r'^## Check ([AB]) ·',check,re.MULTILINE)==['A','B'],'two fresh learner checks')
 need('public' in check.lower() and 'public' in key.lower(),'public key warning')
 for phrase in ('9 triangle markers','15 circle markers','55 paper badges','4:7','250 cm','6 cm'):
  need(phrase in check and phrase not in cards,f'fresh check marker {phrase}')
 need('not secure' in check.lower() and 'not a secure' in lesson.lower(),'security boundary')
 print('PASS ten timed days, 3 routes/day, 7 cards, 20 swaps, two public checks')

def arithmetic():
 key=read('teacher/KEY-AND-NEXT.md')
 need(Fraction(3,5)+Fraction(2,5)==1,'Card A fractions')
 need(math.gcd(4,6)==2 and (4//2,6//2)==(2,3),'Card B simplify')
 need(math.gcd(8,12)==4 and Fraction(8,20)==Fraction(2,5) and Fraction(12,20)==Fraction(3,5),'Card C')
 need(150==100+50 and (150//50,100//50)==(3,2),'Card D units')
 need(35//(2+3)==7 and (2*7,3*7)==(14,21),'Card E split')
 need((4*200,3*200)==(800,600) and 800//100==8 and 600//100==6,'Card F scale')
 need(math.gcd(12,18)==6 and Fraction(12,30)==Fraction(2,5) and (2*8,3*8)==(16,24),'Card G audit')
 need(math.gcd(9,15)==3 and (9//3,15//3)==(3,5) and Fraction(9,24)==Fraction(3,8) and Fraction(15,24)==Fraction(5,8),'Check A')
 need(55//(4+7)==5 and (4*5,7*5)==(20,35) and (6*250,1000//250)==(1500,4),'Check B')
 # All twenty optional prompts independently recomputed against expected outputs.
 extra=[
  ((5,3,Fraction(5,8)),(7,2,9)),
  ((2,5,5,2),(3,8,8,3)),
  ((2,3,Fraction(2,5)),(4,1,Fraction(4,5))),
  ((6//3,9//3),(14//7,21//7)),
  ((12//4,16//4,Fraction(12,28)),(5//5,15//5,Fraction(5,20))),
  ((120//40,80//40),(200//50,50//50)),
  ((40//8*3,40//8*5),(36//3*1,36//3*2)),
  ((2*300//100,),(6*50//100,)),
  ((50//5*2,50//5*3),(1200//200,)),
  ((42//7*2,42//7*5),(3*400//100,)),
 ]
 expected=[((5,3,Fraction(5,8)),(7,2,9)),((2,5,5,2),(3,8,8,3)),
  ((2,3,Fraction(2,5)),(4,1,Fraction(4,5))),((2,3),(2,3)),
  ((3,4,Fraction(3,7)),(1,3,Fraction(1,4))),((3,2),(4,1)),
  ((15,25),(12,24)),((6,),(3,)),((20,30),(6,)),((12,30),(12,))]
 need(extra==expected,'optional swap arithmetic')
 for result in ('3:2','2:3','8/20=2/5','12/20=3/5','150 cm:100 cm=3:2',
                'large `14`','small `21`','800 cm','8 m','600 cm','6 m','2/5','16','24',
                '`9:15`','`3:5`','`5:3`','`3/8`','`5/8`','`55÷11=5`','`20`','35',
                '1500 cm=15 m','4 cm'):
  need(result in key,f'staff worked result missing {result}')
 print('PASS independent exact arithmetic for cards, 20 swaps and both checks')

def links():
 count=0
 for md in ROOT.rglob('*.md'):
  for raw in re.findall(r'!?\[[^\]]+\]\(([^)]+)\)',md.read_text(encoding='utf8')):
   u=unquote(raw.split('#',1)[0])
   if not u or u.startswith(('http://','https://','mailto:')):continue
   need((md.parent/u).resolve().exists(),f'broken link {md.relative_to(ROOT)} -> {raw}');count+=1
 page=read('ratio-lab.html')
 for u in re.findall(r'href="([^"]+)"',page):
  need((ROOT/u).exists(),f'broken HTML link {u}');count+=1
 print(f'PASS {count} local Markdown/HTML links')

def aids():
 alt=read('print/TEXT-ALTERNATIVES.md')
 for stem in STEMS:
  svg=ET.parse(ROOT/'print'/f'{stem}.svg').getroot()
  need(svg.attrib.get('width')=='210mm' and svg.attrib.get('height')=='297mm',f'{stem} SVG size')
  ns='{http://www.w3.org/2000/svg}'
  need(svg.find(ns+'title') is not None and svg.find(ns+'desc') is not None,f'{stem} title/description')
  need(stem+'.svg' in alt and stem+'.pdf' in alt and 'Tactile route' in alt,f'{stem} text alternative')
  info=cmd('pdfinfo',str(ROOT/'print'/f'{stem}.pdf'))
  need('Pages:           1' in info and '595.276 x 841.89 pts (A4)' in info,f'{stem} PDF A4')
  need(len(cmd('pdftotext',str(ROOT/'print'/f'{stem}.pdf'),'-').strip())>130,f'{stem} PDF text')
 html=read('ratio-lab.html')
 for tag in ('lang="en-AU"','aria-live="polite"','type="number"','<label for="a">','<label for="b">','<label for="factor">','<label for="total">','function gcd','total%sum===0'):
  need(tag in html,f'ratio lab missing {tag}')
 for banned in ('<script src=','fetch(', 'XMLHttpRequest','localStorage','sendBeacon','<iframe'):
  need(banned not in html,f'ratio lab external/storage boundary {banned}')
 print('PASS five A4 SVG/PDF/text aids and self-contained accessible offline lab structure')

def map_hashes():
 actual={p.relative_to(ROOT).as_posix() for p in ROOT.rglob('*') if p.is_file()}
 need(actual==REQUIRED,f'file inventory missing={sorted(REQUIRED-actual)} extra={sorted(actual-REQUIRED)}')
 return {n:{'sha256':hashlib.sha256((ROOT/n).read_bytes()).hexdigest(),'bytes':(ROOT/n).stat().st_size}
         for n in sorted(REQUIRED-{'manifest.json'})}

def main():
 source();pedagogy();arithmetic();links();aids();files=map_hashes()
 if '--write-manifest' in sys.argv[1:]:
  (ROOT/'manifest.json').write_text(json.dumps({'schema':'subjectnest-pack-hash-manifest-v1',
   'pack':'year-11/essential-mathematics/term-1/weeks-01-02',
   'syllabus_snapshot':'Essential Mathematics 2025 v1.3, checked 2026-09-29',
   'files':files},indent=2,ensure_ascii=False)+'\n',encoding='utf8')
  print(f'WROTE manifest for {len(files)} hashed files')
 else:
  m=json.loads(read('manifest.json'))
  need(m.get('schema')=='subjectnest-pack-hash-manifest-v1' and m.get('files')==files,'hash mismatch; inspect before regeneration')
  print(f'PASS SHA-256 manifest for {len(files)} files')
 print('PACK PASS (authored offline artifacts; not live QCAA or classroom validation)')

if __name__=='__main__':
 try:main()
 except (AssertionError,OSError,ValueError) as e:
  print(f'PACK FAIL: {e}',file=sys.stderr);raise SystemExit(1)
