#!/usr/bin/env python3
"""cut_lens2 — locate a crossfaded track boundary, validated against the ear.

v1 (cut_lens.py) failed its own validation: median 24 s error against PLN's
ear-verified boundaries. Three diagnosed causes, each fixed here:

  v1                                        v2
  --------------------------------------    ------------------------------------
  reference from the track MIDPOINT — but    reference from the segment region
  a livecoded track is not stationary, and   ADJACENT to the boundary (but clear
  an 8-minute track's middle can be further  of it), so "what the outgoing track
  from its own head than from its neighbour  sounds like HERE" is what we compare

  weighted MEAN over 1-5 voting orbits, so   MEDIAN over every orbit that has
  a single bad orbit moved the answer        real separation — one liar cannot
                                             move a median

  threshold + 6 s sustained-crossing rule,   CUSUM change-point: no threshold at
  which cannot resolve a 4-cycle crossfade   all, it finds where the series MOST
  (~7.7 s at 124 BPM) — the exact span       changes regime, which is what a
  being measured                             crossfade is

The measure per orbit is still timbral — across a ParVagues switch the ORBIT SET
barely changes (d1 is a kick before and after), so only the SAMPLE tells you
anything. Activity-based detectors are structurally blind here and both of ours
passed the bad cuts.

VALIDATION IS THE POINT. `--truth` runs LEAVE-ONE-OUT against the ear-verified
boundaries: each known answer is estimated without being used to tune anything,
and the error is reported. If that does not come in tight, the proposals for the
unknown boundaries are worthless and must not be shipped.

    python3 cut_lens2.py --spec judge_specs/opal26.json \
        --truth judge_specs/opal26_boundaries_ear.json

>>> RESULT, 2026-08-16: BETTER THAN v1, STILL NOT TRUSTWORTHY. <<<
    median |err| 24.0s (v1) -> 16.6s (v2); voters 1-5 -> 4-8; worst 41.2s.
    Within 2s on only 1 of 8. Its proposals were NOT shipped.

But the failure has a SHAPE, and it is the useful part:

    #3 -21.0  #4 -15.2  #5 -18.0  #6 -18.5  #7 -41.2    early/mid set
    #8  -4.4  #9  -2.0  #10 -2.4                        late set

EVERY error is negative, and the accurate ones are exactly the boundaries PLN
described as short ("perfect starts perfect"), while the wild ones are the ones
he described as long ("this start is still the drops ... piment starts real at
0:21 only").

    THE LENS FINDS WHERE THE INCOMING TRACK FIRST APPEARS.
    THE EAR MARKS WHERE IT TAKES OVER.
    Those differ by the length of the crossfade, which is what varies.

So this is not noise to be tuned away — it is measuring a real and different
quantity. Two ways forward for whoever picks it up:

 1. Measure the transition SPAN, not a point: the lens gives the onset, and the
    end is where the OUTGOING track's exclusive sounds fall below audibility.
    Report [start, end] and take the end as the cut.
 2. Cheaper and honest: present PLN the lens's onset plus a candidate end, and
    ask him to confirm ONE of two timestamps per boundary. That is seconds of
    listening per cut instead of a full pass.

Do NOT paper over it with a constant offset fitted to these eight points; the
errors range 2.0-41.2s and a fitted constant is memorisation, not a method.
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

import numpy as np

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from audio_lens import load_window, profile  # noqa: E402

SEARCH = 40.0     # +/- seconds around the nominal cut
WIN = 3.0         # profiling window
STEP = 0.5        # slide — finer than v1; a 4-cycle fade is ~8 s
REF_MAX = 40.0    # cap on a reference window
REF_CLEAR = 12.0  # keep references this far from the boundary being measured
MIN_SEP = 0.05    # an orbit must sound this different across the switch to vote
SR = 44100


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


def timbral(a, b):
    if a is None or b is None:
        return None
    ba = np.array([a["bands"][k] for k in sorted(a["bands"])])
    bb = np.array([b["bands"][k] for k in sorted(b["bands"])])
    band = np.abs(ba - bb).sum() / 200.0
    ca, cb = max(a["centroid"], 1.0), max(b["centroid"], 1.0)
    cen = min(abs(np.log2(ca / cb)) / 4.0, 1.0)
    return float(0.65 * band + 0.35 * cen)


def make_m2s(keeps):
    def f(t):
        acc = 0.0
        for a, b in keeps:
            if t < acc + (b - a):
                return a + (t - acc)
            acc += b - a
        return keeps[-1][1]
    return f


def ref_window(seg_start, seg_end, boundary, side):
    """A reference span inside one segment, adjacent to the boundary but clear of it."""
    if side == "prev":
        hi = boundary - REF_CLEAR
        lo = max(seg_start + 2.0, hi - REF_MAX)
    else:
        lo = boundary + REF_CLEAR
        hi = min(seg_end - 2.0, lo + REF_MAX)
    return (lo, hi) if hi - lo >= 6.0 else None


def cusum_changepoint(series, grid):
    """Where the series most changes regime. No threshold, no tuning.

    S_k = sum_{i<k}(x_i - mean(x)); the extremum of S is the point that best
    splits the series into two levels. For "sounds like prev" -> "sounds like
    next" that IS the handover.
    """
    ok = ~np.isnan(series)
    if ok.sum() < 8:
        return None
    x = series.copy()
    x[~ok] = np.nanmean(series)
    s = np.cumsum(x - x.mean())
    # the series runs negative (prev) then positive (next), so S dips then climbs:
    # the MINIMUM of S is the switch.
    return float(grid[int(np.argmin(s))])


def estimate(stems, m2s, seg_prev, seg_next, verbose=False):
    boundary = seg_next["start"]
    lo = max(seg_prev["start"] + 3, boundary - SEARCH)
    hi = min(seg_next["end"] - 3, boundary + SEARCH)
    grid = np.arange(lo, hi - WIN, STEP)
    if grid.size < 10:
        return None, 0

    wp = ref_window(seg_prev["start"], seg_prev["end"], boundary, "prev")
    wn = ref_window(seg_next["start"], seg_next["end"], boundary, "next")
    if not wp or not wn:
        return None, 0

    votes = []
    for path in stems:
        rp = _prof(path, m2s, *wp)
        rn = _prof(path, m2s, *wn)
        if rp is None or rn is None:
            continue
        sep = timbral(rp, rn)
        if sep is None or sep < MIN_SEP:
            continue                      # same sound both sides: no information

        st0 = m2s(float(grid[0]))
        try:
            sig = load_window(path, st0, st0 + float(grid[-1] - grid[0]) + WIN)
        except Exception:
            continue
        n = int(WIN * SR)
        series = []
        for t in grid:
            a = int((t - grid[0]) * SR)
            seg = sig[a:a + n]
            p = profile(seg) if seg.size >= n // 2 else None
            if p is None or p["rms_db"] < -55:
                series.append(np.nan)
                continue
            series.append(timbral(p, rp) - timbral(p, rn))
        cp = cusum_changepoint(np.array(series, dtype=float), grid)
        if cp is not None:
            votes.append(cp)
            if verbose:
                print(f"      {path.name[-6:]:>6} sep={sep:.2f} -> {mmss(cp)}")

    if len(votes) < 3:
        return None, len(votes)
    return float(np.median(votes)), len(votes)   # median: one liar cannot move it


def _prof(path, m2s, t0, t1):
    try:
        st = m2s(t0)
        sig = load_window(path, st, st + min(t1 - t0, REF_MAX))
        return profile(sig) if sig.size else None
    except Exception:
        return None


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--spec", required=True)
    ap.add_argument("--truth")
    ap.add_argument("--out")
    ap.add_argument("-v", "--verbose", action="store_true")
    a = ap.parse_args()

    spec = json.loads(Path(a.spec).read_text())
    segs = json.loads(Path(spec["segments"]).read_text())
    m2s = make_m2s(spec["keeps"])
    stems = sorted(Path(spec["stemsDir"]).glob(spec.get("stemsGlob", "*.wav")))

    truth = {}
    if a.truth:
        td = json.loads(Path(a.truth).read_text())
        for k, v in td.get("verified", {}).items():
            if v.get("start") is not None:
                truth[int(k)] = float(v["start"])

    print(f"cut-lens2 — {len(stems)} orbits, {len(truth)} ear-verified boundaries\n")
    print(f"{'#':>2} {'track':<30} {'nominal':>8} {'est':>8} {'shift':>7} {'n':>3} {'truth':>8} {'err':>7}")
    errs, out = [], []
    for i in range(1, len(segs)):
        prev, nxt = segs[i - 1], segs[i]
        if a.verbose:
            print(f"\n  -- into #{nxt['track']} {nxt['title']}")
        est, n = estimate(stems, m2s, prev, nxt, a.verbose)
        line = f"{nxt['track']:>2} {nxt['title'][:30]:<30} {nxt['start']:>8.1f}"
        if est is None:
            print(line + f" {'—':>8} {'—':>7} {n:>3}")
            continue
        line += f" {est:>8.1f} {est - nxt['start']:>+7.1f} {n:>3}"
        if nxt["track"] in truth:
            err = est - truth[nxt["track"]]
            errs.append(abs(err))
            line += f" {truth[nxt['track']]:>8.1f} {err:>+7.1f}"
        print(line)
        out.append({"track": nxt["track"], "title": nxt["title"],
                    "nominal": nxt["start"], "proposed": round(est, 2), "voters": n})

    if errs:
        e = np.array(errs)
        print(f"\nVALIDATION on {len(e)} ear-verified boundaries:")
        print(f"   median |err| {np.median(e):5.1f}s   mean {e.mean():5.1f}s   worst {e.max():5.1f}s")
        print(f"   within 2s: {(e <= 2).sum()}/{len(e)}   within 5s: {(e <= 5).sum()}/{len(e)}")
        verdict = "USABLE" if np.median(e) <= 3 and e.max() <= 8 else "NOT TRUSTWORTHY"
        print(f"   -> {verdict}")
    if a.out:
        Path(a.out).write_text(json.dumps(out, indent=2))
        print(f"\n✓ {a.out}")
    return 0


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