#!/usr/bin/env python3
"""Regenerate the complete playable SFZ catalog from the pinned installation."""
import argparse
import hashlib
import json
from pathlib import Path
import re
import sys

PACK = Path(__file__).resolve().parent
ROOT = PACK / 'assets/sso/Sonatina Symphonic Orchestra'


def slug(text):
    return re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')


def midi(value):
    if re.fullmatch(r'-?\d+', value):
        return int(value)
    match = re.fullmatch(r'([a-g])([#b]?)(-?\d+)', value.lower())
    if not match:
        raise ValueError(f'unsupported SFZ note: {value}')
    letter, accidental, octave = match.groups()
    return 12 * (int(octave) + 1) + dict(c=0, d=2, e=4, f=5, g=7, a=9, b=11)[letter] + {'': 0, '#': 1, 'b': -1}[accidental]


def expanded(path, stack=(), defines=None):
    """Follow upstream includes and simple defines; this is catalog metadata only."""
    if defines is None:
        defines = {}
    path = path.resolve()
    if path in stack:
        raise ValueError(f'cyclic SFZ include: {path}')
    path.relative_to(ROOT.resolve())
    lines = []
    for line in path.read_text(encoding='utf-8-sig').splitlines():
        definition = re.match(r'\s*#define\s+(\$\w+)\s+(.*)', line)
        if definition:
            defines[definition[1]] = definition[2].strip()
            continue
        line = re.sub(r'\$\w+', lambda m: defines.get(m[0], m[0]), line)
        include = re.match(r'\s*#include\s+"([^"]+)"', line)
        if include:
            lines.append(expanded(path.parent / include[1].replace(chr(92), '/'), (*stack, path), defines))
        else:
            lines.append(line)
    return '\n'.join(lines)


def build():
    manifest = json.loads((PACK / 'manifest.json').read_text())
    sfzs = []
    for row in manifest['files']:
        if not row['path'].endswith('.sfz'):
            continue
        path = PACK / 'assets' / row['path']
        data = path.read_bytes()
        if len(data) != row['bytes'] or hashlib.sha256(data).hexdigest() != row['sha256']:
            raise ValueError(f'installed SFZ differs from pin: {path}')
        relative = path.relative_to(ROOT)
        if 'includes' not in relative.parts:
            sfzs.append(relative)
    entries = []
    for relative in sorted(sfzs, key=str):
        path = ROOT / relative
        text = expanded(path)
        group = str(relative.parent)
        if group == '.':
            group = 'Other'
        labels = dict((int(n), label.strip()) for n, label in re.findall(r'\blabel_cc(\d+)\s*=\s*([^\r\n]+)', text))
        defaults = dict((int(n), int(value)) for n, value in re.findall(r'\bset_cc(\d+)\s*=\s*(\d+)', text))
        normalized = dict((int(n), float(value)) for n, value in re.findall(r'\bset_hdcc(\d+)\s*=\s*([0-9.]+)', text))
        switches = []
        for block in re.split(r'<global>', path.read_text())[1:]:
            note = re.search(r'\bsw_last\s*=\s*(\S+)', block)
            label = re.search(r'\bsw_label\s*=\s*([^\r\n]+)', block)
            if note and label:
                switches.append({'note': midi(note[1]), 'sfz_note': note[1], 'label': label[1].strip()})
        entries.append({
            'id': slug(str(relative.with_suffix(''))),
            'name': relative.stem,
            'group': group,
            'path': 'sso/Sonatina Symphonic Orchestra/' + relative.as_posix(),
            'keyswitches': switches,
            'controllers': [{'cc': cc, 'label': labels.get(cc, ''), **({'default': defaults[cc]} if cc in defaults else {}), **({'default_normalized': normalized[cc]} if cc in normalized else {})} for cc in sorted(labels.keys() | defaults.keys() | normalized.keys())],
        })
    assert len(set(row['id'] for row in entries)) == len(entries), 'duplicate patch IDs'
    document = {'upstream': manifest['repositories']['sso'], 'patch_count': len(entries), 'patches': entries}
    lines = ['# Sonatina 4.0 patch catalog', '', 'Generated by `python3 contrib/sonatina/export-catalog.py` from the pinned upstream SFZs.', '', f'All **{len(entries)} playable patches** are included. SFZ include fragments are implementation details, not additional instruments.', '', 'Paths are relative to `assets/sso/Sonatina Symphonic Orchestra/`. Use the exact ID with the pack\'s state setup tool. MIDI key numbers avoid octave-name differences between hosts.', '']
    last_group = None
    for entry in entries:
        if entry['group'] != last_group:
            last_group = entry['group']
            lines += ['', f'## {last_group}', '', '| Patch ID | SFZ | Keyswitches (MIDI number: articulation) | Labeled / initialized CCs |', '| --- | --- | --- | --- |']
        switches = '; '.join(f"{s['note']}: {s['label']}" for s in entry['keyswitches']) or '—'
        ccs = '; '.join(f"{c['cc']} {c['label']}" + (f" (default {c['default']})" if 'default' in c else '') + (f" (normalized default {c['default_normalized']})" if 'default_normalized' in c else '') for c in entry['controllers']) or '—'
        lines.append(f"| `{entry['id']}` | {entry['name']}.sfz | {switches} | {ccs} |")
    return {'catalog.json': json.dumps(document, indent=2) + '\n', 'CATALOG.md': '\n'.join(lines) + '\n'}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--check', action='store_true', help='check generated files without writing')
    args = parser.parse_args()
    changed = []
    for name, contents in build().items():
        target = PACK / name
        if args.check:
            if not target.exists() or target.read_text() != contents:
                changed.append(name)
        else:
            target.write_text(contents)
    if changed:
        print('out of date: ' + ', '.join(changed), file=sys.stderr)
        return 1
    print('catalog verified' if args.check else 'catalog exported')
    return 0


if __name__ == '__main__':
    sys.exit(main())
