#!/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",
}
# NO apad as reverb headroom. The first version padded 2.5s "so the ring has
# somewhere to go", reasoning that ffmpeg would otherwise chop the echo at the
# last sample. That reasoning was wrong and the measurement settles it: aecho
# extends its own output by its longest tap, and every second of apad becomes
# trailing DIGITAL SILENCE one-for-one.
#     no apad   -> 5.240s wet, 0.000s trailing silence   (full ring, nothing cut)
#     apad=2.5  -> 7.740s wet, 2.500s trailing silence
# Shipped once: the record ended with ~2.3s of pure zeros — exactly the defect
# PLN flagged at the START of the record ("trim leading silence tho").
#
# `pad_end_s` is the SAME filter used deliberately, and the distinction is the
# whole lesson: silence as an accident of a misunderstood filter is a defect,
# silence a listener asked for is a rest. PLN on the CRIME->REVOLUTION seam:
# *"lets add after seam 2s silence in crime file, then seam is start of [the]
# revolution"*. One-for-one is exactly what makes it usable — pad_end_s=2.0
# yields 2.000s, no rounding, no guesswork.


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 tail_pad(seg: dict) -> str | None:
    """A REST the ear asked for, appended after everything else.

    It must come last in the chain, after the fades and after any reverb ring:
    padding first would put the silence where the fade still has audio to act
    on, and the fade's own start is measured from the un-padded duration.
    """
    if not seg.get("pad_end_s"):
        return None
    return f"apad=pad_dur={float(seg['pad_end_s']):.4f}"


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
    the echo, and the echo is allowed to ring PAST the segment's last sample
    rather than being chopped there (which would reintroduce exactly the hard
    stop the reverb exists to soften). Pre-filters — fades — apply to both
    halves, so they must run BEFORE the split; a tail pad applies to neither, so
    it runs after the join.
    """
    dur = seg["end"] - seg["start"]
    split = max(0.0, dur - float(seg["reverb_tail_s"]))
    pre = ",".join(filters(seg))
    pad = tail_pad(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,"
            f"aecho={CLOSE_AB[seg['reverb_preset']]}[wet];"
            f"[dry][wet]concat=n=2:v=0:a=1" + (f",{pad}" if pad else "") + "[out]")


def echo_extension(preset: str) -> float:
    """How much longer `aecho` makes its input: its LONGEST delay tap.

    Missed on the first render and the verify stage caught it — REVOLUTION came
    out 207.64s against an expected 207.40, exactly the subtle preset's 240ms
    top tap. Worth stating plainly because the temptation was to `atrim` the
    output back to the predicted length, which would have silently clipped the
    end of the very ring the reverb exists to produce. The prediction was wrong,
    not the audio.
    """
    delays = CLOSE_AB[preset].split(":")[2]
    return max(float(d) for d in delays.split("|")) / 1000.0


def expected_dur(seg: dict) -> float:
    """Audio length + everything the effects ADD to it.

    Both terms lengthen the output and both are exactly predictable: aecho's
    longest tap, and apad one-for-one. Predicting them is what lets `verify`
    compare against INTENT rather than against whatever came out.
    """
    d = seg["end"] - seg["start"]
    if has_reverb(seg):
        d += echo_extension(seg["reverb_preset"])
    return d + float(seg.get("pad_end_s") or 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) + [p for p in (tail_pad(s),) if p]
                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")
    # A pad is the opposite of a gap and must not be silently lumped in with
    # one: a gap removes audio the listener would have heard, a pad ADDS a rest
    # the listener asked for. Both change the running time, so print both.
    for s in segs:
        if s.get("pad_end_s"):
            print(f"    rest after #{s['track']} {s['title'][:26]:<26} "
                  f"{float(s['pad_end_s']):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) + [p for p in (tail_pad(seg),) if p]
                    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())
