#!/usr/bin/env python3
"""Read-only-by-default integrity and musical arithmetic audit for Music starter."""
from __future__ import annotations

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

ROOT = Path(__file__).resolve().parent
QCAA = 'https://www.qcaa.qld.edu.au/downloads/senior-qce/syllabuses/snr_music_25_syll.pdf'
LETTERS = 'ABCDEFGH'
PITCH_MIDI = {'C3': 48, 'G3': 55, 'C4': 60, 'D4': 62, 'E4': 64,
              'F4': 65, 'G4': 67, 'A4': 69}
REQUIRED = {
    'README.md', 'CURRICULUM-CROSSWALK.md', 'SCORE-CARDS.md',
    'SOURCE-AND-RIGHTS.md', 'LESSONS.md', 'LEARNER.md',
    'DAILY-CHOICES.md', 'DAILY-EXTRAS.md', 'EXAMPLE-BANK.md',
    'SPOKEN-PROMPTS.md', 'STUDENT-CHECKS.md',
    'teacher/ANSWER-AND-NEXT.md', 'QA-RUN-THROUGH.md',
    'scores.json', 'generate_assets.py', 'verify_pack.py',
    'print/TEXT-ALTERNATIVES.md', 'print/FONT-RIGHTS.md',
    'print/dejavu-font-copyright.txt', 'audio/ASSET-INDEX.md',
}
for letter in LETTERS:
    folder = 'check' if letter in 'GH' else 'print'
    REQUIRED.update((f'{folder}/file-{letter.lower()}.svg',
                     f'{folder}/file-{letter.lower()}.pdf',
                     f'audio/file-{letter.lower()}.wav'))


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


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


def prose(rel: str) -> str:
    body = re.sub(r'\[([^]]+)\]\([^)]+\)', r'\1', get(rel))
    return body.replace('**', '').lower()


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.3' in body,
             f'{rel}: current official syllabus or version absent')
    readme = prose('README.md')
    for phrase in ('unit 1: designs', 'area of study: designs',
                   'not a “topic 1.”', '250 minutes', '55-hour',
                   'not a whole unit', 'no device', 'not been classroom piloted',
                   'first nations', 'cc by 4.0'):
        need(phrase in readme, f'front matter boundary absent: {phrase}')
    cross = prose('CURRICULUM-CROSSWALK.md')
    for phrase in ('printed p 20', 'printed p 21', 'p 22', 'no topic 1',
                   'duration', 'pitch', 'expressive devices', 'texture',
                   'timbre', 'variety of repertoire', 'not school assessment'):
        need(phrase in cross, f'crosswalk boundary absent: {phrase}')
    rights = get('SOURCE-AND-RIGHTS.md')
    for phrase in ('29 September 2026', 'QCAA Music 2025 v1.3',
                   'Files A–F', 'Fresh G/H', 'Eight optional WAVs',
                   'DejaVu', 'CC BY 4.0'):
        need(phrase in rights, f'rights provenance absent: {phrase}')


def score_arithmetic() -> dict:
    data = json.loads(get('scores.json'))
    need(data['schema_version'] == '1.0' and data['tempo_bpm'] == 96,
         'score schema/tempo drift')
    scores = data['scores']
    need(set(scores) == set(LETTERS), 'score inventory drift')
    for letter, score in scores.items():
        need(score['bars'] == (2 if letter == 'H' else 1),
             f'{letter}: wrong bar count')
        slots = score['bars'] * 8
        need(set(score['voices']) == ({'upper', 'lower'} if letter == 'F'
                                      else {'main'}),
             f'{letter}: voice schema drift')
        for voice, events in score['voices'].items():
            timeline = [None] * slots
            for event in events:
                need(len(event) == 4, f'{letter}/{voice}: malformed event')
                start, length, pitch, level = event
                need(isinstance(start, int) and isinstance(length, int)
                     and length in (1, 2, 4) and 0 <= start
                     and start + length <= slots,
                     f'{letter}/{voice}: invalid rhythmic boundary')
                need(pitch == 'rest' or pitch in PITCH_MIDI,
                     f'{letter}/{voice}: unrecognised pitch')
                need(level == ('none' if pitch == 'rest' else level)
                     and level in ('none', 'p', 'mf')
                     and (pitch == 'rest') == (level == 'none'),
                     f'{letter}/{voice}: dynamic/rest mismatch')
                need(not any(timeline[i] is not None
                             for i in range(start, start + length)),
                     f'{letter}/{voice}: overlapping events')
                for i in range(start, start + length):
                    timeline[i] = pitch
            need(all(cell is not None for cell in timeline),
                 f'{letter}/{voice}: silent time must be explicit rest')
            for bar in range(score['bars']):
                included = [event for event in events
                            if bar * 8 <= event[0] < (bar + 1) * 8]
                need(sum(event[1] for event in included) == 8 and
                     all(event[0] + event[1] <= (bar + 1) * 8
                         for event in included),
                     f'{letter}/{voice}: bar {bar + 1} not four beats')
    need(scores['G']['voices']['main'] != scores['B']['voices']['main']
         and scores['H']['voices']['main'] != scores['A']['voices']['main'],
         'fresh score duplicates practice')
    return data


def lessons_and_routes() -> tuple[int, int, int]:
    lesson = get('LESSONS.md')
    counts = {'LEARNER.md': 2, '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)', lesson,
                              flags=re.MULTILINE | re.DOTALL)
        need(len(sections) == 1, f'Day {day}: lesson absent/duplicated')
        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 looking', 'Practice route',
                   'Audit', 'Exit'))
        times = []
        for stage in stages:
            hits = re.findall(rf'\*\*{re.escape(stage)} · (\d+) min\.\*\*',
                              section)
            need(len(hits) == 1, f'Day {day}: {stage} absent/duplicated')
            times.append(int(hits[0]))
        need(times == [2, 4, 5, 7, 4, 3],
             f'Day {day}: timing drift {times}')
        for rel, expected in counts.items():
            rows = re.findall(rf'^\| {day} \|(.+)$', get(rel),
                              flags=re.MULTILINE)
            need(len(rows) == 1, f'Day {day}: {rel} row missing/duplicated')
            cells = [c.strip() for c in rows[0].strip().strip('|').split('|')]
            need(len(cells) == expected and all(cells),
                 f'Day {day}: {rel} cells incomplete')
            if rel == 'DAILY-CHOICES.md':
                need(len(set(cells)) == 3,
                     f'Day {day}: routes repeated')
            if rel == 'DAILY-EXTRAS.md':
                need(all('→' in c for c in cells),
                     f'Day {day}: swap exemplar absent')
    need('neutral scribe' in prose('DAILY-CHOICES.md')
         and 'one musical target' in prose('DAILY-CHOICES.md'),
         'access boundary absent')
    return 30, 20, 20


def fresh_checks() -> None:
    practice = get('SCORE-CARDS.md')
    student = get('STUDENT-CHECKS.md')
    key = get('teacher/ANSWER-AND-NEXT.md')
    for letter in 'ABCDEF':
        need(f'| {letter} ·' in practice, f'practice {letter} missing')
    for letter in 'GH':
        need(f'File {letter}' in student and f'check/file-{letter.lower()}' in student,
             f'fresh check {letter} absent')
        need(f'File {letter}' not in practice,
             f'fresh {letter} leaked into practice cards')
    for phrase in ('not secure school assessment', 'eight half-beat',
                   'four beats', 'rights concern'):
        need(phrase in (student + key).lower(),
             f'check/key missing: {phrase}')
    for rel in ('LEARNER.md', 'DAILY-CHOICES.md', 'STUDENT-CHECKS.md'):
        need('ANSWER-AND-NEXT.md' not in get(rel),
             f'{rel}: direct learner key link')
    need(len(re.findall(r'^\| (?:[1-9]|10) \|', key,
                        flags=re.MULTILINE)) == 10,
         'daily teacher feedback map incomplete')
    need('G' not in re.sub(r'\bG[34]\b', '', get('DAILY-EXTRAS.md').split('| 5 |')[1].split('\n')[0])
         and 'H' not in get('DAILY-EXTRAS.md').split('| 10 |')[1].split('\n')[0],
         'fresh check worked swap leaked')


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


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


def audio_and_print(data: dict) -> None:
    alt = get('print/TEXT-ALTERNATIVES.md')
    normalized_alt = ' '.join(re.sub(r'[^\w]+', ' ', alt.lower()).split())
    need('tactile route' in alt.lower() and
         'DejaVu' in get('print/FONT-RIGHTS.md'),
         'print access/font rights absent')
    ns = {'s': 'http://www.w3.org/2000/svg'}
    for letter, score in data['scores'].items():
        folder = 'check' if letter in 'GH' else 'print'
        svg = ROOT / folder / f'file-{letter.lower()}.svg'
        pdf = ROOT / folder / f'file-{letter.lower()}.pdf'
        root = ET.parse(svg).getroot()
        need(root.attrib.get('width') == '210mm' and
             root.attrib.get('height') == '297mm',
             f'{letter}: SVG not A4')
        title = root.find('s:title', ns)
        desc = root.find('s:desc', ns)
        need(title is not None and desc is not None and
             len(''.join(desc.itertext())) > 100,
             f'{letter}: exact visual description absent')
        for item in root.iter():
            tag = item.tag.split('}')[-1]
            need(tag in {'svg', 'title', 'desc', 'rect', 'text', 'line'}
                 and not any('href' in attr for attr in item.attrib),
                 f'{letter}: unexpected/external SVG content {tag}')
        for item in root.findall('s:text', ns):
            label = ''.join(item.itertext()).strip()
            norm = ' '.join(re.sub(r'[^\w]+', ' ', label.lower()).split())
            need(norm in normalized_alt,
                 f'{letter}: printed label absent from alternative: {label}')
            need(int(item.attrib['x']) < 770 and int(item.attrib['y']) < 1110,
                 f'{letter}: label origin outside page')
        description = ' '.join(re.sub(r'[^\w]+', ' ',
                                        ''.join(desc.itertext()).lower()).split())
        need(description in normalized_alt,
             f'{letter}: description absent from exact alternative')
        extracted = subprocess.run(['pdftotext', str(pdf), '-'], check=True,
                                   capture_output=True, text=True).stdout
        need('SUBJECTNEST' in extracted and len(extracted) > 200,
             f'{letter}: 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'{letter}: PDF not one A4 page')
        wav = ROOT / 'audio' / f'file-{letter.lower()}.wav'
        with wave.open(str(wav), 'rb') as audio:
            need(audio.getnchannels() == 1 and audio.getsampwidth() == 2
                 and audio.getframerate() == 22050,
                 f'{letter}: WAV format drift')
            seconds = score['bars'] * 4 * 60 / data['tempo_bpm']
            need(audio.getnframes() == round(seconds * 22050),
                 f'{letter}: WAV bar duration drift')
            frames = audio.readframes(audio.getnframes())
            need(any(b != 0 for b in frames), f'{letter}: WAV silent')
        if letter != 'F':
            samples = array.array('h')
            samples.frombytes(frames)
            start = int(.08 * 22050)
            end = int(.27 * 22050)
            segment = samples[start:end]
            crossings = sum(a < 0 <= b for a, b in pairwise(segment))
            estimated = crossings / ((end - start) / 22050)
            first_pitch = next(event[2] for event in score['voices']['main']
                               if event[2] != 'rest')
            expected = 440 * 2 ** ((PITCH_MIDI[first_pitch] - 69) / 12)
            need(math.isclose(estimated, expected, abs_tol=7),
                 f'{letter}: audio pitch {estimated:.1f} vs {expected:.1f}')


def manifest(write: bool) -> int:
    paths = sorted(str(p.relative_to(ROOT)) for p in ROOT.rglob('*')
                   if p.is_file() and p.name != 'manifest.json'
                   and '.ruff_cache' not in p.parts
                   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((ROOT / path).read_bytes()).hexdigest()
        for path in paths}}
    target = ROOT / '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()
    data = score_arithmetic()
    routes, swaps, bridges = lessons_and_routes()
    fresh_checks()
    link_count = links()
    audio_and_print(data)
    file_count = manifest(args.write_manifest)
    print(f'PASS: 10 x 25 min; {routes} routes; {swaps} worked swaps; '
          f'{bridges} context bridges; 2 fresh checks; 6 regular + 2 fresh '
          f'A4 scores; 8 original WAVs; {link_count} local links; '
          f'{file_count} hashed files')


if __name__ == '__main__':
    main()
