#!/usr/bin/env python3
"""Offline, fail-closed structural/answer/link/hash checks for the Methods pack."""
from __future__ import annotations

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

ROOT=Path(__file__).resolve().parent
STEMS=('exact-approx','square-factor','like-radicals','rationalise','quadratic-map')
REQUIRED={
 'README.md','LESSONS.md','LEARNER-CARDS.md','PRACTICE-SWAPS.md','STUDENT-CHECKS.md',
 'teacher/KEY-AND-NEXT.md','SOURCE-AND-CROSSWALK.md','SOURCE-AND-RIGHTS.md',
 'source-snapshot.json','RUN-THROUGH.md','verify_pack.py','manifest.json','CODE-LICENSE.txt',
 'surd-explorer.html','print/make_print.py','print/TEXT-ALTERNATIVES.md','print/DEJAVU-FONT-LICENSE.txt',
}
REQUIRED.update(f'print/{s}.{e}' for s in STEMS for e 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,text=True,capture_output=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','subject':'Mathematical Methods','pathway':'General senior syllabus',
  'version':'Mathematical Methods 2025 v1.3','issue_date':'January 2026',
  'unit':'Unit 1: Surds, algebra, functions and probability','unit_pdf_page':17,
  'unit_printed_page':15,'topic':'Topic 1: Surds and quadratic functions',
  'topic_pdf_page':18,'topic_printed_page':16,'unit_notional_hours':55,
 }
 for k,v in pin.items():need(s.get(k)==v,f'QCAA source snapshot drift: {k}')
 need(s['subtopics']==[{'name':'Surds','notional_hours':4,'official_bullets':4},
                       {'name':'Quadratic functions','notional_hours':7,'official_bullets':6}],
      'QCAA sub-topic snapshot')
 need(s['syllabus_url']=='https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_maths_methods_25_syll.pdf','QCAA URL')
 c=read('SOURCE-AND-CROSSWALK.md')
 for phrase in ('PDF p. 18 / printed p. 16','200 minutes = 3 hours 20 minutes',
                '250 minutes = 4 hours 10 minutes','not QCAA codes','not secure',
                'M1','M2','M3','M4','M5','M6','M7','M8','M9','M10'):
  need(phrase.lower() in c.lower(),f'crosswalk missing {phrase}')
 print('PASS official QCAA Methods v1.3 Unit 1 Topic 1 pin and partial limits')

def pedagogy():
 l=read('LESSONS.md');days=[int(x) for x in re.findall(r'^## Day (\d+) ·',l,re.MULTILINE)]
 need(days==list(range(1,11)),f'ten days: {days}')
 chunks=re.split(r'^## Day \d+ ·',l,flags=re.MULTILINE)[1:]
 for d,c in zip(days,chunks):
  for tag in ('**Goal:**','**Routes:**','**Other domains:**','**Optional/home:**','**Move:**'):
   need(tag in c,f'Day {d} missing {tag}')
  route=re.search(r'\*\*Routes:\*\* ([^\n]+)',c)
  need(route and route.group(1).count(';')>=2,f'Day {d} needs three routes')
  starts=('0–2:','2–5:','5–17:','17–22:','22–25:') if d in (5,10) else ('0–2:','2–7:','7–13:','13–20:','20–25:')
  need(all(s in c for s in starts),f'Day {d} clock')
  need(f'Card {chr(64+d)}' in c,f'Day {d} card')
 cards=read('LEARNER-CARDS.md')
 need(re.findall(r'^## Card ([A-J]) ·',cards,re.MULTILINE)==list('ABCDEFGHIJ'),'ten clean learner cards')
 swaps=read('PRACTICE-SWAPS.md')
 need([int(x) for x in re.findall(r'^## Day (\d+) ·',swaps,re.MULTILINE)]==days,'swaps days')
 for d,c in zip(days,re.split(r'^## Day \d+ ·',swaps,flags=re.MULTILINE)[1:]):
  need(len(re.findall(r'^- \*\*',c,re.MULTILINE))==2,f'Day {d} 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 fresh checks')
 for token in ('sqrt(243)','sqrt(48)','sqrt(195)','-(x-2)(x-8)','5/sqrt(11)'):
  need(token in checks and token not in cards and token not in swaps,f'check reuse/missing: {token}')
 need('not secure' in checks.lower() and 'public' in key.lower(),'public key honesty')
 print('PASS ten timed days, 30 routes, ten cards, 20 swaps, two fresh public checks')

def simplify(n):
 for d in range(math.isqrt(n),1,-1):
  if n%(d*d)==0:return d,n//(d*d)
 return 1,n

def mathematics():
 key=read('teacher/KEY-AND-NEXT.md')
 cases={72:(6,2),50:(5,2),98:(7,2),12:(2,3),27:(3,3),
        32:(4,2),8:(2,2),18:(3,2),90:(3,10),108:(6,3),200:(10,2),
        75:(5,3),125:(5,5),20:(2,5),28:(2,7),63:(3,7),
        243:(9,3),48:(4,3),195:(1,195)}
 for n,want in cases.items():need(simplify(n)==want,f'surd arithmetic: {n}')
 need((simplify(243)[0]-simplify(48)[0],simplify(243)[1])==(5,3),'Check A exact difference')
 need(abs(5*math.sqrt(3)-8.660254)<1e-5 and round(5*math.sqrt(3),1)==8.7,'Check A decimal')
 need(5*5*3==75 and math.isqrt(195)**2!=195,'Check A false equivalence')
 need(simplify(6*15)==(3,10) and simplify(18)==(3,2),'Card F')
 need(abs(7/math.sqrt(5)-7*math.sqrt(5)/5)<1e-12,'Card G equivalence')
 need(abs(5/math.sqrt(11)-5*math.sqrt(11)/11)<1e-12,'Check B rationalisation')
 f=lambda x: -(x-2)*(x-8)
 need(f(2)==f(8)==0 and f(5)==9 and f(0)==-16 and f(3)>0 and f(9)<0,'Check B parabola')
 g=lambda x:(x-2)**2+1
 need([g(x) for x in range(5)]==[5,2,1,2,5],'Card I graph')
 h=lambda x:(x+1)*(x-3)
 need(h(-1)==h(3)==0 and h(1)==-4 and h(0)==-3,'Card J graph')
 for phrase in ('5sqrt(3)','8.7','2<x<8','(5,9)','(0,-16)','5sqrt(11)/11',
                '18sqrt(2)','7sqrt(5)/5','12sqrt(5)','(1,9)'):
  need(phrase in key,f'worked key missing {phrase}')
 print('PASS independent exact-factor, graph and check-value spot checks')

def slug(s):
 return re.sub(r'[^a-z0-9 _-]','',s.lower()).strip().replace(' ','-')

def links():
 count=0
 for md in ROOT.rglob('*.md'):
  body=md.read_text(encoding='utf8')
  for raw in re.findall(r'!?\[[^\]]+\]\(([^)]+)\)',body):
   if raw.startswith(('https://','http://','mailto:')):continue
   name,_,frag=raw.partition('#'); name=unquote(name)
   target=(md.parent/name).resolve() if name else md
   need(target.is_file(),f'broken link {md.relative_to(ROOT)} -> {raw}')
   if frag and target.suffix=='.md':
    headings=[slug(x) for x in re.findall(r'^#{1,6}\s+(.+)$',target.read_text(encoding='utf8'),re.MULTILINE)]
    need(unquote(frag) in headings,f'broken heading {md.relative_to(ROOT)} -> {raw}')
   count+=1
 html=read('surd-explorer.html')
 for raw in re.findall(r'href="([^"]+)"',html):
  need((ROOT/raw).is_file(),f'broken explorer link {raw}');count+=1
 print(f'PASS {count} local file and heading links')

def assets():
 alt=read('print/TEXT-ALTERNATIVES.md')
 for stem in STEMS:
  svg=ET.parse(ROOT/'print'/f'{stem}.svg').getroot();ns='{http://www.w3.org/2000/svg}'
  need(svg.attrib.get('width')=='210mm' and svg.attrib.get('height')=='297mm',f'{stem} SVG size')
  need(svg.find(ns+'title') is not None and svg.find(ns+'desc') is not None,f'{stem} SVG accessibility')
  need(f'{stem}.svg' in alt and f'{stem}.pdf' in alt and 'Tactile' in alt,f'{stem} text/tactile')
  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')
  pdf=cmd('pdftotext',str(ROOT/'print'/f'{stem}.pdf'),'-')
  need(len(pdf.strip())>200 and 'WORKED EXAMPLE' in pdf,f'{stem} selectable PDF text')
 html=read('surd-explorer.html')
 for s in ('lang="en-AU"','aria-live="polite"','type="number"','min="1"','max="5000"',
           'function show()','rootFactor','remainder','Number.isInteger','Paper route'):
  need(s in html,f'explorer missing {s}')
 for bad in ('<script src=','fetch(', 'XMLHttpRequest','sendBeacon','localStorage','<iframe'):
  need(bad not in html,f'explorer external/storage boundary {bad}')
 print('PASS five A4 SVG/PDF/text/tactile aids and offline explorer structure')

def hashes():
 actual={p.relative_to(ROOT).as_posix() for p in ROOT.rglob('*') if p.is_file()}
 writing='--write-manifest' in sys.argv[1:]
 expected=REQUIRED if not writing or 'manifest.json' in actual else REQUIRED-{'manifest.json'}
 need(actual==expected,f'file inventory missing={sorted(expected-actual)} extra={sorted(actual-expected)}')
 files={n:{'sha256':hashlib.sha256((ROOT/n).read_bytes()).hexdigest(),
           'bytes':(ROOT/n).stat().st_size} for n in sorted(REQUIRED-{'manifest.json'})}
 if writing:
  (ROOT/'manifest.json').write_text(json.dumps({
   'schema':'subjectnest-pack-hash-manifest-v1',
   'pack':'year-11/mathematical-methods/term-1/weeks-01-02',
   'syllabus_snapshot':'Mathematical Methods 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')

def main():
 source();pedagogy();mathematics();links();assets();hashes()
 print('PACK PASS (offline authored artifacts; not live curriculum 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)
