"""
Configure OBS for the running 3D demo — one command, discovers the race from the live feed.

Connects to OBS over obs-websocket (localhost:4455 by default; enable it once in OBS under
Tools → WebSocket Server Settings), reads ONE `init` off the demo's live SSE feed to learn the
followed race's boats + nation labels, then builds the production layout via the product's own
`OBSController` (`v3/broadcast/live/director_obs.py`):

  * scene "Broadcast 3D"  — Browser source on the full 3D broadcast page (set as program)
  * scene "Boat Cams"     — auto-laid-out grid of every boat's camera
  * scene "Boat <label>"  — one full-screen scene per boat (boatcam.html?feed=live&boat=…)

Idempotent — rerun after a bridge restart (new nations) and it reconciles the diff. Streaming and
recording are NOT started; cut/stream from OBS as usual.

    .venv-demo/Scripts/python v3/broadcast/live/obs_boatcams.py              # demo on :8766, OBS on :4455
"""
from __future__ import annotations

import argparse
import json
import sys
import urllib.request
from pathlib import Path

_HERE = Path(__file__).resolve().parent                # .../v3/broadcast/live (director_obs lives here)
sys.path.insert(0, str(_HERE))


def _boats_from_init(init: dict) -> dict[str, str]:
    """{boatRef: "<nation> <sail>"} for the FOLLOWED race (spectator fleets are skipped — their tiles
    aren't part of the followed production)."""
    boats: dict[str, str] = {}
    for team in init.get("teams", []):
        for b in team.get("boats", []):
            if b.get("spectator"):
                continue
            boats[b["deviceId"]] = f"{team.get('name', '?')} {b.get('sailId', '')}".strip()
    return boats


def sending_cameras(positions, cams) -> set[str]:
    """Which boats have a CAMERA on air right now — the panel's admission rule.

    A nav fix in a delta proves the BOAT is alive, not that its camera is running: a phone with the
    camera off still reports position, and a tile opened for it is a black rectangle. So a boat must
    both be sending (`positions`) and be one the feed header has carried footage for (`cams`).

    When `cams` is empty the producer simply isn't carrying footage at all — camera liveness is then
    unknowable, and nav alone decides, because an empty panel is the worse answer.
    """
    sending = set(positions or ())
    return (sending & cams) if cams else sending


def iter_live_inits(base: str, *, timeout: float = 15):
    """Yield every `init` payload off the live SSE feed as it arrives (the header re-publishes when
    media lands or the roster changes)."""
    with urllib.request.urlopen(f"{base}/events", timeout=timeout) as r:
        want = False
        for raw in r:
            line = raw.decode("utf-8", "replace").rstrip("\n")
            if line == "event: init":
                want = True
            elif want and line.startswith("data: "):
                want = False
                yield json.loads(line[len("data: "):])


def iter_live_frames(base: str, *, timeout: float | None = 15):
    """Yield `(event, payload)` for EVERY frame off the live SSE feed — inits *and* deltas.

    `iter_live_inits` sees only the header, which carries the ALLOCATION. Whether a boat is actually
    SENDING is in the deltas: each one carries the positions that arrived for that second. A panel
    built from the header alone shows a tile for every allocated boat, transmitting or not — which is
    a black rectangle for a boat whose phone is flat."""
    with urllib.request.urlopen(f"{base}/events", timeout=timeout) as r:
        event = None
        for raw in r:
            line = raw.decode("utf-8", "replace").rstrip()
            if line.startswith("event: "):
                event = line[len("event: "):].strip()
            elif event and line.startswith("data: "):
                kind, event = event, None
                yield kind, json.loads(line[len("data: "):])


def fetch_live_boats(base: str) -> dict[str, str]:
    """Read ONE `init` → the followed race's boats (the single-shot mode)."""
    for init in iter_live_inits(base):
        return _boats_from_init(init)
    return {}


def _instance_of(init: dict) -> str:
    """The race the feed is showing. Taken FROM THE FEED so nobody has to type an instance id that
    the broadcast already knows — and so `--watch` follows it when the producer moves to the next
    heat."""
    return str((init.get("meta") or {}).get("instanceId") or "")


def boat_query(instance: str, read_base: str, lag: float | None) -> str:
    """The query that puts a boat-cam source on the LIVE HLS STREAM instead of the delayed-clip
    surface: `hls=1&instance=…[&readBase=…][&lag=<ms>]`.

    **`lag` is MILLISECONDS**, the one unit the whole presentation clock uses (`config.js liveLagMs`,
    the Director's `LIVE_LAG_MS`, both `--lag` flags and the page's `?lag=`). The page briefly read
    this parameter as seconds, which turned the shipped 22000 into a request for a picture six hours
    old — a black grid with nothing in any log. One unit, named at every crossing.

    **`readBase` is what makes one layout work in both deployments.** The scene may render on the
    on-site machine while the media is served from gcloud, or both may be the same origin — the page
    fetches its playlist from `readBase` and the OBS URL is the only thing that differs. The SYNC does
    not depend on either: the player locks to `now − lag` using each segment's `PROGRAM-DATE-TIME`,
    which is UTC, so it agrees with the 3D scene wherever each of them happens to run.

    Empty when there is no instance to point at — then the source keeps the clip surface rather than
    loading a playlist that cannot exist."""
    if not instance:
        return ""
    q = f"hls=1&instance={instance}"
    if read_base:
        q += f"&readBase={read_base.rstrip('/')}"
    if lag is not None:
        q += f"&lag={lag:g}"
    return q


def main(argv=None) -> int:                            # noqa: ANN001
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
    ap.add_argument("--base", default=None,
                    help="origin serving the scene and the feed. Unset falls back to "
                         "$VR_PRODUCER_BASE and then to the local dev default (:8766); with "
                         "VR_RACE_DAY=1 a loopback base is refused rather than silently building a "
                         "layout for the wrong race")
    ap.add_argument("--host", default="localhost")
    ap.add_argument("--port", type=int, default=4455)
    ap.add_argument("--password", default="", help="obs-websocket password (empty = auth off)")
    ap.add_argument("--no-grid", action="store_true", help="skip the Boat Cams grid scene")
    ap.add_argument("--read-base", default="",
                    help="origin serving the boat-cam HLS playlists (e.g. https://apps.viewregatta.com). "
                         "Omit when the scene and the media share an origin. This is the ONLY difference "
                         "between running the render on the on-site machine and in gcloud")
    ap.add_argument("--lag", type=float, default=None, metavar="MS",
                    help="presentation lag in MILLISECONDS (the unit `config.js liveLagMs` and the "
                         "page's `?lag=` use); default is the scene's own. Pass it ONLY together with "
                         "the same value on the scene URL — the whole point is that the camera and the "
                         "3D boat present the same UTC instant")
    ap.add_argument("--clips", action="store_true",
                    help="use the delayed-clip surface instead of the live HLS stream (the old "
                         "behaviour; useful when a boat uploads stills rather than video)")
    ap.add_argument("--grace", type=float, default=45.0,
                    help="seconds of silence before a boat's camera leaves the panel (--watch). A "
                         "cellular upload gap is normal, so this is a settling window, not a timeout")
    ap.add_argument("--watch", action="store_true",
                    help="stay on the feed and re-reconcile on every init — scenes FOLLOW the roster "
                         "(a boat reassigned to a new team gets its scene renamed; new/gone boats "
                         "added/removed)")
    a = ap.parse_args(argv)

    import logging
    logging.getLogger("obsws_python").setLevel(logging.CRITICAL)   # its ERROR logs re-print handled fallbacks

    from producer_base import RaceDayBaseError, resolve
    try:
        a.base, _src = resolve(a.base, default="http://localhost:8766")
    except RaceDayBaseError as e:
        print(f"[obs] {e}", flush=True)
        return 2
    print(f"[obs] producer base: {a.base} (from the {_src})", flush=True)

    first = next(iter(iter_live_inits(a.base)), None)
    boats = _boats_from_init(first) if first else {}
    if not boats:
        print("[obs] no boats on the live feed — is the bridge running?")
        return 1
    instance = "" if a.clips else _instance_of(first)
    query = boat_query(instance, a.read_base, a.lag)
    print(f"[obs] followed race: { {k.rsplit(':', 1)[-1]: v for k, v in boats.items()} }")
    if query:
        print(f"[obs] boat cams: LIVE HLS from {a.read_base or 'the scene origin'} "
              f"(instance {instance}), locked to the scene's presentation clock")
    else:
        print("[obs] boat cams: delayed-clip surface"
              + ("" if a.clips else " — the feed named no instance, so there is no playlist to play"))

    from director_obs import OBSController
    obs = OBSController(host=a.host, port=a.port, password=a.password).connect()
    web = f"{a.base}/core/web"
    obs.ensure_scene("Broadcast 3D")
    if "Broadcast" not in obs._input_names():
        obs.create_browser_input("Broadcast 3D", "Broadcast",
                                 f"{web}/index.html?feed=live&establish=40,11,65&audio=off")
    # drop boatcam inputs left over from earlier runs/races (they'd sit dark in the grid), then
    # reconcile the current fleet in
    obs.remove_stale_boat_inputs(boats.keys(), verbose=True)
    composite = "" if a.no_grid else "Broadcast"       # the programme, tiled beside its own cameras
    changes = obs.reconcile_boat_sources(boats, f"{web}/boatcam.html", grid=not a.no_grid,
                                         query=query, composite=composite)
    if not a.no_grid:
        try:
            obs._relayout_grid("Boat Cams", (1920, 1080), composite=composite)   # after stale removals
        except Exception:  # noqa: BLE001 — tiling is cosmetic
            pass
    for name in ["Broadcast"] + [f"boatcam:{d}" for d in boats]:
        try:                                           # a source that loaded before its page server
            obs.refresh_browser_input(name)            # stays blank until refreshed
        except Exception:  # noqa: BLE001 — best-effort; the source may not exist under --no-grid
            pass
    obs.set_scene("Broadcast 3D")
    print(f"[obs] reconciled: +{len(changes['added'])} -{len(changes['removed'])}; "
          f"program = {obs.current_program_scene()!r}")

    if a.watch:
        # Keep the panel true to what is on air: the deltas say who is SENDING, the re-published
        # headers say who they are (a boat reassigned to a new team relabels its scene). Ctrl+C stops.
        print(f"[obs] watching the feed — the panel holds the boats that are SENDING "
              f"(dropped after {a.grace:.0f}s of silence) plus the programme (Ctrl+C to stop)")
        from obs_boat_sources import BoatSourceSupervisor

        labels: dict[str, str] = dict(boats)
        cams: set[str] = set()          # boats the header has ever carried footage for
        sup = BoatSourceSupervisor(
            obs, f"{web}/boatcam.html", grace_s=a.grace, grid=not a.no_grid,
            label_of=lambda did: labels.get(did, did), query=query, composite=composite)
        try:
            for kind, payload in iter_live_frames(a.base, timeout=None):
                if kind == "init":
                    # The header carries the ROSTER (labels) and which race is on air — the producer
                    # moves to the next heat on its own, so both are re-read rather than pinned.
                    fresh = _boats_from_init(payload)
                    if fresh:
                        labels = fresh
                    # The header's media map is the camera evidence `sending_cameras` gates on.
                    cams |= {d for d, clips in (payload.get("media") or {}).items() if clips}
                    sup.query = "" if a.clips else boat_query(_instance_of(payload), a.read_base, a.lag)
                    continue
                if kind != "delta":
                    continue
                # A boat is in the panel because it is SENDING, not because it is allocated. The
                # positions in this delta are that evidence; the supervisor's grace window keeps a
                # normal upload gap from churning sources.
                sec = float(payload.get("sec") or 0)
                sup.observe(sec, sending_cameras(payload.get("positions"), cams))
                ch = sup.tick(sec)
                if any(ch.values()):
                    print(f"[obs] roster change: +{ch['added']} -{ch['removed']} "
                          f"relabelled {ch['relabelled']}")
        except KeyboardInterrupt:
            pass
    obs.disconnect()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
