#!/usr/bin/env python3
"""build_eda — per-take EDA emitter (corner-B grounding, the mastering first step).

Measures each orbit's LEVEL / REGISTER / TIME-ACTIVITY from the LOCAL Ardour
interchange stems (no freebox) → eda_{take}.json, validated against models.TakeEDA.
Never infers role from a name; flags silent orbits and empty takes (stem files
present ≠ real audio). eda_index() auto-discovers the emitted files, so the
triangle's corner-B coverage climbs with no wiring (feedback_mastering_eda).

    python3 build_eda.py ledger                 # Stage 0: reachability → eda_status.json
    python3 build_eda.py run Take70             # Stage 1: emit eda_Take70.json
    python3 build_eda.py all [--limit N] [--force]   # emit for every local take

Each orbit stem is decoded ONCE: a coarse 2 s-bin envelope gives activity + level
over the whole take, and the loudest ~20 s window is profiled (centroid/bands) — so
a 40-min SET take costs one decode per orbit, not a full-length FFT.
"""
import json
import sys
from datetime import date
from pathlib import Path

import numpy as np

import audio_lens as AL
import models as M

HERE = Path(__file__).resolve().parent
LIT_FLOOR = -52.0          # dB; an orbit bin above this is "lit" (Judge UI convention)
SILENT_PEAK = -55.0        # an orbit whose loudest window peaks below this is silent
WIN_S = 20.0               # spectral-profile window around the loudest bin


def take_orbits(take):
    """Orbits (dN) with a present interchange stem for `take`, ascending."""
    return [o for o in range(1, 13) if AL.orbit_files(take, o)[0] is not None]


def local_takes():
    """{take: [orbits]} for every take with stems in the interchange (one scan)."""
    out = {}
    for f in AL.IX.glob("*_Tidal *-1%L.wav"):
        name = f.name
        i = name.find("_Tidal ")
        take = name[:i]
        out.setdefault(take, set()).add(int(name[i + 7:name.index("-1%L.wav")]))
    return {t: sorted(v) for t, v in out.items()}


def _orbit_eda(take, o):
    """Decode orbit `o` once → OrbitEDA (activity + loudest-window spectrum)."""
    L, R = AL.orbit_files(take, o)
    sig = AL.decode(L)
    if R is not None:
        r = AL.decode(R)
        n = min(len(sig), len(r))
        sig = (sig[:n] + r[:n]) / 2
    dur = len(sig) / AL.SR
    # coarse 2 s-bin envelope (activity + overall level), cheap
    per = int(2.0 * AL.SR)
    nb = max(1, len(sig) // per)
    env = np.array([20 * np.log10(np.sqrt(np.mean(sig[b * per:(b + 1) * per] ** 2)) + 1e-9)
                    for b in range(nb)])
    active_pct = 100.0 * float(np.mean(env > LIT_FLOOR))
    peak_db = 20 * np.log10(np.abs(sig).max() + 1e-9)
    # profile the loudest WIN_S window (full-SR spectrum, but bounded length)
    pk = int(np.argmax(env)) * per
    half = int(WIN_S * AL.SR / 2)
    prof = AL.profile(sig[max(0, pk - half): pk + half])
    fam, cen = AL.classify_family(AL.ROLE.get(o), prof)
    return M.OrbitEDA(
        orbit=o, role=AL.ROLE.get(o), active_pct=round(active_pct, 1),
        peak_db=round(float(peak_db), 1),
        rms_db=round(prof["rms_db"], 1) if prof else -120.0,
        centroid=round(cen, 1) if cen is not None else None,
        family=fam,
        bands={k: round(v, 1) for k, v in prof["bands"].items()} if prof else {},
        silent=peak_db < SILENT_PEAK,
    ), dur


def build_take(take):
    orbits = take_orbits(take)
    if not orbits:
        return None
    oedas, dur = [], 0.0
    for o in orbits:
        oe, d = _orbit_eda(take, o)
        oedas.append(oe)
        dur = max(dur, d)
    n_active = sum(1 for x in oedas if not x.silent)
    peak = max((x.peak_db for x in oedas), default=-120.0)
    return M.TakeEDA(
        take=take, dur_s=round(dur, 1), n_orbits=len(oedas), n_active=n_active,
        peak_db=round(peak, 1), empty=(n_active == 0), orbits=oedas,
        provenance=M.Provenance(source=M.Source.derived,
                                locator="build_eda: local Ardour interchange stems",
                                as_of=date.today()),
    )


def out_path(take):
    return HERE / f"eda_{take}.json"


def cmd_run(take, force=True):
    if not force and out_path(take).exists():
        print(f"  {take}: already grounded (use --force to redo)")
        return
    eda = build_take(take)
    if eda is None:
        print(f"  {take}: no local stems")
        return
    out_path(take).write_text(eda.model_dump_json(indent=1, by_alias=True))
    tag = "  ⚠ EMPTY" if eda.empty else ""
    print(f"✓ eda_{take}.json — {eda.dur_s:.0f}s, {eda.n_active}/{eda.n_orbits} active, "
          f"peak {eda.peak_db:.1f}dB{tag}")


def cmd_all(limit=None, force=False):
    takes = sorted(local_takes())
    if limit:
        takes = takes[:limit]
    print(f"⛵ build_eda over {len(takes)} local takes (force={force})\n")
    for t in takes:
        cmd_run(t, force=force)


def cmd_ledger():
    """Stage 0: classify reachability per take, count grounded, emit eda_status.json."""
    loc = local_takes()
    rows = []
    for t, orbits in sorted(loc.items()):
        rows.append({"take": t, "reach": "local-stems", "n_orbits": len(orbits),
                     "grounded": out_path(t).exists()})
    grounded = sum(1 for r in rows if r["grounded"])
    led = M.EDAStatus(as_of=date.today().isoformat(), n_takes=len(rows),
                      n_local=len(rows), n_grounded=grounded, takes=rows)
    (HERE / "eda_status.json").write_text(led.model_dump_json(indent=1, by_alias=True))
    print(f"✓ eda_status.json — {led.n_takes} takes, {led.n_local} local-reachable, "
          f"{led.n_grounded} already grounded ({led.n_takes - led.n_grounded} to go)")


def main():
    args = sys.argv[1:]
    cmd = args[0] if args else "ledger"
    if cmd == "ledger":
        cmd_ledger()
    elif cmd == "run" and len(args) > 1:
        cmd_run(args[1])
    elif cmd == "all":
        cmd_all(limit=int(args[args.index("--limit") + 1]) if "--limit" in args else None,
                force="--force" in args)
    else:
        sys.exit("usage: build_eda.py [ledger | run <Take> | all [--limit N] [--force]]")


if __name__ == "__main__":
    main()
