#!/usr/bin/env python3
"""build_bounds_set — data for the boundary webview: audition a cut, mark it.

The set-judge answers "is this track a keeper". This answers a different and
much narrower question: **exactly where does one track become the next**. After
three failed automated attempts (see `cut_lens3.py`'s docstring — the timbral
lens is structurally blind on transitions whose two tracks share their sounds),
some boundaries are ear-only by nature, so the tool's job is to make an ear call
cost seconds instead of a full listening pass.

## Why it renders clips instead of seeking the master

The obvious design streams the 817 MB master and seeks. Two problems: seeking
inside a long FLAC depends on a seektable the browser may not use well, and a
boundary audition is a scrub — dozens of small seeks around one point. Rendering
a ±`--pad` second clip per boundary makes every seek local and instant, and the
clips are a few MB total. Same reasoning as the judge's `heads`, and it reuses
that mount pattern.

Clip time and master time are kept explicitly distinct: each boundary carries
`clipStart` (master seconds at clip sample 0) so every marker converts with one
subtraction. Getting this wrong silently shifts every candidate, which is the
same class of bug as the master→stem `keeps` mapping.

## What it emits

Per boundary: the outgoing/incoming titles, the nominal cut, whatever candidates
exist (`takeover` / `mid` / `onset`), any ear-verified answer, waveform peaks,
and the clip URL. Boundaries with NO candidate are included and flagged — #13
and #15 are exactly the ones that need a human, so omitting them would hide the
work.

    python3 build_bounds_set.py judge_specs/opal26.json \
        --ear judge_specs/opal26_boundaries_ear.json --render
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path

import numpy as np

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from build_judge_set import clean_title, ffprobe_dur, peaks  # noqa: E402

PAD = 45.0          # seconds either side of the nominal cut
PEAKS_N = 1400      # a 90 s clip: ~15 buckets/second, enough to see a fade


def render_clip(master: Path, out: Path, t0: float, dur: float) -> bool:
    """Cut [t0, t0+dur] from the master. Re-encoded, not stream-copied.

    `-c copy` on FLAC snaps to frame boundaries, so the clip would start a few
    ms from where we asked and every marker inside it would inherit that error.
    """
    out.parent.mkdir(parents=True, exist_ok=True)
    r = subprocess.run(
        ["ffmpeg", "-v", "error", "-y", "-ss", f"{t0:.6f}", "-t", f"{dur:.6f}",
         "-i", str(master), "-ac", "2", "-c:a", "flac", "-compression_level", "5",
         str(out)], capture_output=True)
    if r.returncode != 0:
        print(f"  ! ffmpeg failed for {out.name}: {r.stderr.decode()[:200]}")
        return False
    return True


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("spec")
    ap.add_argument("--ear", required=True, help="ear-verified + candidates JSON")
    ap.add_argument("--pad", type=float, default=PAD)
    ap.add_argument("--render", action="store_true", help="(re)render the clips")
    ap.add_argument("--only", help="comma-separated track numbers")
    ap.add_argument("--out")
    a = ap.parse_args()

    spec = json.loads(Path(a.spec).read_text())
    ear = json.loads(Path(a.ear).read_text())
    segs = json.loads(Path(spec["segments"]).read_text())
    master = Path(spec["master"])
    if not master.exists():
        print(f"master not found: {master}")
        return 1

    clips_dir = Path(spec.get("boundsDir") or master.parent / "bounds_v4")
    verified = ear.get("verified", {})
    cands = ear.get("candidates", {})
    only = {int(x) for x in a.only.split(",")} if a.only else None
    mdur = ffprobe_dur(master)

    by_track = {s["track"]: s for s in segs}
    out_rows = []
    for i in range(1, len(segs)):
        nxt = segs[i]
        tno = nxt["track"]
        if only and tno not in only:
            continue
        nominal = float(nxt["start"])
        t0 = max(0.0, nominal - a.pad)
        t1 = min(mdur, nominal + a.pad)
        clip = clips_dir / f"{tno:02d}.flac"

        if a.render or not clip.exists():
            print(f"  rendering #{tno:>2} {nxt['title'][:28]:<28} "
                  f"[{t0:.1f}, {t1:.1f}]")
            if not render_clip(master, clip, t0, t1 - t0):
                continue

        v = verified.get(str(tno), {})
        c = cands.get(str(tno), {})
        # A boundary into a track that is CUT from the release is not a start at
        # all — it is the previous track's END, and it still has to be right or
        # the dropped track's intro bleeds into the last one that ships. Label it
        # as what it is, or it reads as work that no longer matters.
        cut = ear.get("cut_from_release", {}).get(str(tno))
        marks = []
        # order matters: this is the audition order, best-first. `takeover` came
        # within 0.7 s on both boundaries that also had ear answers — n=2, on the
        # easy cases, so it leads the list but carries its own caveat in the UI.
        for key, label in (("takeover", "takeover"), ("mid", "mid"),
                           ("onset", "onset")):
            if c.get(key) is not None:
                marks.append({"key": key, "label": label, "t": float(c[key])})
        if v.get("start") is not None:
            marks.append({"key": "ear", "label": "ear-verified", "t": float(v["start"])})
        if v.get("alt_start") is not None:
            marks.append({"key": "alt", "label": "ear alt", "t": float(v["alt_start"])})

        prev_title = clean_title(by_track.get(tno - 1, {}).get("title", "?"))
        out_rows.append({
            "track": tno,
            "title": clean_title(nxt["title"]),
            "cutFromRelease": bool(cut),
            "role": (f"END of #{tno - 1} {prev_title}" if cut else "start"),
            "fromTitle": clean_title(by_track.get(tno - 1, {}).get("title", "?")),
            "nominal": nominal,
            "clipStart": t0,
            "clipEnd": t1,
            "url": f"{spec.get('boundsAudioBase', '/audio/bounds')}/{clip.name}",
            "peaks": [round(x, 4) for x in peaks(clip, PEAKS_N)],
            "marks": marks,
            "settled": v.get("start") is not None,
            "noCandidate": not marks or all(m["key"] in ("ear", "alt") for m in marks),
            "note": (cut.get("note", "") + " " if cut else "") + (c.get("note") or v.get("note") or ""),
        })

    doc = {
        "gig": spec["gig"],
        "title": spec.get("title", ""),
        "generated": True,
        "pad": a.pad,
        "boundaries": out_rows,
    }
    # default straight into the SPA's public/ — same convention as the judge's
    # judge-<gig>.json, so `vite build` and serve.py both pick it up untouched.
    dest = Path(a.out or HERE.parent / "ui" / "public" / f"bounds-{spec['gig']}.json")
    dest.write_text(json.dumps(doc, indent=1))
    n_open = sum(1 for r in out_rows if not r["settled"])
    print(f"\n✓ {dest}  ({len(out_rows)} boundaries, {n_open} still open)")
    print(f"  clips: {clips_dir}")
    return 0


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