#!/usr/bin/env python3
"""Fail-closed offline pack, math, print and hash checks; not live classroom validation."""
from __future__ import annotations

import hashlib
import json
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from decimal import Decimal as D
from pathlib import Path
from urllib.parse import unquote

from openpyxl import load_workbook

ROOT=Path(__file__).resolve().parent
STEMS=('wage-layers','markup-margin','change-ladder','exchange-directions','share-measures')
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','verify_pack.py','manifest.json','teacher/KEY-AND-NEXT.md',
 'print/TEXT-ALTERNATIVES.md','print/DEJAVU-FONT-LICENSE.txt','print/make_print.py',
 'print/make_rate_switch.py','print/rate-switch.xlsx',
}
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 command(*args):
 p=subprocess.run(args,capture_output=True,text=True,check=False)
 need(p.returncode==0,f'{args[0]} failed for {args[-1]}: {p.stderr.strip()}')
 return p.stdout

def source():
 s=json.loads(read('source-snapshot.json'))
 pin={
  'checked_on':'2026-09-29','official_subject':'General Mathematics',
  'official_syllabus':'General Mathematics 2025 v1.3',
  'unit':'Unit 1: Money, measurement, algebra and linear equations',
  'unit_contents_pdf_page':16,'unit_contents_printed_page':14,
  'topic':'Topic 1: Consumer arithmetic',
  'subtopic':'Applications of rates, percentages and use of spreadsheets',
  'subtopic_notional_hours':14,'unit_notional_hours':55,
  'subject_matter_pdf_page':17,'subject_matter_printed_page':15,
  'subject_matter_bullet_range':'1-9'
 }
 for k,v in pin.items():need(s.get(k)==v,f'QCAA snapshot drift: {k}')
 need(s['syllabus_url'].startswith('https://www.qcaa.qld.edu.au/'),'QCAA source URL')
 cross=read('SOURCE-AND-CROSSWALK.md')
 for i in range(1,10):need(f'bullet {i} ' in cross,f'missing QCAA row {i}')
 for phrase in ('General Mathematics 2025 v1.3','PDF p. 17 / printed p. 15',
                '750 minutes = 12 hours 30 minutes','not an official QCAA code',
                'Unaddressed','does **not** prove any learner used'):
  need(phrase.lower() in cross.lower(),f'missing source boundary {phrase}')
 print('PASS QCAA v1.3 dated snapshot and nine-row partial crosswalk')

def pedagogy():
 lesson=read('LESSONS.md')
 days=[int(x) for x in re.findall(r'^## Day (\d+) ·',lesson,re.MULTILINE)]
 need(days==list(range(21,31)),f'days wrong: {days}')
 sections=re.split(r'^## Day \d+ ·',lesson,flags=re.MULTILINE)[1:]
 for day,sec in zip(days,sections):
  need(sec.count('**Routes:**')==1,f'Day {day} route count')
  routes=re.search(r'\*\*Routes:\*\* ([^\n]+)',sec)
  need(routes and routes.group(1).count(';')>=2,f'Day {day} needs 3 distinct 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 in (25,30) 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 in (25,30) 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 ([H-N]) ·',cards,re.MULTILINE)==list('HIJKLMN'),'cards H–N')
 swaps=read('PRACTICE-SWAPS.md')
 sh=[int(x) for x in re.findall(r'^## Day (\d+) ·',swaps,re.MULTILINE)]
 need(sh==days,'swap day inventory')
 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 optional swaps')
 checks=read('STUDENT-CHECKS.md');key=read('teacher/KEY-AND-NEXT.md')
 need(re.findall(r'^## Check ([AB]) ·',checks,re.MULTILINE)==['A','B'],'two checks')
 need('public' in checks.lower() and 'public' in key.lower(),'public key boundary')
 for marker in ('$33','$17','$88','$110','$175','$54','$32','$1.28','$2.50'):
  need(marker in checks,f'check input absent {marker}')
  need(marker not in cards and marker not in swaps,f'check input reused {marker}')
 need(re.search(r'not secure',checks,re.IGNORECASE) and re.search(r'not a secure',lesson,re.IGNORECASE),'security boundary')
 print('PASS 10 timed days, 3 routes/day, 7 cards, 20 swaps, 2 fresh public checks')

def math():
 key=read('teacher/KEY-AND-NEXT.md')
 need(D(8)*D(28)+D(2)*D(28)*D('1.5')==D(308),'Card H')
 need(D(6)*D(31)+D(2)*D(31)*D('1.25')+D(24)==D('287.50'),'Card I')
 need(D(80)*D('1.25')==D(100),'Card J')
 need((D(90)-D(72))/D(72)*100==D(25) and (D(90)-D(72))/D(90)*100==D(20),'Card K')
 need((D('137.50')-D(125))/D(125)*100==D(10),'Card L forward')
 need(D('12.50')/D('137.50')*100>D('9.09') and D('12.50')/D('137.50')*100<D('9.10'),'Card L reverse')
 need(D(250)*D('.64')==D(160) and D(96)/D('.64')==D(150),'Card M')
 need(D(50)*D('.84')==D(42) and D('.84')/D(24)*100==D('3.5'),'Card N dividend')
 need(D(30)/D(2)==D(15) and D(36)/D(3)==D(12),'Card N P/E')
 need(D(7)*D(33)+D(2)*D(33)*D('1.5')+D(17)==D(347),'Check A wages')
 need((D(110)-D(88))/D(88)*100==D(25) and (D(110)-D(88))/D(110)*100==D(20),'Check A bases')
 need(D(175)*D('.72')==D(126) and D(54)/D('.72')==D(75),'Check B exchange')
 need(D(45)*D('1.28')==D('57.60') and D('1.28')/D(32)*100==D(4) and D(32)/D('2.50')==D('12.8'),'Check B shares')
 # Two extra cases per day are recomputed rather than trusting the staff table.
 swaps=[
  (D(6)*25+D(3)*25*D('1.5'),D(4)*30+30*2),
  (D(5)*26+D(2)*26*D('1.25')+15,D(7)*24+D(1)*24*D('1.5')+12),
  (D(48)*D('.25'),D(35)*D('.20')),
  ((D(50)-D(40))/D(40)*100,(D(108)-D(90))/D(108)*100),
  ((D(75)-D(60))/D(75)*100,D(3)*20+D(1)*20*D('1.5')),
  ((D(50)-D(40))/D(40)*100,(D(90)-D(75))/D(75)*100),
  (D(100)*D('.75'),D(180)*D('.60')),
  (D(25)*D('.40'),D(80)*D('.64')),
  (D(28)/D(4),D(45)/D(3)),
  (D(90)*D('.80'),D(12)*D('.50'))
 ]
 expected=[('262.50','180'),('210','216'),('12','7'),('25','16.66666666666666666666666667'),('20','90'),('25','20'),('75','108'),('10','51.20'),('7','15'),('72','6')]
 for day,(got,want) in enumerate(zip(swaps,expected),21):
  for value,exp in zip(got,want):need(abs(value-D(exp))<D('.0001'),f'Day {day} swap arithmetic')
 for answer in ('$224','$84','$308','$186','$77.50','$287.50','$100','$18','$125','$137.50',
                '9.09%','USD $160','AUD $150','$42','3.5%','P/E','15','12',
                '$231','$99','$347','$22','$126','$75','$57.60','12.8',
                '$262.50','$210','$216','$51.20'):
  need(answer in key,f'worked key missing {answer}')
 print('PASS independent Decimal arithmetic for 10 cores, 20 swaps, 2 checks')

def links():
 n=0
 for md in sorted(ROOT.rglob('*.md')):
  for raw in re.findall(r'!?\[[^\]]+\]\(([^)]+)\)',md.read_text(encoding='utf8')):
   url=unquote(raw.split('#',1)[0])
   if not url or url.startswith(('https://','http://','mailto:')):continue
   target=(md.parent/url).resolve()
   need(target.exists(),f'broken link {md.relative_to(ROOT)} -> {raw}')
   n+=1
 print(f'PASS {n} local Markdown 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} SVG alt')
  need(stem+'.svg' in alt and stem+'.pdf' in alt and 'Tactile route' in alt,f'{stem} text alternative')
  info=command('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 not A4')
  need(len(command('pdftotext',str(ROOT/'print'/f'{stem}.pdf'),'-').strip())>160,f'{stem} PDF text')
 w=load_workbook(ROOT/'print/rate-switch.xlsx',data_only=False,read_only=False);s=w.active
 need(s.title=='Invented rates','workbook sheet')
 for row,values in [(3,('Card M',.64,250,96)),(4,('Try changed rate',.80,250,96))]:
  need(s.cell(row,1).value==values[0] and abs(s.cell(row,2).value-values[1])<1e-9 and
       s.cell(row,3).value==values[2] and s.cell(row,5).value==values[3],f'workbook row {row}')
  need(s.cell(row,4).value==f'=C{row}*B{row}' and s.cell(row,6).value==f'=E{row}/B{row}',f'workbook row {row} formulas')
 need(str(s.page_setup.paperSize)==str(s.PAPERSIZE_A4),'workbook print A4')
 w.close()
 print('PASS five A4 SVG/PDF/text aids and offline formula workbook')

def hash_map():
 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 {name:{'sha256':hashlib.sha256((ROOT/name).read_bytes()).hexdigest(),
               'bytes':(ROOT/name).stat().st_size}
         for name in sorted(REQUIRED-{'manifest.json'})}

def main():
 source();pedagogy();math();links();aids();files=hash_map()
 if '--write-manifest' in sys.argv[1:]:
  (ROOT/'manifest.json').write_text(json.dumps({'schema':'subjectnest-pack-hash-manifest-v1',
   'pack':'year-11/mathematics/term-1/weeks-05-06',
   'syllabus_snapshot':'General 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:
  manifest=json.loads(read('manifest.json'))
  need(manifest.get('schema')=='subjectnest-pack-hash-manifest-v1' and manifest.get('files')==files,
       'hash manifest mismatch; review before regeneration')
  print(f'PASS SHA-256 manifest for {len(files)} authored files')
 print('PACK PASS (local authored artifacts; not live source 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)
