#!/usr/bin/env python3
"""Fail-closed local checks for this authored pack. No live web freshness claim."""
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
REQUIRED = {
    'README.md', 'LESSONS.md', 'LEARNER-CARDS.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_budget_sheet.py', 'print/budget-what-if.xlsx',
}
STEMS = ('period-converter', 'payment-models', 'budget-map', 'price-base', 'interest-timeline')
REQUIRED.update(f'print/{stem}.{ext}' for stem in STEMS for ext in ('svg','pdf'))


def need(condition: bool, message: str) -> None:
    if not condition:
        raise AssertionError(message)


def read(name: str) -> str:
    return (ROOT / name).read_text(encoding='utf-8')


def run(*args: str) -> str:
    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() -> None:
    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 key, value in pin.items():
        need(s.get(key) == value, f'source snapshot {key} drifted')
    need(s['syllabus_url'].startswith('https://www.qcaa.qld.edu.au/'), 'QCAA URL drifted')
    need(s['ato_gst_rule_url'].startswith('https://www.ato.gov.au/'), 'ATO URL drifted')
    crosswalk = read('SOURCE-AND-CROSSWALK.md')
    for i in range(1,10):
        need(f'bullet {i} |' in crosswalk or f'bullet {i} and' in crosswalk, f'QCAA bullet {i} missing')
    for phrase in ('General Mathematics 2025 v1.3', 'PDF page 17 / printed page 15',
                   '14 hours', '55 notional hours', 'not an official code', 'Unaddressed',
                   'does **not** establish that a learner used a spreadsheet'):
        need(phrase.lower() in crosswalk.lower(), f'source boundary missing: {phrase}')
    print('PASS source identity and nine-row partial crosswalk (offline snapshot)')


def pedagogy() -> None:
    lessons = read('LESSONS.md')
    heads = [int(x) for x in re.findall(r'^## Day (\d+) ·', lessons, flags=re.MULTILINE)]
    need(heads == list(range(11,21)), f'days not 11–20 exactly: {heads}')
    sections = re.split(r'^## Day \d+ ·', lessons, flags=re.MULTILINE)[1:]
    need(len(sections)==10, 'wrong day section count')
    for day, sec in zip(range(11,21), sections):
        need('**Routes:**' in sec and sec.count('**Routes:**')==1, f'Day {day} routes')
        route = re.search(r'\*\*Routes:\*\* ([^\n]+)', sec)
        need(route is not None and route.group(1).count(';') >= 2, f'Day {day} needs 3 routes')
        for tag in ('**Alternative domain:**', '**Optional/home:**', '**Move:**', '**Goal:**'):
            need(tag in sec, f'Day {day} missing {tag}')
        need(('25 = 2 + 3 + 12 + 5 + 3' if day in (15,20) else '25 = 2 + 5 + 6 + 7 + 5') in sec,
             f'Day {day} timing')
        for start in ('0–2:', '2–5:' if day in (15,20) else '2–7:',
                      '5–17:' if day in (15,20) else '7–13:',
                      '17–22:' if day in (15,20) else '13–20:',
                      '22–25:' if day in (15,20) else '20–25:'):
            need(start in sec, f'Day {day} missing clock {start}')
    learner = read('LEARNER-CARDS.md')
    need([x for x in re.findall(r'^## Card ([A-G]) ·', learner, re.MULTILINE)]==list('ABCDEFG'), 'cards A–G')
    check = read('STUDENT-CHECKS.md')
    need(re.findall(r'^## Check ([AB]) ·', check, re.MULTILINE)==['A','B'], 'two learner checks')
    key = read('teacher/KEY-AND-NEXT.md')
    need('public' in check.lower() and 'public' in key.lower(), 'public-key boundary')
    for amount in ('$70,200', '$190', '$900', '$135', '$99', '$1,800'):
        need(amount in check, f'fresh check input missing {amount}')
        need(amount not in learner, f'check input reused in core learner cards {amount}')
    need(re.search(r'not (?:a )?secure', lessons, re.IGNORECASE) and re.search(r'not (?:a )?secure', check, re.IGNORECASE), 'security scope')
    print('PASS 10 timed days, 3 routes/day, 7 cards, 2 fresh public checks')


def arithmetic() -> None:
    key = read('teacher/KEY-AND-NEXT.md')
    salary = D(65520)
    need([salary/D(i) for i in (52,26,12)]==[D(1260),D(2520),D(5460)], 'salary arithmetic')
    need(D(32)*5==D(160) and D(9)*20==D(180) and D(9)*17==D(153) and D(9)*18==D(162), 'payment/threshold arithmetic')
    need([D(180)+D('.045')*D(s) for s in (800,1200,1600)]==[D(216),D(234),D(252)], 'commission arithmetic')
    need(D(520)+D(120)+D(40)==D(680), 'fixed subtotal')
    need(D(1260)-D(680)-D(220)-D(110)==D(250), 'budget arithmetic')
    need(D(84)*D('1.10')==D('92.40') and D(121)/D('1.10')==D(110), 'GST arithmetic')
    need(D(2400)*D('.035')*2==D(168), 'simple interest')
    need([D(1260)-D(f)-D(e)-D(x) for f,e,x in [(680,220,110),(680,250,125),(700,230,85)]]==[D(250),D(205),D(245)], 'weekly formula')
    need(D(1260)-D(680)-D(260)-D(125)==D(195), 'single-input what-if')
    need([D(70200)/D(i) for i in (52,26,12)]==[D(1350),D(2700),D(5850)], 'Check A salary')
    need(D(190)+D('.05')*D(900)==D(235), 'Check A commission')
    need(D(135)*D('1.10')==D('148.50') and D(99)/D('1.10')==D(90), 'Check B GST')
    need(D(1800)*D('.04')*D('1.5')==D(108), 'Check B interest')
    for answer in ('$1,260','$2,520','$5,460','$160','$180','$153','$162','$216','$234','$252',
                   '$680','$1,010','$250','$92.40','$110','$168','$2,568','$205','$245','$195',
                   '$1,350','$2,700','$5,850','$45','$235','$13.50','$148.50','$90','$108','$1,908'):
        need(answer in key, f'worked key omits {answer}')
    print('PASS independent Decimal arithmetic for core, what-if and held-out checks')


def links() -> None:
    checked=0
    for file in sorted(ROOT.rglob('*.md')):
        source=file.read_text(encoding='utf-8')
        for raw in re.findall(r'!?\[[^\]]+\]\(([^)]+)\)', source):
            url=unquote(raw.split('#',1)[0])
            if not url or url.startswith(('https://','http://','mailto:')):
                continue
            target=(file.parent/url).resolve()
            need(target.exists(), f'broken link {file.relative_to(ROOT)} -> {raw}')
            checked+=1
    print(f'PASS {checked} local Markdown links')


def assets() -> None:
    alt=read('print/TEXT-ALTERNATIVES.md')
    for stem in STEMS:
        svg=ROOT/'print'/f'{stem}.svg'
        root=ET.parse(svg).getroot()
        need(root.attrib.get('width')=='210mm' and root.attrib.get('height')=='297mm', f'{stem} SVG not A4')
        ns='{http://www.w3.org/2000/svg}'
        need(root.find(ns+'title') is not None and root.find(ns+'desc') is not None, f'{stem} SVG lacks accessible title/desc')
        need(f'{stem}.svg' in alt and f'{stem}.pdf' in alt and 'Tactile/large-text route' in alt, f'{stem} alternative missing')
        pdf=ROOT/'print'/f'{stem}.pdf'
        info=run('pdfinfo',str(pdf))
        need('Pages:           1' in info and '595.276 x 841.89 pts (A4)' in info, f'{stem} PDF not single A4')
        extracted=run('pdftotext',str(pdf),'-')
        need(len(extracted.strip())>160, f'{stem} PDF text not extractable')
    w=load_workbook(ROOT/'print/budget-what-if.xlsx',data_only=False,read_only=True)
    s=w.active
    need(s.title=='Invented weeks', 'worksheet title')
    for r, expected in [(3,('A',1260,680,220,110)),(4,('B',1260,680,250,125)),(5,('C',1260,700,230,85))]:
        need(tuple(s.cell(r,c).value for c in range(1,6))==expected, f'worksheet row {r} input')
        need(s.cell(r,6).value==f'=B{r}-C{r}-D{r}-E{r}', f'worksheet row {r} formula')
        need(s.cell(r,7).value==f'=IF(F{r}>=200,"MEETS","BELOW")', f'worksheet row {r} target formula')
    w.close()
    print('PASS five A4 SVG/PDF/text pairs and offline formula worksheet')


def file_map() -> dict[str,dict[str,int|str]]:
    actual={p.relative_to(ROOT).as_posix() for p in ROOT.rglob('*') if p.is_file()}
    need(actual==REQUIRED, f'file inventory differs; 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() -> None:
    source();pedagogy();arithmetic();links();assets()
    files=file_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-03-04',
            '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:
        m=json.loads(read('manifest.json'))
        need(m.get('schema')=='subjectnest-pack-hash-manifest-v1' and m.get('files')==files,
             'hash manifest mismatch; re-authorize regeneration after review')
        print(f'PASS manifest hashes for {len(files)} files')
    print('PACK PASS (authored artifacts and pinned-source map; no live classroom or web revalidation)')

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