"""
Streaming scorer  —  §6 seam #2: incremental scoring  (re-run-on-prefix)

`core.score_race.enrich(feed)` scores a whole feed in one pass (batch). Live broadcast needs the
SAME logic fed frames as they arrive, emitting per-tick standings + event deltas. Rather than fork
the scoring logic (enrich is a deliberately monolithic single-pass loop — see project notes), this
re-runs the *unchanged* `enrich` on the frames accumulated so far each tick and reports what's new.
There is therefore exactly ONE scoring implementation; this is a thin driver around it.

Two facts about `enrich` (verified by the B-A parity test on the real race) shape the design:

  * **Standings are frontier-stable** — `standings[sec]` depends only on frames up to `sec`, so every
    tick we emit the live frontier standings (positions for the 3D render) and they already match batch.
  * **Commentary events settle with a short lag** — `_split_long_lines` and the margin/cadence
    smoothing revise an event for up to ~9 s after its `tSec` (they look a few beats ahead). So we only
    *finalise* (emit) an event once the frontier has passed `tSec + FINALISE_LAG_S`, by which point its
    text/focus equals the batch output. `finish()` flushes the tail (the last lag-seconds of events).

The lag is invisible live: the broadcast already runs seconds behind wall-clock (OBS→RTMP→YouTube
latency + the director's deliberate delay), which absorbs it — positions stay live, commentary trails
by the lag. Cost: re-scoring the growing prefix is O(n^2), but a full enrich is milliseconds and live
advances one tick per real second, so it's comfortably real-time for race-length feeds.

Acceptance bar: test_streaming_parity — feeding a recorded race one tick at a time (then `finish()`)
must accumulate to exactly the events + standings the batch `enrich` produced.

Sub-phase: B-A (live scoring). Next: LiveFeedServer (B-B) fans `push_frame`'s delta to renderers,
and the bounded-LLM editor (live `ai_director`) edits each tick's newly-finalised colour beats.
"""
from __future__ import annotations

import json
import sys
from collections.abc import Callable
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scoring"))
from score_race import enrich  # noqa: E402

#: seconds the frontier must advance past an event's tSec before it's stable enough to air (> the ~9 s
#: revision window of _split_long_lines + margin smoothing, with margin). Absorbed by broadcast latency.
FINALISE_LAG_S = 12


def _event_key(e: dict) -> str:
    """Stable identity for de-duping a (now-settled) event. Verbatim — settled events are unique."""
    return json.dumps(e, sort_keys=True, ensure_ascii=False)


class StreamingScorer:
    """Ingest frames incrementally; emit {standings, events} deltas per tick by re-running `enrich` on
    the accumulated prefix. Standings are the live frontier; events trail by `lag` so they're final
    (== batch) when aired. Call `finish()` at race end to flush the last `lag` seconds of commentary.

    Reuses `enrich` verbatim — no forked scoring logic.
    """

    def __init__(self, meta: dict, course: dict, teams: list, lag: int = FINALISE_LAG_S,
                 capture_ai: bool = False, producer: Callable[[dict], dict] | None = None,
                 race_signals: list | None = None):
        self.meta, self.course, self.teams = meta, course, teams
        self.lag = lag
        # RRS committee signals (recall/restart/postpone/abandon/shorten) for this instance, carried
        # into the per-tick feed so the RIE producer can narrate them (§4a). The live driver keeps this
        # list in sync with the store (`race_signals_for`); `enrich` ignores it, so this is inert unless
        # a RIE producer is wired. A live driver should include only signals at/behind the frontier.
        self.race_signals = race_signals if race_signals is not None else []
        self.capture_ai = capture_ai            # also surface finalised aiBeats (for the live AI editor)
        # The feed->scored producer. Defaults to the unchanged `enrich`; the RIE live producer
        # (Phase 4) drops in here with the same signature. One scoring implementation either way.
        self._producer = producer or enrich
        self._frames: dict[str, dict] = {}
        self._events: list = []                 # latest full enrich events (for finish())
        self._beats: list = []                  # latest full enrich aiBeats (when capture_ai)
        self._onboard: list = []                # latest RIE onboard inserts (empty unless a RIE producer)
        self._emitted: set[str] = set()
        self._emitted_beats: set[str] = set()
        self._emitted_onboard: set[str] = set()
        self._result: dict | None = None
        self._standings: dict = {}
        #: mid-race join (`seed_frames`): nothing at/behind this second is ever aired. A permanent
        #: floor, not a one-shot sweep — an event inside the ~9 s revision window can be REWRITTEN a
        #: few ticks after the join, and the rewrite would otherwise air as if it were new.
        self._air_floor: int | None = None

    def ai_context(self) -> dict:
        """The enriched context the L4 live author needs beyond meta/course/teams — the full frontier
        standings, the result, and all events so far (for the per-beat fact sheet + run-up). Read-only."""
        return {"standings": self._standings, "result": self._result, "events": self._events}

    def _emit_through(self, items: list, emitted: set, horizon: int | None) -> list:
        """Return not-yet-emitted items with tSec <= horizon (or all, if horizon is None), never any
        at/behind the mid-race-join air floor."""
        out = []
        for e in items:
            if horizon is not None and e["tSec"] > horizon:
                continue
            if self._air_floor is not None and e["tSec"] <= self._air_floor:
                continue
            k = _event_key(e)
            if k not in emitted:
                emitted.add(k)
                out.append(e)
        return out

    def seed_frames(self, frames: dict) -> None:
        """Pre-load already-elapsed frames (`{"<sec>": {boatRef: fix}}`) WITHOUT scoring them — a
        **mid-race join**: the broadcast switches to a race that has been sailing while another was
        on air (v3/sim/bridge_3d.py `OnAirQueue`). `enrich` is a single-pass whole-feed scorer, so
        the only way to give it that history is the raw frames: the next `push_frame` then scores the
        whole prefix in ONE pass and the standings, leg/mark state and finish detection are exactly
        what following the race from the gun would have produced.

        Scored, **not aired**: the seeded seconds are time that has already gone, so nothing from
        them is emitted — the join must not dump a race's worth of settled commentary into the
        broadcast at once. Only what happens from the join onwards airs (`finish()` too)."""
        for sec, positions in frames.items():
            self._frames[str(sec)] = positions
        if frames:
            latest = max(int(s) for s in frames)
            self._air_floor = latest if self._air_floor is None else max(self._air_floor, latest)

    def push_frame(self, sec: int, positions: dict) -> dict:
        """Advance one tick with `{deviceId: {lat,lon,speed,course}}`; return this tick's delta:
        `{sec, standings, events}` (plus `aiBeats` when capture_ai) where standings is the live frontier
        and events are the lines that became final this tick (tSec <= sec - lag)."""
        self._frames[str(sec)] = positions
        feed = {"meta": self.meta, "course": self.course, "teams": self.teams, "frames": self._frames}
        if self.race_signals:
            feed["raceSignals"] = self.race_signals
        if self.capture_ai:
            feed["captureAiBeats"] = True       # side-effect-free: events/standings are unchanged
        scored = self._producer(feed)
        self._events = scored["events"]
        self._beats = scored.get("aiBeats", [])
        self._result = scored.get("result")
        self._standings = scored["standings"]         # full frontier standings (for the L4 author fact sheet)
        self._onboard = scored.get("rieOnboardDirectives", [])
        horizon = sec - self.lag
        delta = {"sec": sec, "standings": scored["standings"].get(str(sec)),
                 "events": self._emit_through(self._events, self._emitted, horizon)}
        if self.capture_ai:
            delta["aiBeats"] = self._emit_through(self._beats, self._emitted_beats, horizon)
        # RIE onboard-manoeuvre inserts (v3/rie/onboard_director.py) ride the delta as a visual sidecar:
        # emit each once, at/behind the same finalisation horizon as commentary so the insert window is
        # settled when it airs. Inert when the producer is `enrich` (no such key). Never gates audio.
        onboard = self._emit_through(self._onboard, self._emitted_onboard, horizon)
        if onboard:
            delta["rieOnboardDirectives"] = onboard
        return delta

    def finish(self) -> dict:
        """Flush the tail: emit every remaining (final-prefix) event not yet aired (plus aiBeats when
        capture_ai). Call once after the last frame. Returns `{events, result}`."""
        out = {"events": self._emit_through(self._events, self._emitted, None), "result": self._result}
        if self.capture_ai:
            out["aiBeats"] = self._emit_through(self._beats, self._emitted_beats, None)
        return out

    def result(self) -> dict | None:
        """The final `result` payload (winner/…) once the race has finished, else None."""
        return self._result
