#!/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), grouped by measured role family
  - a momentary-LUFS trace per master (ffmpeg ebur128) + integrated LUFS / LRA / TP

Montreuil orbit activity comes from the windowed Take89 stem-map; Hamburg's is
computed from the already-windowed hamburg_stems. Calibration: stem ≈ master − 3.
"""
import json, re, subprocess, sys
from pathlib import Path
import numpy as np

HERE = Path(__file__).parent
OUT = HERE.parent.parent / "ui" / "public" / "punkachien.json"
DUR = 287.74
BIN_S = 2.0
ACTIVE_DB = -38.0          # lane lights above this
MONTREUIL_WIN = (2309.1, 2596.8)

# role families (DESIGN.md). vox folds into melodic's stack.
ROLE_GROUPS = [
    {"key": "percs",   "label": "Percs",   "color": "#ff8c00", "glyph": "▰"},
    {"key": "bass",    "label": "Bass",    "color": "#7c5cff", "glyph": "▂"},
    {"key": "melodic", "label": "Melodic", "color": "#36c5f0", "glyph": "♪"},
    {"key": "tops",    "label": "Tops",    "color": "#2dd4bf", "glyph": "≈"},
    {"key": "atmos",   "label": "Atmos",   "color": "#8a93a6", "glyph": "◌"},
]
# measured PunkAChien arrangement (orbit -> label, family)
ROLE_MAP = {
    "01": ("kick", "percs"), "02": ("snare", "percs"), "03": ("hats", "percs"),
    "04": ("acid", "bass"),  "05": ("cpluck", "bass"), "09": ("moog sub", "bass"),
    "08": ("break", "tops"), "11": ("atmos", "atmos"),
}


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 = []
    # frames ~ every 0.1s; bucket to 0.5s mean (ignoring -inf/silence floor)
    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 montreuil_orbits(dur):
    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 = name.split("-")[1]
        if o not in ROLE_MAP:
            continue
        act = [round(float(x), 1) for x in rms[i][sb:sb + n]]
        if max(act) <= ACTIVE_DB:
            continue
        label, group = ROLE_MAP[o]
        orbits.append({"orbit": o, "label": label, "group": group, "activity": act})
    return orbits


def hamburg_orbits(dur):
    stems = HERE / "hamburg_stems"
    n = int(round(dur / BIN_S))
    orbits = []
    for o in (f"{i:02d}" for i in range(1, 13)):
        if o not in ROLE_MAP:
            continue
        f = stems / f"orbit-{o}.flac"
        if not f.exists():
            continue
        # decode mono @ 4kHz f32, RMS per 2s bin
        r = subprocess.run(["ffmpeg", "-v", "error", "-i", str(f), "-ac", "1",
                            "-ar", "4000", "-f", "f32le", "-"], capture_output=True)
        sig = np.frombuffer(r.stdout, dtype=np.float32)
        sr = 4000
        per = int(BIN_S * sr)
        act = []
        for b in range(n):
            seg = sig[b * per:(b + 1) * per]
            rms = float(np.sqrt(np.mean(seg**2))) if seg.size else 0.0
            act.append(round(20 * np.log10(rms) if rms > 1e-7 else -240.0, 1))
        if max(act) <= ACTIVE_DB:
            continue
        label, group = ROLE_MAP[o]
        orbits.append({"orbit": o, "label": label, "group": group, "activity": act})
    return orbits


def take(tid, label, gig, date, stream_f, club_f, orbits_fn):
    dur = round(ffprobe_dur(HERE / stream_f), 2)
    masters = {}
    loud = {}
    for k, fn in (("stream", stream_f), ("club", club_f)):
        path = HERE / fn
        ld = loudness(path)
        masters[k] = {"file": fn, "I": ld["I"], "LRA": ld["LRA"], "TP": ld["TP"]}
        loud[k] = {"trace": ld["trace"], "stepS": ld["stepS"]}
        print(f"  {tid}/{k}: I={ld['I']} LRA={ld['LRA']} TP={ld['TP']} "
              f"({len(ld['trace'])} pts)", flush=True)
    return {"id": tid, "label": label, "gig": gig, "date": date,
            "dur": dur, "binS": BIN_S, "masters": masters, "loud": loud,
            "orbits": orbits_fn(dur)}


def main():
    print("building player data…", flush=True)
    data = {
        "track": "PunkAChien",
        "calibration": "stem ≈ master − 3 s (verified by cross-correlation, z=29)",
        "activeDb": ACTIVE_DB,
        "roleGroups": ROLE_GROUPS,
        # NOTE: the "Hamburg/39C3" take was MISIDENTIFIED — locate (z=10, weak) grabbed
        # a La Fin de l'Insouciance → Liquid Finale stretch from Take87 (PunkAChien is
        # not in the 39C3 set). Real Hamburg PunkAChien = 38C3 Toilet Rave 2024 ("Pitbul
        # Punk", Take35/36); to be re-sourced + ear-verified before it returns to the A/B.
        "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": [
            take("montreuil", "Montreuil", "Montreuil Algorave V3 — Mai Floral",
                 "2026-05-22", "masters/montreuil_stream_v1.flac",
                 "masters/montreuil_club_v1.flac", montreuil_orbits),
        ],
    }
    OUT.parent.mkdir(parents=True, exist_ok=True)
    OUT.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
    kb = OUT.stat().st_size / 1024
    for t in data["takes"]:
        print(f"  {t['id']}: {len(t['orbits'])} active orbits "
              f"({', '.join(o['label'] for o in t['orbits'])})")
    print(f"✓ {OUT}  ({kb:.0f} KB)")


if __name__ == "__main__":
    main()
