#!/usr/bin/env python3
"""apply_boundaries — turn ear-verified cuts into the segments the splitter reads.

This is the step that shipped a wrong album once already, so it is a tool with
assertions rather than a one-off edit. Three rules, and every one of them exists
because getting it wrong is silent:

1. **Starts come from the ear, ends come from the neighbour.** The set is
   butt-joined, so a track's end is the NEXT track's start. Editing a start
   therefore moves two tracks, which is exactly how a hand edit drifts.

2. **A track cut from the release still owns its boundary.** Desire is dropped,
   but the edge into Desire is where Vague de CRIME *ends*. Dropping the track
   without honouring its start would run the last official track straight into
   Desire's intro. The cut track's verified start becomes the previous track's
   end, and only then is it removed.

3. **A trim is not a boundary move.** `verified` holds boundaries: moving one
   shifts BOTH neighbouring tracks, because the set is butt-joined. `edits`
   holds per-track trims and fades: an `end` there DROPS audio instead of
   handing it to the next track, opening a gap the continuous mix must also
   skip. Confusing the two silently gives a neighbour a second of someone
   else's silence, which is exactly the artefact these edits exist to remove.

4. **Release position is not track number.** The originals are numbered by the
   PERFORMANCE order; removing one track renumbers the album but must not
   renumber the provenance. Both are emitted: `track` (as performed, the key
   everything else joins on) and `n` (position on the release).

Validated on the way out: no overlaps, no negative durations, every segment
inside the master, and every ear-verified start actually present in the output.

    python3 apply_boundaries.py judge_specs/opal26.json \
        --ear judge_specs/opal26_boundaries_ear.json \
        --out /home/pln/Work/Sound/Prod/Opal26_master/segments_v4.json
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

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


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("spec")
    ap.add_argument("--ear", required=True)
    ap.add_argument("--out", required=True)
    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())
    verified = {int(k): v for k, v in ear.get("verified", {}).items()}
    edits = {int(k): v for k, v in ear.get("edits", {}).items()}
    cut = {int(k) for k in ear.get("cut_from_release", {})}
    mdur = ffprobe_dur(Path(spec["master"]))

    # 1 — apply every ear-verified start, keeping the nominal where none exists
    starts: dict[int, float] = {}
    for s in segs:
        t = s["track"]
        v = verified.get(t, {})
        starts[t] = float(v["start"]) if v.get("start") is not None else float(s["start"])
    # a per-track `start` edit trims the HEAD: the previous track keeps its own
    # end, so this too opens a gap rather than moving a shared boundary
    head_trim = {t: float(e["start"]) for t, e in edits.items() if e.get("start") is not None}

    order = sorted(starts)
    rows, n = [], 0
    for i, t in enumerate(order):
        if t in cut:
            continue                      # rule 2: its start was already used below
        nxt = order[i + 1] if i + 1 < len(order) else None
        end = starts[nxt] if nxt is not None else mdur   # incl. a cut track's start
        ed = edits.get(t, {})
        if ed.get("end") is not None:
            end = float(ed["end"])        # a TRIM: the dropped audio goes nowhere
        n += 1
        src = next(s for s in segs if s["track"] == t)
        rows.append({
            # `track` IS the release position, because tidal_ears.master split
            # uses it for both the filename and the `track=N/total` tag. Emitting
            # performance order here would name the last file "15 - ..." in a
            # 14-track album and tag it 15/14, which some stores reject outright.
            "track": n,
            "perf_track": t,              # performance order — the join key
            "title": src["title"],
            "start": round(head_trim.get(t, starts[t]), 3),
            "end": round(end, 3),
            "duration": round(end - head_trim.get(t, starts[t]), 3),
            "bpm": src.get("bpm"),
            "source": "ear" if verified.get(t, {}).get("start") is not None else "nominal",
            # Pass through every edit key EXCEPT the ones consumed above. An
            # allow-list silently dropped `reverb_preset` and the renderer fell
            # back to no reverb — a missing edit is invisible in the output, so
            # deny-list the four we handle and forward the rest.
            **{k: v for k, v in ed.items()
               if k not in ("start", "end", "why") and v is not None},
            **({"edited": ed.get("why", True)} if ed else {}),
        })

    # 2 — assertions. Each one has cost us a re-render at some point.
    problems = []
    for r in rows:
        if r["duration"] <= 0:
            problems.append(f"#{r['perf_track']} {r['title']}: non-positive duration {r['duration']}")
        if r["start"] < 0 or r["end"] > mdur + 0.01:
            problems.append(f"#{r['perf_track']}: [{r['start']}, {r['end']}] outside master (0, {mdur:.2f})")
    for t, e in edits.items():
        if t not in starts:
            problems.append(f"edit for #{t} but no such track")
            continue
        lo, hi = starts[t], next((starts[o] for o in order if o > t), mdur)
        for key in ("start", "end"):
            if e.get(key) is not None and not (lo - 0.001 <= float(e[key]) <= hi + 0.001):
                problems.append(f"edit #{t} {key}={e[key]} outside its own span [{lo}, {hi}]")
    for x, y in zip(rows, rows[1:]):
        if y["start"] < x["end"] - 0.001:   # gaps are legal (trims); overlaps are not
            problems.append(f"overlap: #{x['perf_track']} ends {x['end']} but #{y['perf_track']} starts {y['start']}")
    for t, v in verified.items():
        if v.get("start") is None or t in cut:
            continue
        want = head_trim.get(t, float(v["start"]))
        if not any(abs(r["start"] - want) < 0.001 for r in rows):
            problems.append(f"ear-verified #{t} start {want} did not reach the output")
    if problems:
        print("REFUSING TO WRITE — " + f"{len(problems)} problem(s):")
        for p in problems:
            print("  ✗ " + p)
        return 1

    # The consumed file is a PLAIN LIST because that is what the splitter reads;
    # a wrapper dict would have to be unwrapped somewhere, and the file that gets
    # validated must be the file that gets consumed. Provenance goes beside it.
    Path(a.out).write_text(json.dumps(rows, indent=1) + "\n")
    meta = Path(a.out).with_suffix(".meta.json")
    meta.write_text(json.dumps({
        "_comment": [
            "Provenance for the sibling segments list. Do not hand-edit either:",
            "edit judge_specs/<gig>_boundaries_ear.json and re-run apply_boundaries.py.",
            "In the list, `track` is RELEASE position (what the splitter tags) and",
            "`perf_track` is performance order (the join key for stems/scores).",
        ],
        "gig": spec["gig"],
        "master": spec["master"],
        "master_duration": round(mdur, 3),
        "cut_from_release": sorted(cut),
        "tracks": len(rows),
        "kept_s": round(sum(r["duration"] for r in rows), 3),
    }, indent=1) + "\n")

    total = sum(r["duration"] for r in rows)
    print(f"{'rel':>2} {'#':>3} {'title':<32} {'start':>9} {'end':>9} {'dur':>8}  src")
    for r in rows:
        print(f"{r['track']:>2} {r['perf_track']:>3} {r['title'][:32]:<32} "
              f"{r['start']:>9.2f} {r['end']:>9.2f} {r['duration']:>8.2f}  {r['source']}")
    print(f"\n{len(rows)} tracks, {total/60:.1f} min kept of {mdur/60:.1f} min master "
          f"({(mdur-total)/60:.1f} min dropped)")
    print(f"✓ {a.out}")
    return 0


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