#!/usr/bin/env python3
"""Beat-resolution occupancy: where inside a loop is there nothing?

The presence checks in `grade`/`loops` are AGGREGATES over the whole window
(file peak, whole-file RMS, max of the beat table). An aggregate cannot see a
HOLE. `fred_angie_bass:0` graded 0.991 / tier S with 2.5 beats of digital
silence (-88 dBFS) sitting at beats 7-9 of 32 — PLN heard "it starts, then
silence, then starts again" the moment he auditioned it.

So: bin the envelope on the loop's OWN beat grid (bpm + bars come from the
manifest, never re-estimated) and report the SHAPE, not the level —
head/tail dead beats and the longest interior dead run.

    python3 beatocc.py packs/fred/fred_kits.json           # whole pack
    python3 beatocc.py packs/fred/fred_kits.json --kit fred_angie_bass -v

Dead = beat peak below --rel dB relative to that loop's own peak (default -40),
which is a shape measure and so must stay relative; an absolute floor would
just re-measure level. Occupancy floors are NOT applied here on purpose:
they belong per family, like the periodicity floors (a breath loop at 25%
occupancy is a breath loop, not a defect). This tool reports; the gate decides.
"""
from __future__ import annotations
import argparse, json, sys
from pathlib import Path
import numpy as np
import soundfile as sf


def beat_occupancy(path: Path, bpm: float, dur_s: float, rel_db: float = -40.0):
    """-> (n_beats, head_dead, tail_dead, longest_interior_run, occupancy, live[])"""
    y, sr = sf.read(str(path), always_2d=True)
    a = np.abs(y).max(axis=1)                      # channel max, never the mono sum
    if not a.size or a.max() <= 0:
        return 0, 0, 0, 0, 0.0, np.zeros(0, bool)
    beat = 60.0 / bpm
    n = max(1, int(round(dur_s / beat)))
    thr = a.max() * 10 ** (rel_db / 20.0)
    live = np.array([
        bool(a[int(i * beat * sr):int((i + 1) * beat * sr)].max() > thr)
        if int((i + 1) * beat * sr) > int(i * beat * sr) else False
        for i in range(n)])
    if not live.any():
        return n, n, n, n, 0.0, live
    head = int(np.argmax(live))
    tail = int(np.argmax(live[::-1]))
    run = cur = 0
    for v in live[head:n - tail]:
        cur = 0 if v else cur + 1
        run = max(run, cur)
    return n, head, tail, run, float(live.sum()) / n, live


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("manifest", type=Path)
    ap.add_argument("--kit", action="append", default=[], help="restrict to kit(s)")
    ap.add_argument("--rel", type=float, default=-40.0, help="dead threshold, dB rel. peak")
    ap.add_argument("-v", "--verbose", action="store_true", help="print the beat map")
    a = ap.parse_args()

    doc = json.loads(a.manifest.read_text())
    cuts = doc["cuts"] if isinstance(doc, dict) else doc
    cuts = [c for c in cuts if c.get("mode") == "loop"
            and (not a.kit or c.get("kit") in a.kit)]

    rows, missing = [], 0
    for c in cuts:
        p = Path(c["path"])
        if not p.exists():
            missing += 1
            continue
        n, head, tail, run, occ, live = beat_occupancy(p, c["bpm"], c["dur_s"], a.rel)
        rows.append((c, n, head, tail, run, occ))
        if a.verbose:
            print(f"{c['kit']}/{c['name']}  {c['bpm']:.2f}bpm {n}bt  "
                  f"head={head} tail={tail} gap={run} occ={occ*100:.1f}%")
            print("   " + "".join("#" if v else "." for v in live))

    print(f"\n{len(rows)} loops · dead = beat peak < {a.rel:+.0f} dB rel. to its own peak"
          + (f" · {missing} missing on disk" if missing else ""))
    flagged = [r for r in rows if r[2] >= 1 or r[3] >= 1 or r[4] >= 2]
    if flagged:
        print(f"\n{len(flagged)} of {len(rows)} loops have dead air the grade did not see:\n")
        print(f"    {'kit':<26} {'name':<18} {'fam':<6} beats head tail gap   occ  tier grade")
        for c, n, head, tail, run, occ in sorted(flagged, key=lambda r: -max(r[2], r[3], r[4])):
            print(f"    {c['kit']:<26} {c['name']:<18} {c.get('family',''):<6} "
                  f"{n:>5} {head:>4} {tail:>4} {run:>3} {occ*100:>5.1f}%  "
                  f"{c.get('tier','?')} {c.get('grade',0):.3f}")
    else:
        print("\nEvery loop is occupied edge to edge.")
    return 1 if flagged else 0


if __name__ == "__main__":
    sys.exit(main())
