#!/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] [--workers N]   # every local take

Each orbit stem is decoded ONCE, L-channel only, at full SR — that single decode
serves BOTH a coarse 2 s-bin envelope (activity + level over the whole take) and the
loudest ~20 s spectral profile (centroid/bands). Benched 2026-06-07: the cost is the
ffmpeg read (LUKS-decrypt + WAV parse), not the FFT or the pipe; decoding L only (the
stereo stems share the activity envelope) halves it with no measurable accuracy loss
vs the old L+R average, and orbits decode concurrently in a bounded thread pool
(ffmpeg releases the GIL in subprocess.run) → a 78-min SET take grounds in ~one
decode-wave, not 12 serial ones. Low-SR was rejected: its anti-alias lowpass kills
hat brilliance and shifts the loudest-window pick to the wrong moment.
"""
import json
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
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
BIN_S = 2.0                # envelope bin width
WORKERS = 4                # concurrent orbit decodes (memory ≈ workers × ~0.8 GB/SET-take)


def _decode_l(path):
    """Decode one stem to mono float32 (half the RAM of AL.decode's float64 — and
    we keep peak takes at full SR, so no precision is lost where it matters)."""
    raw = subprocess.run(
        ["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", str(AL.SR),
         "-f", "f32le", "-"], capture_output=True).stdout
    return np.frombuffer(raw, dtype=np.float32)


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 (L-channel, full SR) → OrbitEDA. That one decode feeds
    the whole-take envelope AND the loudest-window spectral profile."""
    L, _ = AL.orbit_files(take, o)
    sig = _decode_l(L)
    dur = len(sig) / AL.SR
    # coarse 2 s-bin envelope (activity + overall level), cheap
    per = int(BIN_S * AL.SR)
    nb = max(1, len(sig) // per)
    env = np.array([20 * np.log10(np.sqrt(np.mean(
        sig[b * per:(b + 1) * per].astype(np.float64) ** 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].astype(np.float64))
    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, workers=WORKERS):
    orbits = take_orbits(take)
    if not orbits:
        return None
    # decode/analyse orbits concurrently — ffmpeg is a subprocess so threads parallelise
    with ThreadPoolExecutor(max_workers=workers) as ex:
        results = list(ex.map(lambda o: _orbit_eda(take, o), orbits))
    oedas = [oe for oe, _ in results]
    dur = max((d for _, d in results), default=0.0)
    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, workers=WORKERS):
    if not force and out_path(take).exists():
        print(f"  {take}: already grounded (use --force to redo)")
        return
    eda = build_take(take, workers=workers)
    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, workers=WORKERS):
    takes = sorted(local_takes())
    if limit:
        takes = takes[:limit]
    print(f"⛵ build_eda over {len(takes)} local takes (force={force}, workers={workers})\n")
    import time
    t0 = time.time()
    for i, t in enumerate(takes, 1):
        ts = time.time()
        cmd_run(t, force=force, workers=workers)
        print(f"    [{i}/{len(takes)}] {time.time() - ts:.0f}s  (total {time.time() - t0:.0f}s)")


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],
                workers=int(args[args.index("--workers") + 1]) if "--workers" in args else WORKERS)
    elif cmd == "all":
        cmd_all(limit=int(args[args.index("--limit") + 1]) if "--limit" in args else None,
                force="--force" in args,
                workers=int(args[args.index("--workers") + 1]) if "--workers" in args else WORKERS)
    else:
        sys.exit("usage: build_eda.py [ledger | run <Take> | all [--limit N] [--force]]")


if __name__ == "__main__":
    main()
