#!/usr/bin/env python3
"""Precompute the Judge UI's player data → armada/ui/public/punkachien.json.

The katana produces data; React consumes it. Per take we emit:
  - orbit activity over time (RMS dB, 2 s bins), labelled from the TRACK'S SCORE
    (tidal_score → the actual sound on each dN, e.g. "dr" not a generic "hats"),
    grouped into role families VALIDATED by measured spectral centroid (audio_lens)
  - a momentary-LUFS trace per master (ffmpeg ebur128) + integrated LUFS / LRA / TP

Output is a VALIDATED models.PlayerData (single source of truth; ui/src/types.gen.ts
is generated from the same models — #42). Two activation thresholds: `litFloorDb`
(orbit shows, dimly, when audible) and `activeDb` (strongly on). The old single
hard −38 gate hid orbits that dip below it though clearly audible (#40 activation).

Montreuil orbit activity comes from the windowed Take89 stem-map; centroid is
measured from the Take89 interchange stems at each orbit's loudest moment.
Calibration: stem ≈ master − 3.

Run under system python3 (has numpy + pydantic):  python3 build_player_data.py
"""
import json
import re
import subprocess
import sys
from pathlib import Path

import numpy as np

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE.parent))               # armada/tide-table
from audio_lens import classify_family, load_window, orbit_files, profile  # noqa: E402
from models import (LoudTrace, MasterInfo, OrbitActivity, PlayerData,       # noqa: E402
                    RoleGroup, Take, Variant)
from tidal_score import orbit_sounds                                        # noqa: E402

OUT = HERE.parent.parent / "ui" / "public" / "punkachien.json"
TIDAL = Path("/home/pln/Work/Sound/Tidal/live/collab/raph/punkachien.tidal")
TAKE = "Take89"                       # interchange take backing the Montreuil master
DUR = 287.74
BIN_S = 2.0
ACTIVE_DB = -38.0                     # "strongly on / driving"
LIT_FLOOR = -52.0                     # audible floor — orbit shows (dim) above this
MONTREUIL_WIN = (2309.1, 2596.8)      # PunkAChien within the Take89 set (stem time)

# role families (DESIGN.md). Colors reinforce label + glyph + lane, never hue alone.
ROLE_GROUPS = [
    RoleGroup(key="percs",   label="Percs",   color="#ff8c00", glyph="▰"),
    RoleGroup(key="bass",    label="Bass",    color="#7c5cff", glyph="▂"),
    RoleGroup(key="melodic", label="Melodic", color="#36c5f0", glyph="♪"),
    RoleGroup(key="tops",    label="Tops",    color="#2dd4bf", glyph="≈"),
    RoleGroup(key="atmos",   label="Atmos",   color="#8a93a6", glyph="◌"),
]


def ff(args):
    return subprocess.run(args, capture_output=True, text=True)


def ffprobe_dur(path):
    r = ff(["ffprobe", "-v", "error", "-show_entries", "format=duration",
            "-of", "default=nw=1:nk=1", str(path)])
    try:
        return float(r.stdout.strip())
    except ValueError:
        return DUR


def loudness(path):
    """Momentary-LUFS trace (0.5 s grid) + integrated / LRA / true-peak."""
    r = ff(["ffmpeg", "-hide_banner", "-nostats", "-loglevel", "verbose",
            "-i", str(path), "-filter_complex", "ebur128=peak=true",
            "-f", "null", "-"])
    txt = r.stderr
    pts = re.findall(r"t:\s*([\d.]+).*?\sM:\s*(-?[\d.]+|-inf)", txt)
    grid, cur, step = [], 0.0, 0.5
    bucket = []
    for t, m in pts:
        t = float(t)
        val = None if m == "-inf" else float(m)
        if t >= cur + step:
            grid.append(round(float(np.mean([b for b in bucket if b is not None]
                                             or [-70.0])), 1))
            bucket = []
            cur += step
        bucket.append(val)
    summ = txt.split("Summary:")[-1]

    def g(pat, d):
        m = re.search(pat, summ)
        return round(float(m.group(1)), 1) if m else d
    return {"trace": grid, "stepS": step,
            "I": g(r"I:\s*(-?[\d.]+)\s*LUFS", None),
            "LRA": g(r"LRA:\s*(-?[\d.]+)\s*LU", None),
            "TP": g(r"Peak:\s*(-?[\d.]+)\s*dBFS", None)}


def measure_centroid(orbit, t_peak):
    """Spectral profile of one orbit's interchange stem around its loudest moment.
    Returns a profile dict (centroid/bands) or None if the stem is unavailable."""
    L, _ = orbit_files(TAKE, orbit)
    if L is None:
        return None
    t0 = max(MONTREUIL_WIN[0], t_peak - 8)
    t1 = min(MONTREUIL_WIN[1], t_peak + 8)
    try:
        return profile(load_window(L, t0, t1))
    except Exception:
        return None


def montreuil_orbits(dur):
    """Per-orbit lanes for the Montreuil take, labelled+grouped from the score."""
    sounds = orbit_sounds(TIDAL)                    # {orbit:int -> {sound,...}}
    sm = json.loads((HERE / "stemmap_take89.json").read_text())
    rms = sm["rms_db"]
    sb = int(round(MONTREUIL_WIN[0] / BIN_S))
    n = int(round(dur / BIN_S))
    orbits = []
    for i, name in enumerate(sm["orbits"]):
        o = int(name.split("-")[1])
        if o not in sounds:                         # not triggered in this score
            continue
        act = [round(float(x), 1) for x in rms[i][sb:sb + n]]
        if not act or max(act) <= LIT_FLOOR:        # never audible in the window
            continue
        sound = sounds[o]["sound"]
        peak_bin = int(np.argmax(act))
        prof = measure_centroid(o, MONTREUIL_WIN[0] + peak_bin * BIN_S)
        group, cen = classify_family(sound, prof)
        orbits.append(OrbitActivity(
            orbit=f"{o:02d}", label=sound, sound=sound, group=group,
            centroid=round(cen) if cen else None, activity=act))
    orbits.sort(key=lambda o: o.orbit)
    return orbits


def build_take(tid, label, gig, date, stream_f, club_f):
    dur = round(ffprobe_dur(HERE / stream_f), 2)
    masters, loud = {}, {}
    for v, fn in ((Variant.stream, stream_f), (Variant.club, club_f)):
        ld = loudness(HERE / fn)
        masters[v] = MasterInfo(file=fn, I=ld["I"], LRA=ld["LRA"], TP=ld["TP"])
        loud[v] = LoudTrace(trace=ld["trace"], stepS=ld["stepS"])
        print(f"  {tid}/{v.value}: I={ld['I']} LRA={ld['LRA']} TP={ld['TP']} "
              f"({len(ld['trace'])} pts)", flush=True)
    return Take(id=tid, label=label, gig=gig, date=date, tidal=str(TIDAL),
                dur=dur, binS=BIN_S, masters=masters, loud=loud,
                orbits=montreuil_orbits(dur))


def main():
    print("building player data…", flush=True)
    data = PlayerData(
        track="PunkAChien",
        calibration="stem ≈ master − 3 s (verified by cross-correlation, z=29)",
        activeDb=ACTIVE_DB,
        litFloorDb=LIT_FLOOR,
        roleGroups=ROLE_GROUPS,
        # The "Hamburg/39C3" take was MISIDENTIFIED (locate z=10) — actually a
        # La Fin de l'Insouciance → Liquid Finale stretch — and pulled. Real
        # Hamburg PunkAChien = 38C3 Toilet Rave 2024, pending re-source (#27/#35).
        note="Single confirmed take (Montreuil). Hamburg/39C3 was misidentified "
             "(actually Insouciance→Liquid Finale) and pulled; real Hamburg "
             "PunkAChien = 38C3 Toilet 2024, pending re-source.",
        takes=[build_take("montreuil", "Montreuil",
                          "Montreuil Algorave V3 — Mai Floral", "2026-05-22",
                          "masters/montreuil_stream_v1.flac",
                          "masters/montreuil_club_v1.flac")],
    )
    OUT.parent.mkdir(parents=True, exist_ok=True)
    OUT.write_text(data.model_dump_json())          # validated on the way out
    kb = OUT.stat().st_size / 1024
    for t in data.takes:
        lanes = ", ".join(f"{o.label}·{o.orbit}[{o.group}"
                          f"{'/'+str(o.centroid)+'Hz' if o.centroid else ''}]"
                          for o in t.orbits)
        print(f"  {t.id}: {len(t.orbits)} orbits → {lanes}")
    print(f"✓ {OUT}  ({kb:.0f} KB)")


if __name__ == "__main__":
    main()
