#!/usr/bin/env python3
"""render_release — take a mastered set to a releasable record. Spec-driven.

Replaces the per-gig `finish.sh`. PLN: *"this becomes our tooling, often a set
will be postprocessed as we did — reusable scripts and views plz"*. Every path
comes from the judge spec, so a new gig is a copy of `judge_specs/<gig>.json`,
never a copy of a script.

Three stages, each skippable:

  split     slice every variant (streaming, club, …) into tracks from the
            ear-verified segments, tagging artist/album/date
  verify    every rendered duration against the segments, plus format — fails
            loudly rather than shipping a wrong album a second time
  continuous  re-render the FULL-LENGTH mix with the dropped tracks excised

## Why `continuous` exists

A set is released twice: as tracks, and as the unbroken mix. Cutting a track
from the release only fixed the first one — the continuous master still played
it. PLN caught this: *"imo the master club rec will also have desire removed"*.
It matters beyond tidiness: the continuous upload is the LOWER-RISK rights path
(an isolated track flagged on SoundCloud where the 87-minute mix passed), so the
mix is the artefact most likely to ship, and it must match the tracklist.

The excision is derived from the same segments the tracks came from — the kept
spans, merged where they touch — so the mix and the tracklist cannot disagree.
Rendered with the concat demuxer over losslessly-cut pieces: sample-exact, and
it never resamples, so a seam that clicks is a real seam and not an artefact of
this tool.

    python3 render_release.py judge_specs/opal26.json            # all stages
    python3 render_release.py judge_specs/opal26.json --only continuous
"""
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 ffprobe_dur  # noqa: E402

TOL = 0.05      # seconds a rendered track may differ from its segment

# Reverb-tail presets. These are THE definition — build_release_joins imports
# them so the A/B PLN auditions is bit-for-bit the chain that ships. They lived
# in two places for one commit and already disagreed (a single 833ms slap here
# vs. a three-tap preset there, and this one washed the whole track instead of
# the tail). One concept, one table.
CLOSE_AB = {
    "subtle": "0.8:0.9:60|140|240:0.28|0.18|0.10",
    "more":   "0.8:0.9:120|260|420|650:0.38|0.28|0.20|0.13",
}
RING = 2.5      # seconds the reverb may ring past the last sample


def sh(cmd, **kw):
    return subprocess.run(cmd, capture_output=True, text=True, **kw)


def filters(seg: dict) -> list[str]:
    """The ffmpeg -af chain for one segment's ear-requested fades.

    Kept in ONE place so a track and the continuous mix cannot disagree about
    what a fade sounds like.
    """
    dur = seg["end"] - seg["start"]
    af = []
    if seg.get("fade_in_s"):
        af.append(f"afade=t=in:st=0:d={float(seg['fade_in_s']):.4f}")
    if seg.get("fade_out_s"):
        d = float(seg["fade_out_s"])
        to = float(seg.get("fade_out_to", 0.0))
        st = max(0.0, dur - d)
        if to <= 0.0:
            af.append(f"afade=t=out:st={st:.4f}:d={d:.4f}")
        else:
            # afade only goes to silence. A partial duck (PLN: "from 100 to 50%
            # to lower the sudden cut") is a gain ramp, so express it directly.
            af.append(f"volume='if(gte(t,{st:.4f}),1-{1-to:.4f}*(t-{st:.4f})/{d:.4f},1)'"
                      f":eval=frame")
    return af


def has_reverb(seg: dict) -> bool:
    return bool(seg.get("reverb_tail_s")) and seg.get("reverb_preset") in CLOSE_AB


def reverb_complex(seg: dict) -> str:
    """Filter graph applying the ear-chosen reverb to the segment's TAIL only.

    A plain `-af aecho` would wet the ENTIRE track — PLN asked for the last few
    seconds. So the stream is split: the head passes through dry, the tail gets
    `apad` (so the ring has somewhere to go instead of being chopped at the last
    sample, which would reintroduce exactly the hard stop the reverb is there to
    soften) and then the echo. Pre-filters — fades — apply to both halves, so
    they must run BEFORE the split.
    """
    dur = seg["end"] - seg["start"]
    split = max(0.0, dur - float(seg["reverb_tail_s"]))
    pre = ",".join(filters(seg))
    chain = f"[0:a]{pre + ',' if pre else ''}asplit=2[p][q];"
    return (chain +
            f"[p]atrim=0:{split:.4f},asetpts=N/SR/TB[dry];"
            f"[q]atrim={split:.4f},asetpts=N/SR/TB,apad=pad_dur={RING},"
            f"aecho={CLOSE_AB[seg['reverb_preset']]}[wet];"
            f"[dry][wet]concat=n=2:v=0:a=1[out]")


def expected_dur(seg: dict) -> float:
    """A reverb tail makes the track LONGER by its ring — not a mismatch."""
    return (seg["end"] - seg["start"]) + (RING if has_reverb(seg) else 0.0)


def sanitize(name: str) -> str:
    return "".join("_" if c in '/\\:*?"<>|' else c for c in name).strip().strip(".")


def kept_spans(segs: list[dict]) -> list[tuple[float, float]]:
    """Merge the segments into contiguous keep ranges.

    Adjacent tracks are butt-joined, so the merge collapses them; a gap only
    survives where a track was CUT from the release. That gap is the whole
    point — it is what gets excised from the continuous mix.
    """
    spans: list[list[float]] = []
    for s in sorted(segs, key=lambda x: x["start"]):
        if spans and s["start"] - spans[-1][1] < 0.001:
            spans[-1][1] = s["end"]
        else:
            spans.append([s["start"], s["end"]])
    return [(a, b) for a, b in spans]


def stage_split(spec, segs, seg_path: Path) -> int:
    bad = 0
    for variant, master in spec["variants"].items():
        m = Path(master)
        if not m.exists():
            print(f"  MISSING {m}")
            return 1
        out = Path(spec["releaseRoot"]) / f"tracks_{spec['releaseTag']}_{variant}"
        print(f"=== split {variant}: {ffprobe_dur(m):.3f} s -> {out.name}")
        if out.exists():
            for f in out.glob("*.flac"):
                f.unlink()
        out.mkdir(parents=True, exist_ok=True)
        for s in segs:
            dest = out / f"{s['track']:02d} - {sanitize(s['title'])}.flac"
            cmd = ["ffmpeg", "-v", "error", "-y", "-ss", str(s["start"]),
                   "-to", str(s["end"]), "-i", str(m),
                   "-metadata", f"title={s['title']}",
                   "-metadata", f"track={s['track']}/{len(segs)}"]
            for k, tag in (("artist", "artist"), ("album", "album"), ("date", "date")):
                if spec.get(k):
                    cmd += ["-metadata", f"{tag}={spec[k]}"]
            # Re-encode, never `-c copy`: a FLAC stream copy carries the SOURCE
            # STREAMINFO, so every track claims the whole master's length. The
            # audio is right and only the header lies, which is worse than a
            # loud failure — players show wrong durations and stores reject it.
            if has_reverb(s):
                cmd += ["-filter_complex", reverb_complex(s), "-map", "[out]"]
            else:
                af = filters(s)
                if af:
                    cmd += ["-af", ",".join(af)]
            cmd += ["-c:a", "flac", "-compression_level", "5", str(dest)]
            if sh(cmd).returncode != 0:
                print(f"  FAIL {dest.name}")
                bad += 1
        print(f"  {len(list(out.glob('*.flac')))} tracks")
    return bad


def stage_verify(spec, segs) -> int:
    bad = 0
    for variant in spec["variants"]:
        d = Path(spec["releaseRoot"]) / f"tracks_{spec['releaseTag']}_{variant}"
        files = sorted(d.glob("*.flac"))
        if len(files) != len(segs):
            print(f"  {variant}: {len(files)} files, expected {len(segs)}  MISMATCH")
            bad += 1
            continue
        for s, f in zip(segs, files):
            got = ffprobe_dur(f)
            want = expected_dur(s)
            if abs(got - want) >= TOL:
                print(f"  {variant} #{s['track']:>2} {s['title'][:26]:<26} "
                      f"want {want:>8.2f} got {got:>8.2f}  MISMATCH")
                bad += 1
        fmt = sh(["ffprobe", "-v", "error", "-show_entries",
                  "stream=sample_rate,bits_per_raw_sample", "-of", "csv=p=0",
                  str(files[0])]).stdout.strip()
        print(f"  {variant}: {len(files)} tracks OK, format {fmt}")
    return bad


def stage_continuous(spec, segs) -> int:
    """The continuous mix = the RELEASE TRACKS, concatenated.

    The first version cut merged "kept spans" straight from the master. That was
    correct for boundaries but wrong the moment per-track fades appeared: the mix
    would have played the un-faded audio while the tracks were faded, and nothing
    would have flagged it. Cutting the same segments with the same filter chain
    and joining them makes the mix literally the tracklist, so the two cannot
    drift. Gaps (trimmed audio, dropped tracks) fall out for free — they are
    simply not in any segment.
    """
    total = sum(expected_dur(s) for s in segs)
    gaps = [(x["end"], y["start"]) for x, y in zip(segs, segs[1:]) if y["start"] - x["end"] > 0.001]
    print(f"=== continuous: {len(segs)} segments, {total/60:.1f} min, {len(gaps)} gap(s)")
    for a, b in gaps:
        print(f"    dropped [{a:>9.2f} .. {b:>9.2f}]  {b-a:6.2f}s")
    bad = 0
    for variant, master in spec["variants"].items():
        m = Path(master)
        dest = m.with_name(m.stem + "_nodrop" + m.suffix)
        with tempfile.TemporaryDirectory() as td:
            parts = []
            for i, seg in enumerate(segs):
                pth = Path(td) / f"{i:02d}.flac"
                cmd = ["ffmpeg", "-v", "error", "-y", "-ss", f"{seg['start']:.6f}",
                       "-to", f"{seg['end']:.6f}", "-i", str(m)]
                if has_reverb(seg):
                    cmd += ["-filter_complex", reverb_complex(seg), "-map", "[out]"]
                else:
                    af = filters(seg)
                    if af:
                        cmd += ["-af", ",".join(af)]
                cmd += ["-c:a", "flac", "-compression_level", "3", str(pth)]
                r = sh(cmd)
                if r.returncode != 0:
                    print(f"  FAIL cutting {variant} #{seg['track']}: {r.stderr[:150]}")
                    bad += 1
                parts.append(pth)
            lst = Path(td) / "list.txt"
            lst.write_text("".join(f"file '{p}'\n" for p in parts))
            r = sh(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0",
                    "-i", str(lst), "-c:a", "flac", "-compression_level", "5",
                    str(dest)])
        if r.returncode != 0:
            print(f"  FAIL {dest.name}: {r.stderr[:200]}")
            bad += 1
            continue
        got = ffprobe_dur(dest)
        ok = abs(got - total) < TOL
        bad += not ok
        print(f"  {variant}: {dest.name}  {got:.2f}s "
              f"({'OK' if ok else f'MISMATCH, wanted {total:.2f}'})")
    return bad


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("spec")
    ap.add_argument("--segments", help="default: spec.segmentsRelease")
    ap.add_argument("--only", choices=["split", "verify", "continuous"], action="append")
    a = ap.parse_args()

    spec = json.loads(Path(a.spec).read_text())
    seg_path = Path(a.segments or spec["segmentsRelease"])
    segs = json.loads(seg_path.read_text())
    if isinstance(segs, dict):
        segs = segs["segments"]
    stages = a.only or ["split", "verify", "continuous"]

    print(f"{spec['gig']} · {spec.get('album','')} · {len(segs)} tracks "
          f"· variants: {', '.join(spec['variants'])}")
    print(f"segments: {seg_path}\n")

    bad = 0
    if "split" in stages:
        bad += stage_split(spec, segs, seg_path)
    if "verify" in stages:
        print("=== verify")
        bad += stage_verify(spec, segs)
    if "continuous" in stages:
        bad += stage_continuous(spec, segs)

    print("\n" + ("ALL OK" if not bad else f"{bad} PROBLEM(S)"))
    return 1 if bad else 0


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