#!/usr/bin/env python3
"""Fail-closed offline source/structure/link/asset/hash check for Digital Solutions starter."""
from __future__ import annotations

import hashlib
import json
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=('need-map','scope-boundary','criterion-test','impact-lens','decision-grid')
REQUIRED={
 'README.md','TEACHER-GUIDE.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','problem-framing-lab.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':'Digital Solutions','pathway':'General senior syllabus',
  'current_version':'Digital Solutions 2025 v1.4','supersedes_requested_version':'Digital Solutions 2025 v1.3',
  'cover_date':'March 2026','running_header_date':'February 2026',
  'unit':'Unit 1: Creating with code','unit_pdf_page':17,'unit_printed_page':15,
  'topic':'Topic 1: Understanding digital problems','topic_pdf_page':18,'topic_printed_page':16,
  'topic_top_level_bullets':8,'technology_context':'Web applications','unit_notional_hours':55,
 }
 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_digital_solutions_25_syll.pdf','source URL')
 c=read('SOURCE-AND-CROSSWALK.md')
 for phrase in ('v1.4','v1.3','March 2026','February 2026','PDF p. 18 / printed p. 16',
                '250 minutes = 4 hours 10 minutes','web applications','not QCAA codes',
                'not secure exams','T1.1','T1.2','T1.3','T1.4','T1.5','T1.6','T1.7','T1.8'):
  need(phrase.lower() in c.lower(),f'crosswalk missing {phrase}')
 print('PASS current official QCAA Digital Solutions v1.4 Topic 1 pin and eight partial rows')

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'lesson 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(x in c for x 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 original clean 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 domain 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 ('lantern festival','community sound archive descriptions','24 approved','four topic tags'):
  need(token.lower() in checks.lower() and token.lower() not in cards.lower() and token.lower() not in swaps.lower(),f'fresh check marker {token}')
 for phrase in ('10 am and 2 pm','not secure exams','reader, task'):
  need(phrase.lower() in checks.lower(),f'check boundary missing {phrase}')
 for phrase in ('live stock','twice-daily','four-topic filter','no training','not secure exams'):
  need(phrase.lower() in key.lower(),f'key missing {phrase}')
 print('PASS ten timed days, 30 routes, ten cards, 20 swaps and two fresh public 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('problem-framing-lab.html')
 for raw in re.findall(r'href="([^"]+)"',html):
  need((ROOT/raw).is_file(),f'broken lab 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 A4')
  need(svg.find(ns+'title') is not None and svg.find(ns+'desc') is not None,f'{stem} SVG title/description')
  need(f'{stem}.svg' in alt and f'{stem}.pdf' in alt and 'Tactile route' in alt,f'{stem} full 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('WORKED FICTIONAL EXAMPLE' in pdf and len(pdf.strip())>230,f'{stem} selectable PDF text')
 html=read('problem-framing-lab.html')
 for text in ('lang="en-AU"','aria-live="polite"','<details>','function render()','function check()',
              'need','constraint','missing','criterion','scene.addEventListener','Paper route'):
  need(text in html,f'lab missing {text}')
 for bad in ('<script src=','fetch(', 'XMLHttpRequest','sendBeacon','localStorage','<iframe'):
  need(bad not in html,f'lab external/storage boundary {bad}')
 print('PASS five A4 SVG/PDF/full-text/tactile aids and offline lab 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/digital-solutions/term-1/weeks-01-02',
   'syllabus_snapshot':'Digital Solutions 2025 v1.4, 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 regenerating')
  print(f'PASS SHA-256 manifest for {len(files)} files')

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