#!/usr/bin/env python3
"""boundary_bleed — auto-detect cross-boundary sound LEAKS in a split set.

The principle (feedback_verify_own_renders): the machine catches OBJECTIVE errors so
PLN's ears are spent on subjective feel. A "bad cut" — next track's element audible in
this track's tail, or this track's tail ringing into the next — is mechanically
detectable and should never need a human to notice it.

Mechanism (purely from stem activity, no audio re-listen):
  - Each track segment's CORE orbits = orbits active through its middle.
  - At a cut between track i and i+1:
      incoming-exclusive = core(i+1) − core(i)  → if ACTIVE in i's TAIL  ⇒ next leaked early
      outgoing-exclusive = core(i) − core(i+1)  → if ACTIVE in i+1's HEAD ⇒ tail rings in
  Each leak → a typed MasterEdit(bad_cut) appended to the EDL, pre-filled for the renderer
  AND usable as ground-truth for tuning. PLN then only judges what FEELS off.

Inputs: stemmap_take89.json (orbit×2s-bin RMS) + boundaries_take89_validated.json.
Run:  python3 boundary_bleed.py [--write-edl]
"""
import argparse, json, re
from datetime import date
from pathlib import Path

import numpy as np

from audio_lens import load_window, orbit_files, profile
from models import EditKind, EditOp, MasterEdit, Provenance

HERE = Path(__file__).parent
STEMMAP = HERE / "punkachien" / "stemmap_take89.json"
BOUNDS = HERE / "boundaries_take89_validated.json"
TRACKS = Path("/home/pln/Work/Web/www/next/content/lives/2026/montreuil-algorave/tracks.json")
OUT = HERE / "boundary_bleed_take89.json"
EDL = HERE / "master_edl_take89.json"

ACTIVE_DB = -38.0     # orbit "on" (core) above this (matches build_player_data)
LEAK_DB = -55.0       # sensitive floor for a FOREIGN onset in the leak window — a
                      # quiet next-track element (e.g. Bogdan stabs ~-48dB) still counts
W = 10.0              # leak window each side of a cut (s)
CORE_FRAC = 0.45      # orbit is "core" if active ≥ this fraction of a track's middle
LEAK_BINS = 2         # need ≥ this many bins (×2s) in the window to call a leak
EDGE = 0.20           # trim this fraction off each segment edge for the "middle"

# stemmap "orbit-NN" == dN (Ardour "Tidal N" channel). NB: Tidal's `# orbit X` param
# is SuperDirt-0-indexed → dN where N=X+1 (so `# orbit 8` == d9 == stemmap orbit-09).
# Convention: d8 is ALWAYS breaks. (per PLN)
FAMILY = {1:"kick",2:"snare",3:"hats",4:"bass",5:"bass/riff",6:"bass",7:"synth",
          8:"breaks",9:"sub/voice",10:"lead",11:"atmos",12:"atmos"}

# ── v2 spectral (shared-orbit) params ─────────────────────────────────────────
TAKE = "Take89"          # interchange stems behind this set (audio_lens)
SPEC_W = 6.0             # tail/head probe window (s)
REF_W = 12.0             # per-track interior reference window (s)
BANDS_ORDER = ["<80", "80-150", "150-500", "500-2k", "2k+"]
SPEC_MARGIN = 0.06       # min distance gap (toward J vs I) to call a spectral leak
MIN_REF_DIST = 0.05      # the two tracks' versions of the orbit must differ this much,
                         # else the orbit sounds the same both sides → nothing to detect
SPEC_MIN_RMS = -50.0     # a probe/reference window must actually have sound this loud,
                         # else its "timbre" is silence-noise → skip (no sound = no bleed)


def mmss(t): return f"{int(t)//60}:{int(t)%60:02d}"


def load():
    sm = json.loads(STEMMAP.read_text())
    orbits = [int(re.search(r"(\d+)", o).group(1)) for o in sm["orbits"]]
    times = sm["times"]; rms = sm["rms_db"]; bins = sm.get("bin_s", 2.0)
    bj = json.loads(BOUNDS.read_text())["boundaries"]
    # ordered segments (name, start, end) — leading track precedes cut 0
    try:
        tj = json.loads(TRACKS.read_text())
        first = (tj.get("tracks", tj))[0].get("name", "track 1")
    except Exception:
        first = "track 1"
    segs, cuts = [], sorted(bj, key=lambda x: x["t"])
    end = times[-1] + bins
    segs.append((first, 0.0, float(cuts[0]["t"])))
    for k, c in enumerate(cuts):
        e = float(cuts[k+1]["t"]) if k+1 < len(cuts) else end
        segs.append((c["into"], float(c["t"]), e))
    return orbits, times, rms, bins, cuts, segs


def active_set(orbits, times, rms, bins, t0, t1, frac):
    """orbits active ≥ frac of bins in [t0,t1)."""
    idx = [i for i, t in enumerate(times) if t0 <= t < t1]
    if not idx:
        return set()
    out = set()
    for r, o in enumerate(orbits):
        on = sum(1 for i in idx if rms[r][i] > ACTIVE_DB)
        if on / len(idx) >= frac:
            out.add(o)
    return out


def active_bins(orbits, times, rms, o, t0, t1, floor=ACTIVE_DB):
    r = orbits.index(o)
    return [t for i, t in enumerate(times) if t0 <= t < t1 and rms[r][i] > floor]


def core(orbits, times, rms, bins, seg):
    _, s, e = seg
    mid0, mid1 = s + (e - s) * EDGE, e - (e - s) * EDGE
    return active_set(orbits, times, rms, bins, mid0, mid1, CORE_FRAC)


def detect():
    orbits, times, rms, bins, cuts, segs = load()
    cores = [core(orbits, times, rms, bins, sg) for sg in segs]
    findings = []
    for k, c in enumerate(cuts):
        b = float(c["t"])
        i, j = k, k + 1           # segs[i] ends at b, segs[j] starts at b
        ci, cj = cores[i], cores[j]
        name_i, name_j = segs[i][0], segs[j][0]
        # incoming: next-track-only orbit lit in THIS track's tail (sensitive floor)
        for o in sorted(cj - ci):
            lit = active_bins(orbits, times, rms, o, b - W, b, floor=LEAK_DB)
            if len(lit) >= LEAK_BINS:
                findings.append({
                    "cut": k, "boundary_t": b, "mmss": mmss(b),
                    "from": name_i, "into": name_j, "side": "incoming",
                    "orbit": o, "family": FAMILY.get(o, "?"),
                    "leak_s": round(b - min(lit), 1), "from_t": mmss(min(lit)),
                    "detail": f"{name_j}'s orbit-{o:02d} ({FAMILY.get(o,'?')}) lit "
                              f"{round(b-min(lit),1)}s BEFORE the cut → next track leaked "
                              f"early / cut too late"})
        # outgoing: this-track-only orbit still lit in NEXT track's head (sensitive floor)
        for o in sorted(ci - cj):
            lit = active_bins(orbits, times, rms, o, b, b + W, floor=LEAK_DB)
            if len(lit) >= LEAK_BINS:
                findings.append({
                    "cut": k, "boundary_t": b, "mmss": mmss(b),
                    "from": name_i, "into": name_j, "side": "outgoing",
                    "orbit": o, "family": FAMILY.get(o, "?"),
                    "leak_s": round(max(lit) - b + bins, 1), "to_t": mmss(max(lit)),
                    "detail": f"{name_i}'s orbit-{o:02d} ({FAMILY.get(o,'?')}) still lit "
                              f"{round(max(lit)-b+bins,1)}s AFTER the cut → tail rings into "
                              f"{name_j}"})
    return findings, segs, cores


# ── v2: per-sound spectral novelty on SHARED orbits ──────────────────────────
def _orbit_profile(o, t0, t1):
    """Spectral profile of orbit `o` in [t0,t1), but ONLY if it's actually
    audible there (else the 'timbre' is silence-noise → return None, so we never
    fabricate a leak from a window where nothing is playing)."""
    L, _ = orbit_files(TAKE, o)
    if L is None or t1 - t0 < 1.0:
        return None
    try:
        p = profile(load_window(L, t0, t1))
    except Exception:
        return None
    if not p or p["rms_db"] < SPEC_MIN_RMS:
        return None
    return p


def _timbral_dist(a, b):
    """Composite distance ∈ ~[0,1] between two profiles: band-shape cosine +
    centroid shift. 0 = identical timbre, larger = more different sound."""
    if not a or not b:
        return None
    va = np.array([a["bands"][k] for k in BANDS_ORDER], float)
    vb = np.array([b["bands"][k] for k in BANDS_ORDER], float)
    na, nb = np.linalg.norm(va), np.linalg.norm(vb)
    if na == 0 or nb == 0:
        return None
    cos_d = 1.0 - float(np.dot(va / na, vb / nb))
    ca, cb = a["centroid"], b["centroid"]
    cen_d = abs(ca - cb) / (ca + cb + 1.0)
    return 0.5 * cos_d + 0.5 * cen_d


def _win(s, e, frac, w):
    """A `w`-second window centered `frac` of the way into [s,e], clipped."""
    c = s + (e - s) * frac
    return max(s, c - w / 2), min(e, c - w / 2 + w)


def spectral_detect(segs, cores, cuts):
    """For each orbit active on BOTH sides of a cut, decide whether the tail
    sounds like the NEXT track (incoming leak) or the head like the PREVIOUS
    (outgoing ring) — invisible to v1's orbit-level gate. Pure timbre."""
    findings = []
    for k, c in enumerate(cuts):
        b = float(c["t"])
        i, j = k, k + 1
        shared = sorted(cores[i] & cores[j])
        if not shared:
            continue
        (_, si, ei), (_, sj, ej) = segs[i], segs[j]
        name_i, name_j = segs[i][0], segs[j][0]
        refI_w = _win(si, ei, 0.45, REF_W)
        refJ_w = _win(sj, ej, 0.55, REF_W)
        for o in shared:
            pI = _orbit_profile(o, *refI_w)
            pJ = _orbit_profile(o, *refJ_w)
            ref_d = _timbral_dist(pI, pJ)
            if ref_d is None or ref_d < MIN_REF_DIST:
                continue                       # same sound both sides — undetectable
            pTail = _orbit_profile(o, max(si, b - SPEC_W), b)
            pHead = _orbit_profile(o, b, min(ej, b + SPEC_W))
            # incoming: tail timbre closer to track J than to its own track I
            dTI, dTJ = _timbral_dist(pTail, pI), _timbral_dist(pTail, pJ)
            if dTI is not None and dTJ is not None and dTI - dTJ > SPEC_MARGIN:
                findings.append({
                    "cut": k, "boundary_t": b, "mmss": mmss(b), "method": "spectral",
                    "from": name_i, "into": name_j, "side": "incoming",
                    "orbit": o, "family": FAMILY.get(o, "?"), "leak_s": SPEC_W,
                    "score": round(dTI - dTJ, 3), "ref_d": round(ref_d, 3),
                    "detail": f"orbit-{o:02d} ({FAMILY.get(o,'?')}) timbre in {name_i}'s "
                              f"tail matches {name_j} (Δ={round(dTI-dTJ,3)}) → {name_j}'s "
                              f"sound bled into the tail on a SHARED orbit"})
            # outgoing: head timbre closer to track I than to its own track J
            dHJ, dHI = _timbral_dist(pHead, pJ), _timbral_dist(pHead, pI)
            if dHJ is not None and dHI is not None and dHJ - dHI > SPEC_MARGIN:
                findings.append({
                    "cut": k, "boundary_t": b, "mmss": mmss(b), "method": "spectral",
                    "from": name_i, "into": name_j, "side": "outgoing",
                    "orbit": o, "family": FAMILY.get(o, "?"), "leak_s": SPEC_W,
                    "score": round(dHJ - dHI, 3), "ref_d": round(ref_d, 3),
                    "detail": f"orbit-{o:02d} ({FAMILY.get(o,'?')}) timbre in {name_j}'s "
                              f"head still matches {name_i} (Δ={round(dHJ-dHI,3)}) → "
                              f"{name_i}'s sound rings into the next track on a SHARED orbit"})
    return findings


def to_edits(findings):
    edits = []
    for f in findings:
        op = EditOp.trim_out if f["side"] == "incoming" else EditOp.trim_in
        edits.append(MasterEdit(
            take="Take89", name=f"{f['from']} → {f['into']}",
            op=op, at=f["mmss"], value=round(f["leak_s"], 1),
            kind=EditKind.bad_cut, reason=f["detail"], device="",
            provenance=Provenance(source="derived", as_of=date(2026, 6, 5),
                locator=f"boundary_bleed/{f.get('method','orbit')} cut{f['cut']} "
                        f"orbit-{f['orbit']:02d} {f['side']} {f['leak_s']}s")))
    return edits


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--write-edl", action="store_true",
                    help="append detected bad_cut rows to master_edl_take89.json")
    ap.add_argument("--spectral", action="store_true",
                    help="also run v2 per-sound spectral novelty on SHARED orbits")
    a = ap.parse_args()
    findings, segs, cores = detect()
    spectral = spectral_detect(segs, cores, cuts=sorted(
        json.loads(BOUNDS.read_text())["boundaries"], key=lambda x: x["t"])) \
        if a.spectral else []
    all_findings = findings + spectral
    print(f"\nboundary bleed scan — {len(segs)} tracks, "
          f"{len(findings)} v1 (orbit) + {len(spectral)} v2 (spectral) leaks\n")
    print("core orbits per track:")
    for (nm, s, e), c in zip(segs, cores):
        print(f"  {mmss(s):>6}-{mmss(e):<6} {nm:<26} {sorted(c)}")
    print("\nLEAKS (objective — for PLN to convert to feel-judgements):")
    if not all_findings:
        print("  (none above threshold)")
    for f in sorted(all_findings, key=lambda x: (x["cut"], x.get("method", "orbit"))):
        arrow = "◀" if f["side"] == "incoming" else "▶"
        tag = "spec" if f.get("method") == "spectral" else "orbt"
        print(f"  {f['mmss']:>6} cut{f['cut']:<2} [{tag}] {arrow} {f['detail']}")
    cov = ("v1 = EXCLUSIVE-orbit ignition (foreign orbit lit on the wrong side of a cut, "
           "to a -55dB floor).")
    if a.spectral:
        cov += (" v2 = per-sound SPECTRAL novelty on SHARED orbits: tail/head timbre "
                "compared to each track's interior, so a foreign SOUND on an orbit active "
                "both sides is caught (the v1 blind spot).")
    else:
        cov += (" LIMITATION: a foreign SOUND on a SHARED+active orbit is invisible at "
                "orbit level → re-run with --spectral.")
    OUT.write_text(json.dumps({"take": "Take89", "params":
        {"active_db": ACTIVE_DB, "window_s": W, "core_frac": CORE_FRAC,
         "spectral": a.spectral, "spec_margin": SPEC_MARGIN, "min_ref_dist": MIN_REF_DIST},
        "coverage": cov, "findings": all_findings}, ensure_ascii=False, indent=1))
    print(f"\n✓ {OUT} ({len(all_findings)} findings)")
    if not a.spectral:
        print("⚠ v1 only — shared-orbit sound-swaps invisible. Re-run with --spectral.")
    if a.write_edl:
        edits = to_edits(all_findings)
        edl = json.loads(EDL.read_text())
        # drop prior derived bleed rows, then append fresh
        edl["edits"] = [e for e in edl["edits"]
                        if (e.get("provenance") or {}).get("source") != "derived"]
        edl["edits"] += [e.model_dump(mode="json") for e in edits]
        EDL.write_text(json.dumps(edl, ensure_ascii=False, indent=1))
        print(f"✓ appended {len(edits)} derived bad_cut rows → {EDL}")


if __name__ == "__main__":
    main()
