#!/usr/bin/env python3
"""take-segments — derive a take's TRACKLIST from the gig-log, not from the audio.

Splitting a livecoded set by listening to it is hard and we have the scars: orbits
stay up across a transition, there is no silence at the seam, and a boundary
detector trained on gaps finds the breakdowns instead of the track changes (see
`tidal-ears master boundaries`, and the notes in armada/tide-table).

But we already record the answer. `gig-log.py` writes a `track` event every time
the active `.tidal` file changes in Pulsar:

    {"t": 1785350744.459, "k": "track",
     "path": "live/collab/raph/mafia_sans_serif.tidal",
     "from": "live/midi/nova/dnb/liquid/you_my_sunshine.tidal"}

That is ground truth for WHAT was playing, at 1 Hz, for free, with no DSP.

THE ONE THING IT IS NOT is a tracklist, because PLN tabs around while he plays —
glancing at the next track, checking a helper, flicking back. Take95 has
`wap -> do_it_right -> wap -> do_it_right` inside 90 seconds. So every segment
shorter than --min-dwell is a GLANCE, not a performance, and is absorbed into
whatever surrounds it.

Emits the `--segments` shape `tidal-ears master split` already accepts, so the
mastering pipeline needs no changes:

    [{"track": 1, "start": 7.4, "end": 233.1, "title": "Ceci n'est pas Une Bombe"}]

Titles come from tools/setlist.py (backlog.md, the SSOT) when the track is in the
set, so a rename in the backlog reaches the ID3 tags. Tracks played but not in
the set keep their file stem, prettified.

Usage:
    tools/take-segments.py --take Take95                    # infer window from the wavs
    tools/take-segments.py --take Take95 --min-dwell 90
    tools/take-segments.py --take Take95 -o segments.json
"""
from __future__ import annotations

import argparse
import datetime as dt
import glob
import json
import os
import pathlib
import re
import subprocess
import sys

ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tools"))

LOG_DIR = pathlib.Path.home() / ".local/share/parvagues/gig-log"
ARDOUR = pathlib.Path.home() / "Work/Sound/Ardour"


def newest_log() -> pathlib.Path:
    logs = sorted(LOG_DIR.glob("gig-*.jsonl"), key=lambda p: p.stat().st_mtime)
    if not logs:
        raise SystemExit(f"take-segments: no gig-log under {LOG_DIR}")
    return logs[-1]


def take_window(take: str) -> tuple[float, float]:
    """(start, end) unix seconds for a take, from its own stems.

    Ardour writes every stem of a take continuously and closes them together, so
    mtime is end-of-record and soxi gives the length. Deriving the window from the
    AUDIO rather than from the log means a log that kept running after the
    transport stopped cannot stretch the take.
    """
    hits = sorted(glob.glob(str(ARDOUR / "**" / f"{take}_*.wav"), recursive=True))
    if not hits:
        raise SystemExit(f"take-segments: no stems matching {take}_*.wav under {ARDOUR}")
    ref = hits[0]
    out = subprocess.run(["soxi", "-D", ref], capture_output=True, text=True)
    if out.returncode != 0:
        raise SystemExit(f"take-segments: soxi failed on {ref}: {out.stderr.strip()}")
    dur = float(out.stdout)
    end = os.path.getmtime(ref)
    return end - dur, end


def raw_segments(log: pathlib.Path, start: float, end: float) -> list[dict]:
    """Contiguous [start,end) spans of one .tidal file, in take-relative seconds."""
    events = []
    with log.open() as fh:
        for line in fh:
            try:
                e = json.loads(line)
            except json.JSONDecodeError:
                continue                      # a torn last line while recording
            if e.get("k") == "track" and start <= e.get("t", 0) <= end:
                events.append(e)
    if not events:
        return []

    # The FIRST event's `from` tells us what was already playing when the take
    # opened — without it the set would start at the second track.
    segs = []
    first = events[0]
    if first.get("from"):
        segs.append({"path": first["from"], "start": 0.0})
    for e in events:
        segs.append({"path": e["path"], "start": e["t"] - start})
    for a, b in zip(segs, segs[1:]):
        a["end"] = b["start"]
    segs[-1]["end"] = end - start
    return segs


def debounce(segs: list[dict], min_dwell: float) -> list[dict]:
    """Absorb glances, then merge neighbours that are the same track.

    Two passes, repeated to a fixed point: dropping a glance can put two spans of
    the same track next to each other, and merging those can leave a span that is
    STILL under the threshold. One pass of each is not enough — Take95's
    take_5_drops/do_it_right flicker needs three rounds to settle.
    """
    segs = [dict(s) for s in segs]
    for _ in range(50):
        merged = []
        for s in segs:
            if merged and merged[-1]["path"] == s["path"]:
                merged[-1]["end"] = s["end"]
            else:
                merged.append(s)
        if len(merged) > 1:
            short = [i for i, s in enumerate(merged)
                     if s["end"] - s["start"] < min_dwell]
            if short:
                i = min(short, key=lambda j: merged[j]["end"] - merged[j]["start"])
                # Give the glance's time to whichever neighbour is longer: the
                # track you were ON, not the one you peeked at.
                prev = merged[i - 1] if i > 0 else None
                nxt = merged[i + 1] if i + 1 < len(merged) else None
                if prev and (not nxt or (prev["end"] - prev["start"])
                             >= (nxt["end"] - nxt["start"])):
                    prev["end"] = merged[i]["end"]
                else:
                    nxt["start"] = merged[i]["start"]
                merged.pop(i)
        if merged == segs:
            return merged
        segs = merged
    return segs


def titles() -> dict[str, str]:
    """stem -> the codename PLN wrote in backlog.md. The SSOT reaches the ID3 tag."""
    try:
        import setlist
        return {e.path.stem: e.codename for e in setlist.entries()}
    except Exception as exc:                  # never block an export on the backlog
        print(f"take-segments: setlist titles unavailable ({exc})", file=sys.stderr)
        return {}


def prettify(stem: str) -> str:
    return re.sub(r"[_-]+", " ", stem).strip().title()


def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--take", required=True, help="Ardour take prefix, e.g. Take95")
    ap.add_argument("--log", type=pathlib.Path, help="gig-log jsonl (default: newest)")
    ap.add_argument("--min-dwell", type=float, default=60.0,
                    help="seconds a track must hold to count as played (default 60)")
    ap.add_argument("--shift", type=float, default=0.0,
                    help="seconds to subtract from every offset — the LEADING "
                         "silence take-master trimmed off the head of the master")
    ap.add_argument("--master", type=pathlib.Path,
                    help="the mastered file these segments will slice; its "
                         "duration clamps the last segment (the master is "
                         "trimmed at the tail too)")
    ap.add_argument("-o", "--output", type=pathlib.Path,
                    help="write JSON here (default: stdout table only)")
    args = ap.parse_args()

    log = args.log or newest_log()
    start, end = take_window(args.take)
    segs = debounce(raw_segments(log, start, end), args.min_dwell)
    if not segs:
        raise SystemExit(f"take-segments: no track events inside {args.take}'s window — "
                         f"was gig-log running?")

    # THE OFFSETS ARE RELATIVE TO THE RAW TAKE; THE MASTER IS EDGE-TRIMMED.
    # take-master strips leading silence before loudnorm, so a master built from
    # Take95 starts 10.94 s later in the music than the take does. Splitting the
    # master with unshifted offsets puts every boundary ~11 s late — each track
    # would begin eleven seconds into the previous one's tail. Measure the head
    # trim (silencedetect at -60 dB on the summed stems) and pass it as --shift.
    #
    # Clamped at 0 because the first track legitimately goes negative: PLN
    # focuses a .tidal file a few seconds BEFORE he evaluates it, so the log's
    # first `track` event (7.4 s) precedes the first sound (10.94 s).
    master_len = None
    if args.master:
        if not args.master.exists():
            raise SystemExit(f"take-segments: no master at {args.master}")
        r = subprocess.run(["ffprobe", "-v", "error", "-show_entries",
                            "format=duration", "-of", "csv=p=0",
                            str(args.master)], capture_output=True, text=True)
        master_len = float(r.stdout.strip())

    tmap = titles()
    out = []
    # NOT `start`/`end` — those name the TAKE WINDOW above, and shadowing them
    # here made the header report the last segment as if it were the whole take
    # ("1970-01-01, 5.1 min"). The segments were right; only the summary lied,
    # which is the kind of bug you ship because the payload looks fine.
    for i, s in enumerate(segs, 1):
        stem = pathlib.Path(s["path"]).stem
        t0 = max(0.0, s["start"] - args.shift)
        t1 = max(t0, s["end"] - args.shift)
        if master_len is not None:
            # The master is trimmed at BOTH ends. Without this the final segment
            # runs 1.5 s past EOF and ffmpeg silently writes a short last track.
            t0, t1 = min(t0, master_len), min(t1, master_len)
        out.append({"track": i, "start": round(t0, 3),
                    "end": round(t1, 3),
                    "title": tmap.get(stem) or prettify(stem),
                    "source": s["path"]})
    out = [s for s in out if s["end"] - s["start"] > 1.0]

    print(f"{args.take}: {dt.datetime.fromtimestamp(start):%Y-%m-%d %H:%M} — "
          f"{(end - start) / 60:.1f} min, {len(out)} tracks "
          f"(min-dwell {args.min_dwell:.0f}s"
          + (f", shift -{args.shift:.2f}s" if args.shift else "")
          + (f", clamped to master {master_len:.1f}s" if master_len else "") + ")\n")
    for s in out:
        d = s["end"] - s["start"]
        print(f"  {s['track']:>2}. {int(s['start'])//60:>3}:{int(s['start'])%60:02d}"
              f"  {int(d)//60:>2}:{int(d)%60:02d}   {s['title']}")

    if args.output:
        args.output.write_text(json.dumps(out, indent=2, ensure_ascii=False) + "\n")
        print(f"\nwrote {args.output}")
    return 0


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