#!/usr/bin/env python3
"""setlist_samples — what samples does a set actually use?

Parses `s "..."` / `sound "..."` calls out of .tidal tracks, resolves each
sample name to its on-disk folder across the SuperDirt sample roots, and emits
a preload plan. This feeds the smart-preload boot step (task #26): warm exactly
the set's samples before the gig instead of lazy-loading them (crackle + xruns)
on first play mid-set — without loading the whole library.

Usage:
  tools/setlist_samples.py TRACK.tidal [TRACK2.tidal ...]
  tools/setlist_samples.py --last 10        # 10 most-recently-edited live/ tracks
  tools/setlist_samples.py --last 30 --emit-sc   # print SC preload snippet
"""
import re, sys, argparse
from pathlib import Path

HOME = Path.home()
# Sample roots, in the order start_and_midi.scd loads them.
SAMPLE_ROOTS = [
    HOME / ".local/share/SuperCollider/downloaded-quarks/Dirt-Samples",
    HOME / "Work/Sound/Samples/extra",
]
DRUM_MACHINES = HOME / "Work/Sound/Samples/tidal-drum-machines/machines"
LIVE_DIR = HOME / "Work/Sound/Tidal/live"

# ParVagues' dialect names samples via bare strings and `# "x"`, not `s "..."`,
# and mixes them with structure/notes/MIDI-ctrl strings in the same quotes. So
# we extract EVERY word-token from EVERY quoted string and let the on-disk
# sample library be the filter (a token is a sample iff a folder exists for it).
QUOTED = re.compile(r'"([^"]*)"')
WORD = re.compile(r'[A-Za-z][A-Za-z0-9_]*')
# Tidal boolean literals — collide with real Dirt-Samples/{t,f} folders but are
# never meant as samples in the mini-notation, so never preload them.
STOP = {'t', 'f'}
LINE_COMMENT = re.compile(r'--.*$', re.MULTILINE)
BLOCK_COMMENT = re.compile(r'\{-.*?-\}', re.DOTALL)


def extract_names(text):
    """Every candidate token appearing in a quoted string (resolution filters)."""
    text = BLOCK_COMMENT.sub(' ', text)
    text = LINE_COMMENT.sub('', text)
    names = set()
    for s in QUOTED.findall(text):
        names.update(WORD.findall(s))
    return names


def build_index():
    """name -> folder Path, across Dirt-Samples, extra/, and drum machines."""
    idx = {}
    for root in SAMPLE_ROOTS:
        if root.is_dir():
            for d in sorted(root.iterdir()):
                if d.is_dir():
                    idx.setdefault(d.name, d)
    # drum machines: <machine>/<folder>, name = folder basename minus '-'
    if DRUM_MACHINES.is_dir():
        for machine in sorted(DRUM_MACHINES.iterdir()):
            if machine.is_dir():
                for folder in sorted(machine.iterdir()):
                    if folder.is_dir():
                        idx.setdefault(folder.name.replace('-', ''), folder)
    return idx


def last_n_tracks(n):
    tracks = sorted(LIVE_DIR.rglob('*.tidal'),
                    key=lambda p: p.stat().st_mtime, reverse=True)
    return tracks[:n]


REPO = HOME / "Work/Sound/Tidal"


def resolve_track(arg):
    """Accept a full path OR a bare track name; find it under the repo."""
    p = Path(arg)
    if p.is_file():
        return [p]
    stem = p.name if p.name.endswith('.tidal') else p.name + '.tidal'
    matches = list(LIVE_DIR.rglob(stem)) or list(REPO.rglob(stem))
    if not matches:
        print(f'! no track named {stem!r} under live/ or the repo', file=sys.stderr)
    elif len(matches) > 1:
        print(f'! {stem!r} is ambiguous ({len(matches)} matches) — using {matches[0]}',
              file=sys.stderr)
    return matches[:1]


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument('tracks', nargs='*', type=Path)
    ap.add_argument('--last', type=int, metavar='N',
                    help='use the N most-recently-edited live/ tracks')
    ap.add_argument('--emit-sc', action='store_true',
                    help='print a SuperCollider preload snippet for the resolved folders')
    args = ap.parse_args()

    tracks = [p for arg in args.tracks for p in resolve_track(arg)]
    if args.last:
        tracks += last_n_tracks(args.last)
    if not tracks:
        ap.error('give track names/paths or --last N')

    idx = build_index()
    names, per_track = set(), {}
    for t in tracks:
        try:
            got = extract_names(t.read_text(errors='replace'))
        except OSError as e:
            print(f'! skip {t}: {e}', file=sys.stderr)
            continue
        per_track[t] = got
        names |= got

    resolved = {n: idx[n] for n in names if n in idx and n not in STOP}
    unresolved = sorted(names - resolved.keys())
    folders = sorted({str(p) for p in resolved.values()})

    if args.emit_sc:
        print('// preload — set-specific samples (setlist_samples.py)')
        print('~dirt.doNotReadYet = false;')
        for f in folders:
            print(f'~dirt.loadSoundFiles("{f}");')
        print(f'// {len(folders)} folders, {len(resolved)} names; '
              f'{len(unresolved)} unresolved (lazy fallback)')
        return

    print(f'tracks scanned : {len(per_track)}')
    print(f'candidate toks : {len(names)}  (words seen inside all quoted strings)')
    print(f'SAMPLES found  : {len(resolved)}  -> {len(folders)} folders to warm')
    print(f'  (non-sample tokens = structure/notes/MIDI/synths, ignored: {len(unresolved)})')
    print('\nSAMPLES to preload  (name -> folder):')
    for n in sorted(resolved):
        print(f'  {n:20s} {resolved[n]}')


if __name__ == '__main__':
    main()
