#!/usr/bin/env python3
r"""make_bounds — turn machine-labelled segments into a Boundary Lab document.

PLN: "dotn we have a webapp for listening to seams in a set? you could use it for
me to confirm your labels. reusable tooling pattern."

Yes — `armada/ui/bounds.html` (BoundaryLab). This does NOT build a second page; it
generates the document that page already reads, so the ear-confirmation loop is the
one that already exists and already exports decisions.

WHY THIS DIRECTION MATTERS
    The three-lens fusion is a machine opinion. Eleven of eighteen CosmicFest
    segments have two lenses behind them, four are honestly unresolved, and the two
    that were WRONG were caught by PLN's memory, not by any score. So the output of
    an analysis pipeline should land in front of ears, not in a report — and the
    Boundary Lab already does exactly that, storing calls per gig in localStorage
    and exporting `boundaries-<gig>.json`.

THE CONTRACT (read from armada/ui/src/bounds/BoundaryLab.tsx, not assumed)
    The page fetches `/bounds-<gig>.json`, so the document goes in
    `armada/ui/public/`. Each boundary needs:
      track, title (incoming), fromTitle (outgoing), nominal (master time),
      clipStart, clipEnd, url, peaks[], marks[{key,label,t}], settled,
      noCandidate, note
    `marks[].t` is in MASTER time — the page subtracts clipStart itself.
    Mark keys are colour-coded by the page: `takeover` is brand magenta ("the one
    to try first"), `ear` green, `mid`/`onset` blue, `alt` dim green.
    `url` is handed to WaveformPlayer with dur = clipEnd - clipStart, so it must
    point at a CLIP of that length, not at the whole recording.

WHY CLIPS AND A MOUNT, RATHER THAN THE WHOLE FILE
    `audio-mounts.json` maps `/audio/<prefix>/...` to disk for BOTH vite dev and
    armada/serve.py, deliberately, so the SPA's URLs are identical in both — "a UI
    that only works under npm run dev is a demo, not a tool". We add one mount and
    write clips into it. A 40 s clip also loads instantly on a phone over LAN,
    which is where this actually gets used.
"""

from __future__ import annotations

import argparse
import json
import pathlib
import subprocess
import sys

import numpy as np

REPO = pathlib.Path("/home/pln/Work/Sound/Tidal")
PUBLIC = REPO / "armada" / "ui" / "public"
MOUNTS = REPO / "armada" / "ui" / "audio-mounts.json"
N_PEAKS = 1400


def probe_dur(path: pathlib.Path) -> float:
    return float(subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries", "format=duration",
         "-of", "csv=p=0", str(path)], capture_output=True, text=True).stdout.strip())


def cut(src: pathlib.Path, dst: pathlib.Path, start: float, dur: float) -> bool:
    dst.parent.mkdir(parents=True, exist_ok=True)
    r = subprocess.run(
        ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
         "-ss", f"{start:.3f}", "-t", f"{dur:.3f}", "-i", str(src),
         "-ac", "2", "-b:a", "128k", str(dst)],
        capture_output=True, text=True)
    return r.returncode == 0 and dst.exists()


def peaks_of(path: pathlib.Path, n: int = N_PEAKS) -> list[float]:
    """Abs-max envelope, n points, normalised to the clip's own peak.

    Per-clip normalisation on purpose: these are 40 s windows of a room mic whose
    level wanders, and a waveform you cannot see is not a waveform.
    """
    import soundfile as sf  # noqa: PLC0415

    y, _ = sf.read(str(path), dtype="float32", always_2d=True)
    y = y.mean(axis=1)
    if len(y) < n:
        return [0.0] * n
    step = len(y) // n
    env = np.abs(y[: step * n].reshape(n, step)).max(axis=1)
    m = float(env.max()) or 1.0
    return [round(float(v / m), 4) for v in env]


def main(argv=None) -> int:
    ap = argparse.ArgumentParser(prog="make_bounds", description=__doc__.splitlines()[0])
    ap.add_argument("recording")
    ap.add_argument("--fused", default=str(REPO / "armada/tide-table/cosmicfest26_fused.json"))
    ap.add_argument("--gig", default="cosmicfest-2026")
    ap.add_argument("--title", default="CosmicFest 2026 — confirm the machine's labels")
    ap.add_argument("--mount", default="cosmic")
    ap.add_argument("--pad", type=float, default=20.0)
    ap.add_argument("--outdir", default=str(REPO / "armada/tide-table/cosmicfest_bounds"))
    a = ap.parse_args(argv)

    rec = pathlib.Path(a.recording)
    raw = json.load(open(a.fused))

    # MERGE consecutive segments carrying the same verdict, before emitting seams.
    # Without this the document offered "bombe_dj -> bombe_dj" as cut #1, which is
    # not a seam at all: the crossmatch median filter simply produced two adjacent
    # windows with the same label. The consolidated tracklist merged them; this
    # generator read the un-merged fused file and faithfully published nonsense.
    #
    # Adjacent UNRESOLVED spans merge too. They might be different tracks, but
    # nothing here can tell, and offering two boundaries where we cannot justify
    # even one is asking PLN to adjudicate our uncertainty rather than the audio.
    segs = []
    for r in raw:
        if segs and segs[-1].get("verdict") == r.get("verdict"):
            segs[-1]["end"] = r["end"]
            ev = segs[-1].setdefault("evidence", [])
            for e in r.get("evidence", []):
                if e not in ev:
                    ev.append(e)
            if r.get("kick_bpm") and not segs[-1].get("kick_bpm"):
                segs[-1]["kick_bpm"] = r["kick_bpm"]
        else:
            segs.append(dict(r))
    if len(segs) != len(raw):
        print(f"merged {len(raw)} raw segments -> {len(segs)} "
              f"({len(raw)-len(segs)} same-label neighbours collapsed)")
    outdir = pathlib.Path(a.outdir)
    dur = probe_dur(rec)
    print(f"{rec.name}: {dur/60:.1f} min · {len(segs)} segments · pad {a.pad}s")

    bounds = []
    for i, s in enumerate(segs):
        nominal = float(s["start"])
        if nominal <= 1.0:
            continue                      # the set's start is not a seam
        cs = max(0.0, nominal - a.pad)
        ce = min(dur, nominal + a.pad)
        clip = outdir / f"{i:02d}.mp3"
        if not clip.exists() and not cut(rec, clip, cs, ce - cs):
            print(f"  ! ffmpeg failed for {clip.name}, skipping")
            continue
        prev = segs[i - 1] if i else None
        verdict = s.get("verdict")
        if prev is not None and prev.get("verdict") == verdict:
            print(f"  {i:02d}  skipped: {verdict} -> {verdict} is not a seam")
            continue
        marks = [{"key": "mid", "label": "seg mid",
                  "t": round((s["start"] + s["end"]) / 2, 2)}]
        # The lens that disagreed is worth SEEING on the waveform, because that is
        # exactly where an ear call is most valuable.
        ev = "; ".join(s.get("evidence", []))
        if "OVERRIDE" in ev:
            marks.append({"key": "takeover", "label": "lens override here",
                          "t": round(nominal, 2)})
        note = ev or ""
        if s.get("kick_bpm"):
            note = f"kick {s['kick_bpm']:.1f} BPM · " + note
        bounds.append({
            "track": i,
            "title": verdict or "UNRESOLVED",
            "fromTitle": (prev.get("verdict") or "UNRESOLVED") if prev else "start",
            "nominal": round(nominal, 2),
            "clipStart": round(cs, 2),
            "clipEnd": round(ce, 2),
            "url": f"/audio/{a.mount}/{clip.name}",
            "peaks": peaks_of(clip),
            "marks": marks,
            # `settled` is the page's "already decided" flag. Nothing here is
            # settled: that is the entire reason the document exists.
            "settled": False,
            "noCandidate": verdict is None,
            "note": note[:400],
        })
        print(f"  {i:02d}  {nominal/60:5.1f}m  {bounds[-1]['fromTitle'][:18]:<20}"
              f" -> {bounds[-1]['title'][:20]:<22} {clip.name}")

    doc = {"gig": a.gig, "title": a.title, "generated": True,
           "pad": a.pad, "boundaries": bounds}
    dst = PUBLIC / f"bounds-{a.gig}.json"
    dst.write_text(json.dumps(doc))
    print(f"\nwrote {dst.relative_to(REPO)}  ({dst.stat().st_size/1024:.0f} KB, "
          f"{len(bounds)} seams)")

    # Register the mount, idempotently, so the same URLs work in dev and prod.
    m = json.loads(MOUNTS.read_text())
    if m["mounts"].get(a.mount) != str(outdir):
        m["mounts"][a.mount] = str(outdir)
        MOUNTS.write_text(json.dumps(m, indent=2, ensure_ascii=False) + "\n")
        print(f"registered mount '{a.mount}' -> {outdir}")
    else:
        print(f"mount '{a.mount}' already registered")

    # The query key is `set`, NOT `gig`. bounds.tsx reads
    #   new URLSearchParams(location.search).get('set') ?? 'opal-festival-2026'
    # so a wrong key silently loads the DEFAULT document instead of erroring — PLN
    # spent time auditioning OPAL's Sunset Forest seams believing they were his
    # CosmicFest ones. The variable in that file is named `gig`, which is exactly
    # how the mistake was made: grepping found the word and not the key.
    print(f"\nAudition:  python3 armada/serve.py --dir armada/ui/dist --port 8742")
    print(f"           then open  /bounds.html?set={a.gig}")
    print("           (the key is ?set= — ?gig= silently loads the default doc)")
    print("Your calls export as boundaries-%s.json from the page." % a.gig)
    return 0


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