"""Build the commentary audio track for a demo MP4, in one language.

This mirrors how the live page schedules speech (core/web/app.js): clips play ONE AT A TIME — the next
starts only when the current finishes, never overlapping — and when commentary bunches up only the
freshest waiting line is spoken, the rest dropped (no backlog; there are plenty of races). A long
comment is emitted as a GROUP of short sub-events (shared `grp`); a group is spoken as one contiguous
block — its parts back-to-back with a breath of silence between, never sliced apart.

The START SEQUENCE is spoken, not beeped: every whole minute is called by name and the last ten
seconds are counted down a bare number a second. Those lines are PINNED to the second they name and
the free-running speaker flows around them — the voice is the signal now, and "three" arriving where
"two" belongs is not a late line, it is the wrong number.

Clips come from demo/data/audio/<lang>/ (built by the cloud live/tts/synth.py — MP3) keyed by the
exact line text in demo/data/audio/index.json. Output is an AAC .m4a placed on the real race-time
line, so it drops straight onto a real-time video.
"""

import json
import subprocess
from pathlib import Path


def _clip_seconds(path):
    """Duration of a commentary clip (MP3 from the cloud synth), probed with ffprobe."""
    out = subprocess.run(
        [
            "ffprobe",
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
            str(path),
        ],
        capture_output=True,
        text=True,
        check=True,
    ).stdout.strip()
    return float(out)


def build_audio(lang, root, out_path, feed=None, *, start_sec=0.0):
    """Render a race's commentary (in `lang`) to `out_path` (.m4a).

    `feed` is the race feed dict (events + meta); when None, the standalone demo/data/races/real.json
    is used (back-compat). Returns the start time (s) of the last spoken line, for logging. Raises if
    `lang` has no clips."""
    root = Path(root)
    audio_dir = root / "demo" / "data" / "audio"
    if feed is None:
        with open(root / "demo" / "data" / "races" / "real.json", encoding="utf-8") as f:
            feed = json.load(f)
    with open(audio_dir / "index.json", encoding="utf-8") as f:
        index = json.load(f)
    idx = index.get(lang)
    if not idx:
        raise SystemExit(f"no audio clips for '{lang}' — run live/tts/synth.py --lang {lang} (and map it in LANG_CODE)")

    # (The gun offset used to be needed to place the tones. The sequence is spoken now, and each
    # countdown line already carries its own `tSec`, so nothing here has to know where the gun is.)

    # Each spoken UNIT is one event, or a GROUP of split sub-events (same `grp`) that a long comment was
    # broken into — a group is spoken as one contiguous block (its parts back-to-back with a breath of
    # silence between), never sliced by another line. Build units in time order: each carries an anchor
    # tSec, its ordered clip parts [(rel, dur)], and whether it's a priority call.
    PRIORITY = {"start", "winner", "countdown", "penalty"}
    groups, order = {}, []
    for e in sorted(feed["events"], key=lambda ev: (ev["tSec"], ev.get("seq", 0))):
        if e["tSec"] < start_sec:
            continue
        rel = idx.get((e.get("i18n") or {}).get(lang))
        if not rel:
            continue  # missing/empty (e.g. a trailing silent split slot)
        part = (rel, _clip_seconds(audio_dir / rel))
        prio = e.get("type") in PRIORITY
        is_count = e.get("type") == "countdown"
        grp = e.get("grp")
        if grp is None:
            order.append({"tSec": e["tSec"] - start_sec, "parts": [part], "prio": prio,
                          "countdown": is_count})
        elif grp in groups:
            groups[grp]["parts"].append(part)  # later seq -> appended in time order
            groups[grp]["prio"] = groups[grp]["prio"] or prio
        else:
            groups[grp] = {"tSec": e["tSec"] - start_sec, "parts": [part], "prio": prio,
                           "countdown": is_count}
            order.append(groups[grp])
    units = sorted(order, key=lambda u: u["tSec"])

    # THE COUNTDOWN IS PINNED TO THE CLOCK; everything else flows around it.
    #
    # The rest of the track is decide-at-sentence-end (mirrors app.js): the speaker plays one unit at
    # a time, and the moment it frees it speaks the FRESHEST unit that has come due and DROPS the
    # rest — no backlog. That serialisation used to be harmless for a priority call too, because a
    # beep sounded on the exact second and the spoken line only confirmed it. With the tones gone the
    # VOICE is the signal: "three" arriving where "two" belongs is not a late line, it is the wrong
    # number, and a start sequence that drifts is worse than none.
    #
    # So countdown units are placed at their own second and the free-running speaker is pushed past
    # them. Within a unit the parts play contiguously, separated by LINE_GAP of silence.
    LINE_GAP = 0.45  # breath of silence between consecutive clips
    counts = [u for u in units if u.get("countdown")]
    units = [u for u in units if not u.get("countdown")]
    placements, free_at, i, n = [], 0.0, 0, len(units)
    while i < n:
        if units[i]["tSec"] >= free_at:  # speaker was free & waiting — speak this at its time
            unit = units[i]
            start = unit["tSec"]
            i += 1
        else:  # busy until free_at: among the units that arrived by
            j = i  # then (tSec <= free_at) speak the best one, drop the rest
            while j < n and units[j]["tSec"] <= free_at:
                j += 1
            pris = [u for u in units[i:j] if u["prio"]]
            unit = pris[-1] if pris else units[j - 1]  # keep a priority call, else the freshest
            start = free_at
            i = j
        t = start
        for rel, dur in unit["parts"]:  # the unit's clips, contiguous with a breath between
            placements.append((t, rel))
            t += dur + LINE_GAP
        free_at = t

    # The pinned countdown, each call on the second it names. No tones: the sequence is spoken.
    for u in counts:
        t = u["tSec"]
        for rel, dur in u["parts"]:
            if t >= 0:
                placements.append((t, rel))
            t += dur

    # PATHS RELATIVE TO `audio_dir`, and ffmpeg is run FROM there. Every clip is a separate `-i`, and
    # a commentary track for a long race has hundreds of them: at ~90 characters per absolute path
    # that alone approaches Windows' ~32 KB command-line ceiling, and the filter graph below pushes it
    # over. Measured 2026-08-09 — a 33-minute race with 328 commentary events died with
    # `[WinError 206] The filename or extension is too long` before a single frame was rendered, while
    # a 24-minute race with 206 of them built fine. The failure therefore arrives with the LENGTH of
    # the race, which is the wrong thing for a championship to be sensitive to.
    ins, filt, labels, n = [], [], [], 0
    for start, rel in placements:
        ins += ["-i", str(rel).replace("\\", "/")]
        ms = int(round(start * 1000))
        filt.append(f"[{n}:a]aformat=channel_layouts=stereo,adelay={ms}|{ms}[a{n}]")
        labels.append(f"[a{n}]")
        n += 1

    # Mix all sources, then bring the whole track to a broadcast loudness target. amix(normalize=0)
    # preserves each clip's own level; the final loudnorm
    # then sets the integrated loudness to YouTube's ~-14 LUFS with a -1.5 dBTP ceiling, so commentary
    # isn't quiet and the beeps don't clip — consistent level across every language's clips.
    mix = "".join(labels) + f"amix=inputs={n}:normalize=0:duration=longest[mx]"
    fc = ";".join(filt) + ";" + mix + ";[mx]loudnorm=I=-14:TP=-1.5:LRA=11[m]"
    # The filter graph goes in a FILE, not on the command line: it carries a stanza per clip, so it is
    # the larger half of the length problem described above and the one that grows fastest.
    script = out_path.with_suffix(".filter.txt")
    script.write_text(fc, encoding="utf-8")
    cmd = (
        ["ffmpeg", "-y"]
        + ins
        + ["-filter_complex_script", str(script),
           "-map", "[m]", "-ar", "48000", "-c:a", "aac", "-b:a", "192k", str(out_path)]
    )
    try:
        subprocess.run(cmd, check=True, capture_output=True, text=True, cwd=str(audio_dir))
    finally:
        script.unlink(missing_ok=True)
    return placements[-1][0] if placements else 0.0
