#!/usr/bin/env python3
"""tidal_score — parse a track's `.tidal` into a per-orbit sound map.

The Judge UI mislabeled orbits because it used a hardcoded generic ROLE_MAP
("hats" on orbit-3) instead of the track's actual score (punkachien plays "dr"
there). This derives the truth straight from the source: for each `dN`, which
SOUND is loaded on it. Reused by build_player_data (#40), the locate-matrix L3
fingerprint (#34), and the sound-palette dashboard (#38).

Mapping (reference_orbit_channel_map): stemmap `orbit-NN` == `dN`. Tidal's
`# orbit X` parameter is SuperDirt-0-indexed → `d(X+1)`; we record such
overrides but `dN` blocks map 1:1 to orbit N unless a `# orbit X` reassigns.

Sound extraction reuses the TF-IDF vocab (Dirt-Samples ∪ synthdefs) and the
sound-context regex from tools/sample_tfidf, so mininotation/boolean/note
tokens never pollute the result. The BASE sound = the last vocab token in the
block (Tidal's source pattern sits at the bottom; transforms above it).

    python3 tidal_score.py live/collab/raph/punkachien.tidal     # print the map
"""
from __future__ import annotations
import re
import sys
from pathlib import Path

# reuse the authoritative vocab + tokenizer (DRY)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "tools"))
from sample_tfidf import SPLIT, sound_vocab  # noqa: E402

# Like sample_tfidf.SOUND_CTX but ALSO accepts the bare source form `$ "..."`
# (e.g. `$ "[kick:4]"`, `$ "meth_bass:9"`). TF-IDF excludes `$` to avoid
# note/structure pollution across the whole corpus; here the vocab filter +
# "last token wins" make `$` safe and necessary (kick/snare/meth_bass live there).
SOURCE_CTX = re.compile(r'(?:\bsound\b|\bs\b|#|\$)\s*"([^"]*)"')

# a `dN` block header (real, not commented). Tidal comments are `--`. Leading
# whitespace IS allowed: ParVagues often wraps the whole track in a `do` block
# with INDENTED `d1`/`d2`… (e.g. burn_this_book.tidal) — a column-0-only match
# silently parsed those to an empty score. The `(\d{1,2})\b` guard still rejects
# identifiers like `degradeBy`/`d4bass` (no word-boundary after the digits).
DN_HEADER = re.compile(r"^\s*(d|p)(\d{1,2})\b")
ORBIT_OVERRIDE = re.compile(r"#\s*orbit\s+(\d+)")


def _strip_comment(line: str) -> str:
    """Drop a Tidal `--` line comment (keep everything before it)."""
    i = line.find("--")
    return line[:i] if i != -1 else line


def _blocks(text: str):
    """Yield (orbit:int, block_text) for each top-level dN/pN definition."""
    cur_n, buf = None, []
    for raw in text.splitlines():
        line = _strip_comment(raw)
        m = DN_HEADER.match(line)
        if m:
            if cur_n is not None:
                yield cur_n, "\n".join(buf)
            cur_n, buf = int(m.group(2)), [line]
        elif cur_n is not None:
            buf.append(line)
    if cur_n is not None:
        yield cur_n, "\n".join(buf)


def _sounds_in_block(block: str, vocab: set[str]):
    """Ordered (base sound, raw token) pairs in a block, sound-context only."""
    out = []
    for q in SOURCE_CTX.findall(block):
        for tok in SPLIT.split(q):
            if len(tok) > 1 and tok in vocab:
                out.append(tok)
    return out


def _raw_sounds(block: str):
    """Fallback: ordered base tokens from any sound-context string (vocab miss).
    Handles custom synths not in the vocab (e.g. `# "moog"`)."""
    out = []
    for q in SOURCE_CTX.findall(block):
        for tok in SPLIT.split(q):
            if len(tok) > 1 and not tok.isdigit():
                out.append(tok)
    return out


def orbit_sounds(tidal_path, vocab=None, kind=None) -> dict[int, dict]:
    """{orbit:int -> {'sound','raw_all','kind','orbit_override'}} for a .tidal.

    `sound` = base name of the source pattern (last vocab token, else last
    plausible token). `orbit` key already accounts for any `# orbit X` override
    (→ d(X+1)). Commented-out dN blocks are ignored.
    """
    if vocab is None:
        vocab, kind = sound_vocab()
    text = Path(tidal_path).read_text(errors="ignore")
    out = {}
    for n, block in _blocks(text):
        voc_toks = _sounds_in_block(block, vocab)
        all_toks = voc_toks or _raw_sounds(block)
        if not all_toks:
            continue
        base = voc_toks[-1] if voc_toks else all_toks[-1]
        ov = ORBIT_OVERRIDE.search(block)
        orbit = int(ov.group(1)) + 1 if ov else n   # # orbit X == d(X+1)
        # de-dup preserving order
        seen, uniq = set(), []
        for t in all_toks:
            if t not in seen:
                seen.add(t); uniq.append(t)
        out[orbit] = {
            "sound": base,
            "raw_all": uniq,
            "kind": (kind or {}).get(base, "?"),
            "orbit_override": bool(ov),
        }
    return out


def main():
    if len(sys.argv) < 2:
        sys.exit("usage: tidal_score.py path/to/track.tidal")
    path = sys.argv[1]
    m = orbit_sounds(path)
    print(f"\n{Path(path).name} — orbit → sound map (derived from score)\n")
    for o in sorted(m):
        d = m[o]
        tag = " (via # orbit override)" if d["orbit_override"] else ""
        extra = f"   [{', '.join(d['raw_all'])}]" if len(d["raw_all"]) > 1 else ""
        print(f"  d{o:<2} orbit-{o:02d}  {d['sound']:<16} {d['kind']:<7}{tag}{extra}")
    print()


if __name__ == "__main__":
    main()
