"""Produce a broadcast demo MP4 (video + commentary + race-clock beeps) for one language.

    python demo/record/record_demo.py --lang en
    python demo/record/record_demo.py --lang zh
    python demo/record/record_demo.py --lang en --use-webm _cap/full_en.webm   # skip capture, re-mux

Pipeline:
  1. build the audio track  — commentary clips played one-at-a-time + race-clock beeps (build_audio.py)
  2. capture the live page  — headless on the GPU (capture.mjs); retries if Chrome crashes under load
  3. resync + mux           — rescale the screencast to real time and mux the audio -> demo_<lang>.mp4

The page's screencast muxes frames at a fixed fps, so its timeline is stretched; step 3 rescales it
back to the real-time duration of the race (≈±2 s of wobble from variable capture fps). Audio sits on
the real race-time line, so the gun and winner land on their on-screen moments.

Requirements: ffmpeg on PATH; Node with `npm install` run in this folder (puppeteer-core); Google
Chrome (auto-detected, or set BROADCAST_CHROME). Languages: any with synthesized clips + a UI block
(en, sv, zh today) — see live/tts/synth.py and core/web/app.js.
"""

import argparse
import functools
import hashlib
import http.server
import json
import os
import re
import shutil
import socketserver
import subprocess
import sys
import threading
import time
from pathlib import Path

HERE = Path(__file__).resolve().parent
ROOT = HERE.parent.parent  # v3/broadcast
sys.path.insert(0, str(HERE))
import replay_library  # noqa: E402
from build_audio import build_audio  # noqa: E402


def find_chrome():
    if os.environ.get("BROADCAST_CHROME"):
        return os.environ["BROADCAST_CHROME"]
    for c in (
        r"C:\Program Files\Google\Chrome\Application\chrome.exe",
        r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/usr/bin/google-chrome",
        "/usr/bin/chromium-browser",
        "/usr/bin/chromium",
    ):
        if Path(c).exists():
            return c
    raise SystemExit("Chrome not found — set BROADCAST_CHROME to the chrome executable")


def serve(port):
    handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(ROOT))
    httpd = socketserver.ThreadingTCPServer(("127.0.0.1", port), handler)
    httpd.daemon_threads = True
    threading.Thread(target=httpd.serve_forever, daemon=True).start()
    return httpd


def media_duration(path):
    """Decode-accurate duration in seconds (webm 'format=duration' is often N/A)."""
    r = subprocess.run(["ffmpeg", "-i", str(path), "-f", "null", "-"], capture_output=True, text=True)
    times = re.findall(r"time=(\d+):(\d+):([\d.]+)", r.stderr)
    if not times:
        raise SystemExit(f"could not read duration of {path}")
    h, m, s = times[-1]
    return int(h) * 3600 + int(m) * 60 + float(s)


def missing_clips(lang, root, feed):
    """Lines of `feed` in `lang` that have no matching TTS clip in the audio index (in time order)."""
    root = Path(root)
    try:
        with open(root / "demo" / "data" / "audio" / "index.json", encoding="utf-8") as f:
            idx = json.load(f).get(lang, {})
    except FileNotFoundError:
        idx = {}
    out = []
    for e in sorted(feed["events"], key=lambda e: e["tSec"]):
        text = (e.get("i18n") or {}).get(lang)
        if text and text not in idx:
            out.append((e["tSec"], text))
    return out


def ensure_clips(lang, root, feed):
    """Guarantee every commentary line of `feed` in `lang` has a clip before recording.

    The recorder builds its audio purely from pre-rendered clips matched by exact line text, so a feed
    edited since the clips were synthesized silently loses those lines. That used to truncate the FILM
    as well — the mux trimmed the video to the audio (`-shortest`), so a stale clip set cut the finish
    off. The mux no longer does that (the frames decide the length), which makes this check the only
    thing standing between an edited feed and a race that is watched in silence. So: if any line is
    unmatched, auto-run synth_races.py once for this language (idempotent; covers the manifest's races
    + real.json) and re-check; hard-fail if still short rather than emit a quietly broken video."""
    miss = missing_clips(lang, root, feed)
    if not miss:
        return
    synth = Path(root) / "demo" / "export" / "synth_races.py"
    print(f"      {len(miss)} commentary line(s) in '{lang}' have no clip — running {synth.name} to synthesize ...")
    if subprocess.run([sys.executable, str(synth), "--lang", lang]).returncode != 0:
        raise SystemExit(f"clip synthesis failed — run `{sys.executable} {synth} --lang {lang}` manually, then retry")
    miss = missing_clips(lang, root, feed)
    if miss:
        sample = "\n".join(f"        {t:>4.0f}s  {txt[:70]}" for t, txt in miss[:8])
        raise SystemExit(
            f"still {len(miss)} line(s) in '{lang}' with no clip after synthesis — refusing to record a "
            f"truncated video. First few:\n{sample}\n"
            f"      (check the '{lang}' voice mapping in live/tts/synth.py LANG_CODE)"
        )


def load_feed(root, race):
    """The feed to render: a manifest race by id (`race`, e.g. 'gsys01') or standalone real.json."""
    root = Path(root)
    if race:
        manifest = json.loads((root / "demo" / "data" / "races" / "manifest.json").read_text(encoding="utf-8"))
        for r in manifest.get("races", []):
            if r.get("id") == race:
                if not r.get("data"):
                    raise SystemExit(f"race '{race}' has no inlined feed data in the manifest")
                return r["data"]
        raise SystemExit(f"race '{race}' not found in manifest.json")
    return json.loads((root / "demo" / "data" / "races" / "real.json").read_text(encoding="utf-8"))


def to_rie_feed(root, feed, race):                     # noqa: ARG001 — `race` kept for the CLI's error text
    """Transform an enrich-scored feed into the PRODUCTION broadcast.

    This used to call `rie_events`, which replaced the whole event list with RIE's narration — so a
    recorded "RIE" MP4 lost the opening, the course walk, the boat presentations, the teaching and
    the colour along with the tactics. It now takes the same path a live broadcast does: the engine
    and the production layer carry the show, the claim gate withholds interpretation with no evidence
    behind it, and RIE contributes the tactical layer (`meta.commentarySource = "enrich+rie"`).

    There is no decline to handle any more. RIE having nothing to say costs the tactical layer, not
    the broadcast.

    The feed on disk was scored for the enrich-only show, so it spent cadence seconds on tactical
    beats the claim gate is about to drop — dead air in the recording. Re-score it as a production
    feed first (`for_production`), the same way the live path does, and those seconds go to the
    lines that will actually be heard.
    """
    sys.path.insert(0, str(Path(root).parents[1]))  # repo root, for v3.rie
    sys.path.insert(0, str(Path(root).parent / "scoring"))   # v3/scoring, for the re-score
    from score_race import enrich

    from v3.rie.broadcast_bridge import for_production, production_feed

    return production_feed(
        enrich(for_production(feed)),
        event_config={
            "markPositionsAuthoritative": False,
            "prestartWindowSeconds": 120,
        },
    )


def synth_rie_clips(root, feed):
    """Ensure Google-TTS clips exist for the RIE feed's English lines.

    ``synth.py`` merges the selected feed's mappings into the existing language index, so pass only
    this programme. Earlier manifest/real mappings remain intact, while a role-voice change cannot
    accidentally trigger a costly re-synthesis of every race. Idempotent: clips are hashed by voice,
    rate, and text, so only new RIE lines hit the cloud.
    """
    root = Path(root)
    tmp = root / "_cap" / "rie_synth_feed.json"
    tmp.parent.mkdir(parents=True, exist_ok=True)
    tmp.write_text(json.dumps(feed, ensure_ascii=False), encoding="utf-8")
    synth = root / "live" / "tts" / "synth.py"
    if subprocess.run([sys.executable, str(synth), str(tmp), "--lang", "en"]).returncode != 0:
        raise SystemExit("RIE clip synthesis failed — check ADC (gcloud auth application-default login)")


def main():
    ap = argparse.ArgumentParser(description="Record a broadcast demo MP4 for one language.")
    ap.add_argument("--lang", default="en", help="commentary/UI language (en, sv, zh)")
    ap.add_argument(
        "--race", default=None, help="render a single manifest race by id (e.g. gsys01); default = real.json"
    )
    ap.add_argument("--speed", type=float, default=1.0, help="playback speed while capturing (output stays real-time)")
    ap.add_argument("--out", help="output mp4 path (default: the replay library for --race, "
                                  "else _cap/demo_<lang>.mp4)")
    ap.add_argument("--replay-dir", dest="replay_dir",
                    help="where finished replays collect (default $VR_REPLAY_DIR, else _cap/replays). "
                         "A deployment fact — one user's library is a shared drive, another's a NAS")
    ap.add_argument("--res", default="1920x1080", help="output resolution WxH")
    ap.add_argument("--fps", type=int, default=30, help="output frame rate")
    ap.add_argument("--crf", type=int, default=18, help="x264 quality (lower = better)")
    ap.add_argument("--port", type=int, default=8097)
    ap.add_argument("--world", choices=("photoreal", "terrain"), default="photoreal",
                    help="3D backdrop (default photoreal — this path produces DELIVERABLES). It is "
                         "stated in the URL rather than left to config.js, whose default is now "
                         "terrain: one Cesium ion session is one page load, and the demo feeds "
                         "disagree among themselves about `venue.world`, so an unstated backdrop "
                         "would differ from race to race.")
    ap.add_argument("--tail", type=float, default=24.0,
                    help="seconds past the winner (whole-manifest demo renders only — a --race render "
                         "takes its window from the feed, which already carries the exporter's tail)")
    ap.add_argument("--pre-start-s", type=float, default=None,
                    help="seconds of pre-start to open on (default: all the feed has, normally 180 = "
                         "T-3). Clamped to the feed's own first frame — there is nothing before it")
    ap.add_argument(
        "--mode",
        choices=("frames", "screencast"),
        default="frames",
        help="frames = deterministic, resumable per-frame capture (robust under GPU load); "
        "screencast = legacy single continuous capture",
    )
    ap.add_argument("--use-webm", help="skip capture; resync+mux this existing .webm instead (screencast)")
    ap.add_argument("--keep-webm", action="store_true", help="don't delete the intermediate .webm")
    ap.add_argument("--keep-frames", action="store_true", help="don't delete the intermediate PNG frames")
    ap.add_argument(
        "--rie",
        action="store_true",
        help="render the autonomous RIE-only broadcast (base + tactical, English) for --race",
    )
    args = ap.parse_args()
    if args.rie:
        if not args.race:
            raise SystemExit("--rie needs --race <id> (the heat to narrate)")
        args.lang = "en"  # the RIE-only broadcast is English-only for TRWC

    # Fail before TTS synthesis or browser/GPU work. The local recorder accepts private, Git-ignored
    # inputs already staged on this machine; the preflight still blocks genuinely missing files.
    # Cloud packaging invokes the same check with its stricter default target.
    preflight = HERE / "asset_preflight.py"
    preflight_report = ROOT / "_cap" / "local-asset-preflight.md"
    preflight_cmd = [
        sys.executable,
        str(preflight),
        "--lang",
        args.lang,
        "--target",
        "local",
        "--report",
        str(preflight_report),
        "--world",
        args.world,
    ]
    if args.race:
        preflight_cmd += ["--race", args.race]
    if subprocess.run(preflight_cmd).returncode != 0:
        raise SystemExit(f"asset preflight failed — final render not started; see {preflight_report}")

    feed = load_feed(ROOT, args.race)
    if args.rie:
        feed = to_rie_feed(ROOT, feed, args.race)
    res = feed.get("result") or {}
    meta = feed["meta"]
    gun_sec = (meta["gunUtcMs"] - meta["trackStartUtcMs"]) / 1000
    audio_start_sec = 0.0
    start_race_sec = None
    feed_end_sec = ((meta.get("trackEndUtcMs") or 0) - meta["trackStartUtcMs"]) / 1000
    if args.race:
        # THE FILM'S WINDOW IS THE FEED'S WINDOW. The exporter already decided it — `gun − 180 s` to
        # `official stop + 30 s` — and it is the only party that can: the stop is the SERVER's, written
        # by the finish detector off the finish line, and the broadcast presents that rather than
        # forming its own view of when a race began or ended.
        #
        # It used to be `-120.0`, hardcoded, with the end taken from the scorer's `maxFinishSec`. So a
        # film was neither of the two things it is asked for. Measured on `sim2001`: the feed opens at
        # T−3:00 and the film opened at T−2:00, throwing away the minute that carries the venue
        # welcome, the arena beat and six of the eight course-walk beats; and the last boat crossed at
        # film-second 1573 of a film 1559.3 s long, so the finish was not in it.
        #
        # Set BEFORE the result is consulted, because a race can be perfectly renderable with no
        # scored finish: where the show starts is not something the finish determines. This used to sit
        # inside the `maxFinishSec is not None` branch, so a race the detector never closed crashed the
        # render with `unsupported format string passed to NoneType.__format__` — not hypothetical, the
        # 2026-08-02 sea trial scored no finish at all.
        want_pre = gun_sec if args.pre_start_s is None else float(args.pre_start_s)
        start_race_sec = -min(want_pre, gun_sec)     # never before the feed's own first frame
        audio_start_sec = max(0.0, gun_sec + start_race_sec)
    if args.race and feed_end_sec > audio_start_sec:
        # `--tail` is NOT added here: the exporter's own `--tail-s` is already inside this window, and
        # rendering past `trackEndUtcMs` would only hold on a scene with no more frames behind it.
        target = feed_end_sec - audio_start_sec
    elif args.race and res.get("maxFinishSec") is not None:
        target = res["maxFinishSec"] - audio_start_sec + args.tail
    elif res.get("maxFinishSec") is not None:
        # New real-track races: run the show to 2 min past the LAST boat's finish (decision 6). The feed
        # is keyed from trackStart = gun − 5 min, so this captures the full [gun−5min, lastfinish+2min].
        target = res["maxFinishSec"] + 120.0
    else:
        # No scored finish AND no usable window: run to the last thing the engine had to say, plus the
        # tail. `max` over the events, not `events[-1]` — that is list order, and the engine does not
        # promise to append in time order.
        last_said = max((e.get("tSec") or 0) for e in (feed.get("events") or [{"tSec": 0}]))
        target = (res.get("winnerSec") or last_said) + args.tail - audio_start_sec

    tag = f"{args.race}_{args.lang}" if args.race else args.lang
    work = ROOT / "_cap"
    work.mkdir(parents=True, exist_ok=True)
    # WHERE A REPLAY IS FILED is a deployment fact, never a product constant: one user's library is a
    # shared drive, the next one's a NAS. `--out` still names one file exactly; otherwise a race
    # render goes to the library under a name that sorts by race day, and a language-only demo keeps
    # its old `_cap/demo_<lang>.mp4`.
    replay_meta = None
    if args.out:
        out = Path(args.out)
    elif args.race:
        # Simulated and real are filed APART (see replay_library.SIMULATED_DIR): a rehearsal render
        # and a championship render of the same course are indistinguishable in a thumbnail, and only
        # one of them is evidence.
        lib = replay_library.replay_dir_for(
            replay_library.library_dir(ROOT, getattr(args, "replay_dir", None)), feed)
        stem = replay_library.replay_name(feed, args.race)
        out = lib / f"{stem}_{args.lang}.mp4"
    else:
        out = work / f"demo_{tag}.mp4"
    out.parent.mkdir(parents=True, exist_ok=True)
    # COMPUTED ONCE, BEFORE THE BRANCH, because there are two mux paths and `--mode frames` — the
    # DEFAULT — is the first one. Written into only the screencast path, the metadata reached no
    # ordinary render at all: the film came out correct and completely anonymous, which is exactly
    # the failure this feature exists to prevent, produced by the feature itself.
    if args.race:
        replay_meta = replay_library.replay_metadata(
            feed, race_id=args.race, res=args.res, fps=args.fps, crf=args.crf, lang=args.lang,
            # The window the FILM has, not the one the feed has. They were the same field until now
            # and they were not the same number.
            film_start_race_sec=start_race_sec, film_duration_s=target,
            engine=("rie" if args.rie else "enrich"),
            render_started_utc_ms=int(time.time() * 1000))
    webm = Path(args.use_webm) if args.use_webm else work / f"full_{tag}.webm"
    audio = work / f"audio_{tag}.m4a"
    race_qs = f"&race={args.race}" if args.race else ""  # render a single manifest race in the page

    if args.rie:
        # Point the page at the RIE feed via a one-race manifest under _cap (served from ROOT), starting
        # at the gun. world defaults to photoreal (config.js); resolution/audio are unchanged.
        rie_manifest = {
            "races": [{"id": args.race, "title": feed["meta"].get("title", args.race), "data": feed}],
            "params": {"winnerGraceS": 12, "preStartS": 0, "fastReplayX": 5},
        }
        manifest_file = work / f"rie_{tag}_manifest.json"
        manifest_file.write_text(json.dumps(rie_manifest, ensure_ascii=False), encoding="utf-8")
        race_qs = f"&manifest=/_cap/{manifest_file.name}&race={args.race}&start=gun&startRace={start_race_sec:g}&repeat=0"
        synth_rie_clips(ROOT, feed)  # synthesize the RIE English lines (merged; nothing lost)
    elif args.race:
        # `repeat=0` because a one-race capture IS the "single-race screening" the director's own
        # comment describes. Left to repeat, it runs out of programme at the end of the race, enters
        # its ARCHIVE LOOP and stamps `OFF-SEASON ·` across the event name -- which on a championship
        # replay is simply false, and made the delivered films disagree with each other about what
        # they were. `_playlist` already refuses the banner for the same reason; this is that
        # reasoning applied to the render path. It also holds the final frame instead of restarting
        # the race under the recorder.
        race_qs += f"&start=gun&startRace={start_race_sec:g}&repeat=0"

    ensure_clips(args.lang, ROOT, feed)  # fail fast / auto-synthesize if the feed has unvoiced lines
    print(f"[1/3] building audio ({tag}) ...")
    last = build_audio(args.lang, ROOT, audio, feed=feed, start_sec=audio_start_sec)
    print(f"      -> {audio}  (last line at {last:.0f}s)")

    w, h = args.res.split("x")

    if args.mode == "frames" and not args.use_webm:
        # Deterministic, RESUMABLE capture: the page is driven one frame at a time and each frame is
        # screenshotted to a PNG, with the director + camera state checkpointed to disk. A GPU crash
        # resumes from the last checkpoint instead of restarting — net progress is guaranteed. Frames
        # are already real-time paced at --fps, so there's no fixed-fps stretch to undo: just assemble.
        frames_dir = work / f"frames_{tag}"
        nframes = round(target * args.fps)
        frame_ext = os.environ.get("RECORD_FRAME_EXT", "jpg").lower()  # capture_frames.mjs default
        print(f"[2/3] capturing {nframes} frames headless (resumable, fps={args.fps}, tag={tag}) ...")
        serve(args.port)
        url = (
            f"http://127.0.0.1:{args.port}/core/web/index.html"
            f"?capture=1&capfps={args.fps}&res=1&adaptive=0&audio=off&lang={args.lang}"
            f"&world={args.world}{race_qs}"
        )
        # WHAT THESE FRAMES ARE OF, so a resume cannot silently stitch two different renders into one
        # film. The frame directory is keyed on `<race>_<lang>`, is never cleared, and the capture used
        # to resume on a bare `{frame: N}` — so when the exporter changed on 2026-08-09 the delivered
        # MP4 opened on frames from BEFORE the change (no event name, no race number, no `(Simulated)`)
        # and corrected itself mid-race. The URL cannot catch that: it was identical. Only the feed
        # differed, so the feed is what is hashed, along with everything else that decides a frame.
        fingerprint = hashlib.sha256(json.dumps(
            {"feed": feed, "url": url, "res": args.res, "fps": args.fps, "crf": args.crf,
             "lang": args.lang, "rie": bool(args.rie), "startRaceSec": start_race_sec,
             "tail": args.tail, "nframes": nframes},
            sort_keys=True, default=str).encode("utf-8")).hexdigest()[:16]
        if (
            subprocess.run(
                ["node", str(HERE / "capture_frames.mjs"), url, str(nframes), str(frames_dir), find_chrome()],
                env={**os.environ, "RECORD_INPUT_FINGERPRINT": fingerprint},
            ).returncode
            != 0
        ):
            raise SystemExit("frame capture failed — see the log above")
        print("[3/3] assembling frames + muxing audio ...")
        subprocess.run(
            [
                "ffmpeg",
                "-y",
                "-framerate",
                str(args.fps),
                "-i",
                str(frames_dir / f"%08d.{frame_ext}"),
                "-i",
                str(audio),
                "-vf",
                f"scale={w}:{h}:flags=lanczos,format=yuv420p",
                "-map",
                "0:v",
                "-map",
                "1:a",
                "-c:v",
                "libx264",
                "-crf",
                str(args.crf),
                "-preset",
                "medium",
                "-pix_fmt",
                "yuv420p",
                "-c:a",
                "copy",
                # NO `-shortest`. It cut the film at the end of the COMMENTARY, which is not the end of
                # the race: on `sim2001` the audio ran out at 1559.3 s of a 1597 s render, so 38 seconds
                # of picture went with it — including the last boat's finish at 1573 s. A film asked to
                # run "to the finish" ended before it. The image sequence is finite and its length is
                # `nframes / fps`, so without the flag the frames decide, which is what was intended.
                # Padding the audio instead would mean re-encoding a track that is already AAC.
                "-movflags",
                "+faststart",
                *(replay_library.container_tags(replay_meta) if replay_meta else []),
                str(out),
            ],
            check=True,
        )
        if not args.keep_frames:
            shutil.rmtree(frames_dir, ignore_errors=True)
        if replay_meta:
            print(f"  metadata → {replay_library.write_sidecar(out, replay_meta).name}")
        print(f"\nwrote {out}")
        return

    # --- screencast mode (legacy fallback): one continuous capture, then resync the fixed-fps stretch ---
    if args.use_webm:
        print(f"[2/3] using existing capture {webm}")
    else:
        cap_wall = round(target / args.speed, 2)
        print(f"[2/3] capturing ~{cap_wall:.0f}s headless on the GPU (speed={args.speed}, lang={args.lang}) ...")
        serve(args.port)
        url = (f"http://127.0.0.1:{args.port}/core/web/index.html?speed={args.speed}&audio=off"
               f"&lang={args.lang}&world={args.world}{race_qs}")
        if (
            subprocess.run(["node", str(HERE / "capture.mjs"), url, str(cap_wall), str(webm), find_chrome()]).returncode
            != 0
        ):
            raise SystemExit("capture failed — Chrome kept crashing under GPU load; free the GPU and retry")

    print("[3/3] resyncing to real time + muxing audio ...")
    factor = target / media_duration(webm)  # undo the screencast's fixed-fps stretch
    vf = f"setpts=PTS*{factor:.6f},scale={w}:{h}:flags=lanczos,format=yuv420p"
    subprocess.run(
        [
            "ffmpeg",
            "-y",
            "-i",
            str(webm),
            "-i",
            str(audio),
            "-filter_complex",
            f"[0:v]{vf}[v]",
            "-map",
            "[v]",
            "-map",
            "1:a",
            "-c:v",
            "libx264",
            "-crf",
            str(args.crf),
            "-preset",
            "medium",
            "-r",
            str(args.fps),
            "-pix_fmt",
            "yuv420p",
            "-c:a",
            "copy",
            "-movflags",
            "+faststart",
            *(replay_library.container_tags(replay_meta) if replay_meta else []),
            str(out),
        ],
        check=True,
    )
    if replay_meta:
        side = replay_library.write_sidecar(out, replay_meta)
        print(f"  metadata → {side.name}")

    if not args.use_webm and not args.keep_webm:
        try:
            webm.unlink()
        except OSError:
            pass
    print(f"\n✓ {out}")


if __name__ == "__main__":
    main()
