#!/usr/bin/env python3
"""build_release_joins — audition the record as a LISTENER hears it, before release.

The boundary lab answers "where should this cut be" against the master. This
answers the different question PLN asked last: *"i wanna confirm all tracks
start/ends/transitions"* — and it cannot be done on the master, for one concrete
reason:

    Desire is cut from the release. In the master, Vague de CRIME ends at
    4166.3 and Desire runs until 4547.57. A ±45 s master window around that
    edge plays six minutes of a track that does not ship. The join a listener
    actually hears — CRIME's tail straight into REVOLUTION's head — exists
    ONLY in the rendered output.

So this builds its clips from the split FLACs, not the master: for every
consecutive pair it concatenates the tail of one and the head of the next, which
is exactly the seam. That has a second benefit worth as much as the first — it
audits the files that ship, so a split error (wrong offset, truncated encode,
a STREAMINFO lie) shows up here rather than after upload.

Two edge entries bracket the record: the head of track 1 (does it open right —
the 1.45 s silence trim) and the tail of the last track (does it end, or stop).

Emits the same JSON shape as `build_bounds_set.py`, so the SAME UI renders it:

    python3 build_release_joins.py judge_specs/opal26.json \\
        --tracks /home/pln/Work/Sound/Prod/Opal26_master/tracks_v4_streaming
    -> ui/public/bounds-<gig>-release.json    (open with ?set=<gig>-release)
"""
from __future__ import annotations

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

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

# Reverb flavours for the closing A/B. PLN asked for "reverb over last 5s so we
# hear it slightly echo as last sound" — so the wet part is the TAIL ONLY, and it
# is allowed to ring past the end of the record rather than being cut off, which
# is the difference between an echo and a truncation. Everything before the tail
# stays bit-identical to the dry render.
from render_release import CLOSE_AB  # noqa: E402  — one table, not two

TAIL = 20.0     # seconds of the outgoing track
HEAD = 20.0     # seconds of the incoming track
PEAKS_N = 900


def cut(src: Path, out: Path, ss: float, dur: float) -> bool:
    r = subprocess.run(
        ["ffmpeg", "-v", "error", "-y", "-ss", f"{ss:.4f}", "-t", f"{dur:.4f}",
         "-i", str(src), "-ac", "2", "-c:a", "flac", "-compression_level", "3",
         str(out)], capture_output=True)
    return r.returncode == 0


def close_variant(src: Path, out: Path, ss: float, dur: float,
                  wet_s: float, echo: str | None) -> bool:
    """The record's last `dur` seconds, optionally with a reverb tail.

    Split / process / concat rather than one global filter: a reverb applied to
    the whole clip would wash the entire ending, and PLN asked for the tail. The
    dry head is passed through untouched, the last `wet_s` seconds get the echo,
    and the echo extends the output by its own longest tap — no `apad`, which
    measurement showed becomes trailing digital silence one second per second.
    """
    if echo is None:
        return cut(src, out, ss, dur)
    split = max(0.0, dur - wet_s)
    fc = (
        f"[0:a]atrim=0:{split:.4f},asetpts=N/SR/TB[dry];"
        f"[0:a]atrim={split:.4f},asetpts=N/SR/TB,aecho={echo}[wet];"
        f"[dry][wet]concat=n=2:v=0:a=1[out]"
    )
    r = subprocess.run(
        ["ffmpeg", "-v", "error", "-y", "-ss", f"{ss:.4f}", "-t", f"{dur:.4f}",
         "-i", str(src), "-filter_complex", fc, "-map", "[out]",
         "-ac", "2", "-c:a", "flac", "-compression_level", "3", str(out)],
        capture_output=True)
    if r.returncode != 0:
        print(f"  ! {out.name}: {r.stderr.decode()[:200]}")
    return r.returncode == 0


def concat(parts: list[Path], out: Path) -> bool:
    """Join the pieces with ffmpeg's concat DEMUXER, not the filter.

    The demuxer is sample-exact for identical formats; re-filtering would
    resample and could hide the very click we are looking for.
    """
    with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as f:
        for p in parts:
            f.write(f"file '{p}'\n")
        lst = f.name
    r = subprocess.run(
        ["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", lst,
         "-c:a", "flac", "-compression_level", "3", str(out)], capture_output=True)
    Path(lst).unlink(missing_ok=True)
    return r.returncode == 0


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("spec")
    ap.add_argument("--tracks", required=True)
    ap.add_argument("--tail", type=float, default=TAIL)
    ap.add_argument("--head", type=float, default=HEAD)
    ap.add_argument("--close-ab", action="store_true",
                    help="also render reverb-tail variants of the closing clip")
    ap.add_argument("--wet", type=float, default=5.0, help="seconds of reverb tail")
    ap.add_argument("--out")
    a = ap.parse_args()

    spec = json.loads(Path(a.spec).read_text())
    tdir = Path(a.tracks)
    files = sorted(p for p in tdir.glob("*.flac"))
    if not files:
        print(f"no tracks in {tdir}")
        return 1
    durs = [ffprobe_dur(f) for f in files]
    joins_dir = tdir.parent / "joins_v4"
    joins_dir.mkdir(parents=True, exist_ok=True)

    def title_of(p: Path) -> str:
        # "07 - Piment Bresilien.flac" -> "Piment Bresilien"
        return clean_title(p.stem.split(" - ", 1)[-1])

    rows = []

    # --- opening: does the record START right (the 1.45 s trim)
    op = joins_dir / "00-open.flac"
    if not cut(files[0], op, 0.0, min(a.head, durs[0])):
        print("! failed to render the opening")
    rows.append({
        "track": 0, "title": f"OPEN · {title_of(files[0])}", "fromTitle": "silence",
        "nominal": 0.0, "clipStart": 0.0, "clipEnd": ffprobe_dur(op),
        "url": f"/audio/joins/{op.name}", "peaks": [round(x, 4) for x in peaks(op, PEAKS_N)],
        "marks": [{"key": "join", "label": "record starts", "t": 0.0}],
        "settled": False, "noCandidate": False,
        "note": "First sound of the album. The head was trimmed by 1.45 s of digital silence.",
    })

    # --- every seam, as the listener hears it
    for i in range(len(files) - 1):
        outp = joins_dir / f"{i + 1:02d}-join.flac"
        tail_len = min(a.tail, durs[i])
        head_len = min(a.head, durs[i + 1])
        with tempfile.TemporaryDirectory() as td:
            pa, pb = Path(td) / "a.flac", Path(td) / "b.flac"
            okA = cut(files[i], pa, max(0.0, durs[i] - tail_len), tail_len)
            okB = cut(files[i + 1], pb, 0.0, head_len)
            if not (okA and okB and concat([pa, pb], outp)):
                print(f"! failed join {i + 1}")
                continue
        rows.append({
            "track": i + 1,
            "title": f"{title_of(files[i])} → {title_of(files[i + 1])}",
            "fromTitle": title_of(files[i]),
            "nominal": tail_len,                 # the seam, in clip seconds
            "clipStart": 0.0, "clipEnd": ffprobe_dur(outp),
            "url": f"/audio/joins/{outp.name}",
            "peaks": [round(x, 4) for x in peaks(outp, PEAKS_N)],
            "marks": [{"key": "join", "label": "seam", "t": tail_len}],
            "settled": False, "noCandidate": False,
            "note": f"end of #{i + 1} into start of #{i + 2}, as it ships "
                    f"({tail_len:.0f}s + {head_len:.0f}s)",
        })

    # --- closing: does the record END, or merely stop
    tail_len = min(a.tail + 10, durs[-1])
    ss = max(0.0, durs[-1] - tail_len)
    # The FIRST entry is never "dry" — it is the last track exactly as rendered,
    # which by now has the ear-chosen reverb baked in. Labelling it "dry" told
    # PLN the record ended without an echo while the file he was auditioning had
    # one, i.e. the audition UI lying about what ships, which is the single
    # failure this whole tool exists to prevent. So name it from the segments.
    shipped = ""
    try:
        rel = json.loads(Path(spec["segmentsRelease"]).read_text())
        last = (rel["segments"] if isinstance(rel, dict) else rel)[-1]
        if last.get("reverb_preset"):
            shipped = f" · {last['reverb_preset'].upper()} tail baked in"
    except Exception:
        pass                       # a missing segments file must not block audition
    variants = [("shipped", None)] + ([(k, CLOSE_AB[k]) for k in CLOSE_AB] if a.close_ab else [])
    for i, (name, echo) in enumerate(variants):
        cl = joins_dir / f"99{i}-close-{name}.flac"
        if not close_variant(files[-1], cl, ss, tail_len, a.wet, echo):
            continue
        rows.append({
            "track": 99 + i,
            "title": f"CLOSE · {name.upper()}" + (f" · {a.wet:.0f}s reverb tail"
                                                  if echo else shipped),
            "fromTitle": title_of(files[-1]), "nominal": tail_len,
            "clipStart": 0.0, "clipEnd": ffprobe_dur(cl),
            "url": f"/audio/joins/{cl.name}",
            "peaks": [round(x, 4) for x in peaks(cl, PEAKS_N)],
            "marks": [{"key": "join", "label": "record ends", "t": tail_len}],
            "settled": False, "noCandidate": False,
            "note": (f"Last sound of the album, exactly as rendered{shipped}."
                     if echo is None else
                     f"A/B option '{name}': the last {a.wet:.0f}s get an echo that "
                     f"rings past the final sample. Everything before the tail is "
                     f"identical to DRY."),
        })

    doc = {"gig": f"{spec['gig']}-release", "title": f"{spec.get('title','')} — release check",
           "generated": True, "pad": a.tail, "boundaries": rows}
    dest = Path(a.out or HERE.parent / "ui" / "public" / f"bounds-{spec['gig']}-release.json")
    dest.write_text(json.dumps(doc, indent=1))
    print(f"✓ {dest}  ({len(rows)} clips: open + {len(files)-1} seams + close)")
    print(f"  clips: {joins_dir}")
    return 0


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