#!/usr/bin/env python3
"""Fail-closed integrity and independent finite-set audit of this opener."""
from __future__ import annotations

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

PACK = Path(__file__).resolve().parent
QCAA = 'https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_maths_specialist_25_syll.pdf'
STEMS = ('two-set-map', 'three-set-map', 'sum-product-switch',
         'branch-tree', 'region-audit', 'method-chooser')
REQUIRED = {
    'README.md', 'CURRICULUM-CROSSWALK.md', 'CASE-CARDS.md',
    'LESSONS.md', 'LEARNER.md', 'DAILY-CHOICES.md',
    'DAILY-EXTRAS.md', 'EXAMPLE-BANK.md', 'SPOKEN-PROMPTS.md',
    'STUDENT-CHECKS.md', 'SOURCE-AND-RIGHTS.md', 'QA-RUN-THROUGH.md',
    'teacher/ANSWER-AND-NEXT.md', 'verify_pack.py',
    'print/generate_print.py', 'print/TEXT-ALTERNATIVES.md',
    'print/FONT-RIGHTS.md', 'print/dejavu-font-copyright.txt',
}
for stem in STEMS:
    REQUIRED.update((f'print/{stem}.svg', f'print/{stem}.pdf'))


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


def get(rel: str) -> str:
    return (PACK / rel).read_text(encoding='utf-8')


def scope() -> None:
    for rel in ('README.md', 'CURRICULUM-CROSSWALK.md',
                'SOURCE-AND-RIGHTS.md'):
        body = get(rel)
        need(QCAA in body and '2025 v1.4' in body,
             f'{rel}: official syllabus version/link absent')
    cross = get('CURRICULUM-CROSSWALK.md').lower()
    for phrase in ('topic 1', '4 hours', '8 hours', '250 minutes',
                   '55-hour', 'printed pp 16–17',
                   'not secure school instruments'):
        need(phrase in cross, f'curriculum scope absent: {phrase}')
    readme = get('README.md').lower()
    for phrase in ('does not', 'permutations', '6', 'not secure',
                   'no classroom pilot', 'six original a4'):
        need(phrase in readme, f'readme boundary absent: {phrase}')
    rights = get('SOURCE-AND-RIGHTS.md')
    for identifier in ('| QCAA |', '| A–F |', '| G · fresh Day 5 |',
                       '| H/I · fresh Day 10 |', '| A4 aids |'):
        need(identifier in rights, f'source row absent: {identifier}')
    need('29 September 2026' in rights and 'CC BY 4.0' in rights,
         'dated rights record absent')


def daily() -> tuple[int, int]:
    lessons = get('LESSONS.md')
    tables = {'LEARNER.md': 1, 'DAILY-CHOICES.md': 3,
              'DAILY-EXTRAS.md': 2, 'EXAMPLE-BANK.md': 2,
              'SPOKEN-PROMPTS.md': 1}
    for day in range(1, 11):
        sections = re.findall(rf'^### Day {day} ·.*?(?=^### Day |\Z)',
                              lessons, flags=re.MULTILINE | re.DOTALL)
        need(len(sections) == 1, f'Day {day}: missing/repeated lesson')
        section = sections[0]
        need('**Target:**' in section and '**Prepare:**' in section,
             f'Day {day}: target/preparation absent')
        stages = (('Launch', 'Source access', 'Independent plan',
                   'Independent response', 'Self-audit', 'Submit')
                  if day in (5, 10) else
                  ('Launch', 'Model', 'Guided reading', 'Practice route',
                   'Audit', 'Exit'))
        minutes = []
        for stage in stages:
            found = re.findall(rf'\*\*{re.escape(stage)} · (\d+) min\.\*\*',
                               section)
            need(len(found) == 1, f'Day {day}: {stage} missing/repeated')
            minutes.append(int(found[0]))
        need(minutes == [2, 4, 5, 7, 4, 3],
             f'Day {day}: 25-minute timing drift {minutes}')
        for rel, count in tables.items():
            found = re.findall(rf'^\| {day} \|(.+)$', get(rel),
                               flags=re.MULTILINE)
            need(len(found) == 1, f'Day {day}: row in {rel} missing/repeated')
            cells = [cell.strip() for cell in
                     found[0].strip().strip('|').split('|')]
            need(len(cells) == count and all(cells),
                 f'Day {day}: {rel} row incomplete')
            if rel == 'DAILY-CHOICES.md':
                need(len(set(cells)) == 3, f'Day {day}: duplicate route')
            if rel == 'DAILY-EXTRAS.md':
                need(all('→' in cell for cell in cells),
                     f'Day {day}: a swap has no worked result')
    choices = get('DAILY-CHOICES.md').lower()
    need('reading or handwriting ability is considered separately' in choices,
         'reading/reasoning access separation absent')
    return 30, 20


def checks() -> None:
    practice = get('CASE-CARDS.md')
    student = get('STUDENT-CHECKS.md')
    teacher = get('teacher/ANSWER-AND-NEXT.md')
    for letter in 'ABCDEF':
        need(len(re.findall(rf'^## {letter} ·', practice,
                            flags=re.MULTILINE)) == 1,
             f'practice File {letter} absent/repeated')
    for letter in 'GHI':
        need(f'**New File {letter}.**' in student,
             f'fresh File {letter} absent')
        need(f'## {letter} ·' not in practice,
             f'fresh File {letter} leaked into practice')
    for rel in ('LEARNER.md', 'DAILY-CHOICES.md', 'STUDENT-CHECKS.md'):
        need('ANSWER-AND-NEXT.md' not in get(rel),
             f'learner-facing direct link to key: {rel}')
    need('public by URL' in student and 'public by URL' in teacher and
         'not secure QCAA' in teacher,
         'public/formal assessment boundary absent')
    need(len(re.findall(r'^\| (?:[1-9]|10) \|', teacher,
                        flags=re.MULTILINE)) == 10,
         'daily feedback rows absent')
    for phrase in ('pairwise count', 'all three', 'exactly one exclusive',
                   'every pairing allowed'):
        need(phrase in student.lower(),
             f'fresh-case counting assumption absent: {phrase}')


def sets_from_regions(regions: dict[str, int], labels: str
                      ) -> tuple[dict[str, set[str]], int]:
    sets: dict[str, set[str]] = {label: set() for label in labels}
    total = 0
    for region, size in regions.items():
        need(size >= 0, f'negative region {region}')
        for index in range(size):
            token = f'{region}-{index}'
            for label in labels:
                if label in region:
                    sets[label].add(token)
        total += size
    return sets, total


def union3(a: int, b: int, c: int,
           ab: int, ac: int, bc: int, abc: int) -> int:
    return a + b + c - ab - ac - bc + abc


def arithmetic() -> None:
    cards = get('CASE-CARDS.md')
    student = get('STUDENT-CHECKS.md')
    key = get('teacher/ANSWER-AND-NEXT.md')
    swaps = get('DAILY-EXTRAS.md')
    a_sets, a_total = sets_from_regions(
        {'S': 12, 'C': 8, 'SC': 5, 'N': 5}, 'SC')
    need((a_total, len(a_sets['S']), len(a_sets['C']),
          len(a_sets['S'] & a_sets['C']),
          len(a_sets['S'] | a_sets['C'])) == (30, 17, 13, 5, 25),
         'A region audit')
    need('17+13−5 = 25' in cards and '12+5+8+5=30' in cards,
         'A written values drift')
    b_both = 22 + 18 - 32
    b_neither = 40 - 32
    need((b_both, b_neither) == (8, 8), 'B inverse count')
    b_sets, b_total = sets_from_regions(
        {'D': 14, 'M': 10, 'DM': 8, 'N': 8}, 'DM')
    need((b_total, len(b_sets['D']), len(b_sets['M']),
          len(b_sets['D'] | b_sets['M'])) == (40, 22, 18, 32),
         'B region audit')
    c_regions = {'A': 7, 'R': 4, 'C': 3, 'AR': 2,
                 'AC': 1, 'RC': 3, 'ARC': 2, 'N': 6}
    c_sets, c_total = sets_from_regions(c_regions, 'ARC')
    need((c_total, *(len(c_sets[x]) for x in 'ARC'),
          len(c_sets['A'] & c_sets['R']),
          len(c_sets['A'] & c_sets['C']),
          len(c_sets['R'] & c_sets['C']),
          len(c_sets['A'] & c_sets['R'] & c_sets['C']),
          len(set.union(*c_sets.values())))
         == (28, 12, 11, 9, 4, 3, 5, 2, 22),
         'C region construction')
    need(union3(12, 11, 9, 4, 3, 5, 2) == 22 and
         '12+11+9−4−3−5+2 = 22' in cards,
         'C formula or printed result drift')
    d_outcomes = [('studio', i) for i in range(3)] + [
        ('outdoor', i) for i in range(4)]
    e_outcomes = list(itertools.product(range(3), range(4)))
    f_visual = [('visual', *pair) for pair in
                itertools.product(range(2), range(3))]
    f_audio = [('audio', *pair) for pair in
               itertools.product(range(2), range(2))]
    need((len(set(d_outcomes)), len(set(e_outcomes)),
          len(set(f_visual + f_audio))) == (7, 12, 10),
         'D/E/F outcome enumeration')
    need(all(text in cards for text in ('3+4=7', '3×4=12',
                                       '2×3 + 2×2 = 10')),
         'D/E/F printed counts drift')
    g_sets, g_total = sets_from_regions(
        {'D': 13, 'C': 8, 'DC': 6, 'N': 9}, 'DC')
    g_bookings = [('indoor', i) for i in range(2)] + [
        ('outdoor', i) for i in range(3)]
    need((g_total, len(g_sets['D']), len(g_sets['C']),
          len(g_sets['D'] & g_sets['C']),
          len(g_sets['D'] | g_sets['C']), len(g_bookings))
         == (36, 19, 14, 6, 27, 5), 'G fresh enumeration')
    need(all(text in key for text in ('19+14−6=27', '36−27=9',
                                     '13+6+8+9=36', '2+3=5')),
         'G key result drift')
    h_regions = {'A': 6, 'C': 6, 'M': 5, 'AC': 4,
                 'AM': 3, 'CM': 2, 'ACM': 2, 'N': 8}
    h_sets, h_total = sets_from_regions(h_regions, 'ACM')
    need((h_total, *(len(h_sets[x]) for x in 'ACM'),
          len(h_sets['A'] & h_sets['C']),
          len(h_sets['A'] & h_sets['M']),
          len(h_sets['C'] & h_sets['M']),
          len(h_sets['A'] & h_sets['C'] & h_sets['M']),
          len(set.union(*h_sets.values())))
         == (36, 15, 14, 12, 6, 5, 4, 2, 28),
         'H fresh region construction')
    i_poster = [('poster', *pair) for pair in
                itertools.product(range(3), range(2))]
    i_audio = [('audio', *pair) for pair in
               itertools.product(range(2), range(3))]
    need((union3(15, 14, 12, 6, 5, 4, 2),
          len(set(i_poster + i_audio))) == (28, 12),
         'H/I formula or branch enumeration')
    need(all(text in key for text in ('15+14+12−6−5−4+2=28',
                                     '36−28=8', '3×2=6', '2×3=6',
                                     '6+6=12')),
         'H/I key result drift')
    need('19+14−6=27' not in student and
         '15+14+12−6−5−4+2=28' not in student,
         'fresh answer leaked into check')
    expected_swaps = {
        1: ('18', '16'), 2: ('5', '4'), 3: ('15', '18'),
        4: ('7', '10'), 5: ('13', '5'), 6: ('12', '10'),
        7: ('10', '14'), 8: ('11', 'at least 2'),
        9: ('16', '9'), 10: ('17', '12'),
    }
    for day, (first, second) in expected_swaps.items():
        row = re.search(rf'^\| {day} \|(.+)$', swaps, flags=re.MULTILINE)
        need(row is not None, f'Day {day} swap row absent')
        cells = [cell.strip() for cell in row.group(1).strip('|').split('|')]
        need(first in cells[0] and second in cells[1],
             f'Day {day} swap arithmetic/result drift')


def slug(title: str) -> str:
    title = re.sub(r'<[^>]+>', '', title.lower())
    title = re.sub(r'[^\w\- ]', '', title)
    return title.replace(' ', '-')


def links() -> int:
    count = 0
    for md in PACK.rglob('*.md'):
        for url in re.findall(r'\[[^]]+\]\(([^)]+)\)',
                              md.read_text(encoding='utf-8')):
            if url.startswith(('https://', 'http://', 'mailto:')):
                continue
            base, _, anchor = unquote(url).partition('#')
            dest = (md.parent / base).resolve() if base else md
            need(dest.exists() and dest.is_relative_to(PACK),
                 f'broken/escaping link {md.relative_to(PACK)} -> {url}')
            if anchor and dest.suffix == '.md':
                heads = re.findall(r'^#{1,6} (.+)$',
                                   dest.read_text(encoding='utf-8'),
                                   flags=re.MULTILINE)
                need(anchor in {slug(head) for head in heads},
                     f'broken anchor {md.relative_to(PACK)} -> {url}')
            count += 1
    return count


def aids() -> None:
    alternatives = get('print/TEXT-ALTERNATIVES.md')
    normal = ' '.join(re.sub(r'[^\w]+', ' ', alternatives.lower()).split())
    need('tactile' in alternatives.lower() and
         'DejaVu' in get('print/FONT-RIGHTS.md'),
         'print access or rights absent')
    ns = {'s': 'http://www.w3.org/2000/svg'}
    for stem in STEMS:
        svg = PACK / f'print/{stem}.svg'
        pdf = PACK / f'print/{stem}.pdf'
        root = ET.parse(svg).getroot()
        need(root.attrib.get('width') == '210mm' and
             root.attrib.get('height') == '297mm', f'{stem}: not A4')
        need(root.find('s:title', ns) is not None and
             root.find('s:desc', ns) is not None,
             f'{stem}: SVG title/description absent')
        for element in root.findall('s:text', ns):
            words = ''.join(element.itertext()).strip()
            normalized = ' '.join(re.sub(r'[^\w]+', ' ', words.lower()).split())
            need(normalized in normal,
                 f'{stem}: visual line absent from exact text: {words}')
            need(int(element.attrib['x']) < 750 and
                 int(element.attrib['y']) < 1120,
                 f'{stem}: text origin outside A4 viewBox')
        extracted = subprocess.run(['pdftotext', str(pdf), '-'], check=True,
                                   capture_output=True, text=True).stdout
        need(len(extracted) > 300 and 'SPECIALIST MATHS' in extracted,
             f'{stem}: searchable PDF text absent')
        info = subprocess.run(['pdfinfo', str(pdf)], check=True,
                              capture_output=True, text=True).stdout
        need('Pages:           1' in info and 'A4' in info,
             f'{stem}: not a one-page A4 PDF')
        need(stem.replace('-', ' ') in alternatives.lower().replace('-', ' '),
             f'{stem}: exact alternative absent')


def manifest(write: bool) -> int:
    paths = sorted(str(p.relative_to(PACK)) for p in PACK.rglob('*')
                   if p.is_file() and p.name != 'manifest.json'
                   and '__pycache__' not in p.parts)
    need(set(paths) == REQUIRED,
         f'inventory drift: missing {sorted(REQUIRED-set(paths))}, '
         f'extra {sorted(set(paths)-REQUIRED)}')
    expected = {'schema_version': '1.0', 'files': {
        path: hashlib.sha256((PACK / path).read_bytes()).hexdigest()
        for path in paths}}
    target = PACK / 'manifest.json'
    if write:
        target.write_text(json.dumps(expected, indent=2, sort_keys=True) + '\n',
                          encoding='utf-8')
    else:
        need(target.exists(), 'SHA-256 manifest absent')
        need(json.loads(target.read_text(encoding='utf-8')) == expected,
             'SHA-256 manifest drift')
    return len(paths)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument('--write-manifest', action='store_true')
    args = parser.parse_args()
    scope()
    routes, swaps = daily()
    checks()
    arithmetic()
    link_count = links()
    aids()
    file_count = manifest(args.write_manifest)
    print(f'PASS: 10 x 25 minutes; {routes} routes; {swaps} worked swaps; '
          f'2 fresh public checks; 6 A4 aid pairs; independently enumerated '
          f'counts; {link_count} local links; {file_count} hashed source files')


if __name__ == '__main__':
    main()
