"""Record a set of exported races into the replay library, one at a time.

The batch step of `docs/replay-production.md`. Each race needs the same three things — a feed, a voice
for every commentary line, and a render — and doing that by hand for a regatta means watching a
terminal for hours and noticing which of the three failed. This does them in order, keeps going when
one race fails, and says which step it was.

    python v3/broadcast/demo/record/render_races.py --all-prepared
    python v3/broadcast/demo/record/render_races.py --race racemgmt_1009 racemgmt_1010

ONE AT A TIME on purpose. A render is a headless GPU capture — measured 6-8 frames/s at 1080p30 on
the 2026 rig, so 1.5-2 h for a race — and two at once simply halve each other. For the same reason,
do not run this while the simulator is producing races: CPU contention has already cost this rig a
run.

Resumable in both directions. `record_demo` checkpoints its frames, so an interrupted render picks up
where it stopped; and a race whose film is already in the library is skipped here, so re-running the
command after a failure costs only the races that still need doing.
"""
from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
from pathlib import Path

_HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(_HERE))
import replay_library  # noqa: E402

RACES_DIR = _HERE.parent / "data" / "races"
BROADCAST = _HERE.parents[1]


def log(msg: str) -> None:
    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)


#: Lines that say nothing about a failure. The recorder serves the page it is capturing over HTTP and
#: logs every asset request, so a raw tail of its output is a hundred lines of `GET /core/web/...` and
#: none of the error — which is the one thing the operator needs. Measured: a failing render reported
#: three requests for sail textures and no reason at all.
_NOISE = ("GET /", "POST /", "127.0.0.1 - -", "[2/3]", "[1/3]")


def _useful(output: str, limit: int = 12) -> str:
    """The lines of a failed step that carry the failure, newest last."""
    lines = [ln for ln in output.splitlines()
             if ln.strip() and not any(n in ln for n in _NOISE)]
    return "\n".join(f"    {ln}" for ln in lines[-limit:])


def _run(step: str, argv: list[str]) -> bool:
    """One step. A failure is reported and never raised — one bad race must not cost the rest."""
    log(f"  {step} ...")
    p = subprocess.run(argv, cwd=str(BROADCAST.parents[1]), capture_output=True,  # noqa: S603
                       text=True, check=False)
    if p.returncode != 0:
        log(f"  {step} FAILED (rc={p.returncode})\n{_useful((p.stdout or '') + (p.stderr or ''))}")
        return False
    log(f"  {step} ok")
    return True


def already_filed(library: Path, race_id: str) -> bool:
    """Is this race's film already in the library?

    Matched by the `raceId` in a SIDECAR, not by filename: the film is named for the race day and the
    matchup, which cannot be reproduced here without re-reading the feed, and a near-miss guess would
    re-render an hour of GPU for nothing.

    Beware a synced drive. Measured 2026-08-09 on Google Drive: a sidecar written seconds earlier was
    not yet visible to this scan, and the race was recorded a second time. Nothing here can fix that —
    the file genuinely is not there yet — so leave a moment between filing a race and asking whether
    it is filed."""
    if not library.exists():
        return False
    for side in library.rglob("*.json"):
        try:
            if json.loads(side.read_text(encoding="utf-8")).get("raceId") == race_id:
                return True
        except Exception:                   # noqa: BLE001, S112 — an unreadable sidecar is no match
            continue
    return False


def _has_voice(feed_path: Path, lang: str) -> bool:
    """Whether every commentary line in this feed already has an audio clip.

    Asked BEFORE rendering because the recorder's own preflight asks it too, and answers by refusing
    to start — a fresh race has no clip for any of its lines, so a first render reports a hundred-odd
    blockers and does nothing. Synthesising first turns that into an ordinary step."""
    index = _HERE.parent / "data" / "audio" / "index.json"
    if not index.exists():
        return False
    try:
        have = set(json.loads(index.read_text(encoding="utf-8")).get(lang) or {})
        feed = json.loads(feed_path.read_text(encoding="utf-8"))
    except Exception:                       # noqa: BLE001 — unreadable → synthesise, it is idempotent
        return False
    wanted = {(e.get("i18n") or {}).get(lang) for e in (feed.get("events") or [])}
    return not {w for w in wanted if w} - have


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--race", nargs="+", help="manifest race ids to record")
    ap.add_argument("--all-prepared", action="store_true",
                    help="record every exported race in demo/data/races/ that is not filed yet")
    ap.add_argument("--lang", default="en")
    ap.add_argument("--res", default="1920x1080")
    ap.add_argument("--fps", type=int, default=30)
    ap.add_argument("--crf", type=int, default=16)
    ap.add_argument("--replay-dir", help="library root (default $VR_REPLAY_DIR, else _cap/replays)")
    ap.add_argument("--skip-voice", action="store_true",
                    help="do not synthesise; fail the race if its commentary has no clips")
    args = ap.parse_args()

    if args.all_prepared:
        ids = sorted(p.stem for p in RACES_DIR.glob("*.json")
                     if p.name not in ("manifest.json",))
    elif args.race:
        ids = list(args.race)
    else:
        raise SystemExit("give --race <id> ... or --all-prepared")

    library = replay_library.library_dir(BROADCAST, args.replay_dir)
    log(f"library: {library}")
    log(f"{len(ids)} race(s): {', '.join(ids)}")

    done, failed = [], []
    for race_id in ids:
        feed_path = RACES_DIR / f"{race_id}.json"
        if not feed_path.exists():
            log(f"{race_id}: no exported feed — export it first")
            failed.append(race_id)
            continue
        if already_filed(library, race_id):
            log(f"{race_id}: already in the library — skipping")
            continue
        log(f"{race_id}: {args.res} / {args.fps} fps / crf {args.crf}")
        if not _has_voice(feed_path, args.lang):
            if args.skip_voice:
                log(f"{race_id}: commentary has no clips and --skip-voice was given — skipping")
                failed.append(race_id)
                continue
            if not _run("voice", [sys.executable, "-u",
                                  str(BROADCAST / "live" / "tts" / "synth.py"),
                                  str(feed_path), "--lang", args.lang]):
                failed.append(race_id)
                continue
        if _run("record", [sys.executable, "-u", str(_HERE / "record_demo.py"),
                           "--race", race_id, "--lang", args.lang, "--res", args.res,
                           "--fps", str(args.fps), "--crf", str(args.crf),
                           *(["--replay-dir", args.replay_dir] if args.replay_dir else [])]):
            done.append(race_id)
        else:
            failed.append(race_id)

    log(f"recorded {len(done)}, failed {len(failed)}"
        + (f" ({', '.join(failed)})" if failed else ""))
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())
