"""
Live AI editor  —  the bounded-LLM commentary/camera editor on the LIVE path (B-C).

The offline editor (v3/scoring/ai_director.py) edits a whole race at export with full hindsight. Live
has none: a line must be committed when it airs and can't be un-said. So this edits each tick's
*newly-finalised* colour beats (from StreamingScorer with capture_ai) the moment they settle, within a
hard latency budget — if the model doesn't answer in time (or there's no key/SDK, or it errors), the
deterministic engine choice airs unchanged. The finalisation lag (StreamingScorer.FINALISE_LAG_S) plus
broadcast latency (OBS→RTMP→YouTube) hide the per-beat decision time, so it's still live to viewers.

It reuses the offline machinery verbatim — `AIDirector` (the LLM call + per-race static context cache),
`ai_digest`, and `ai_schema`'s `validate_choices`/`merge_choices`/`fallback_choices` — so a beat can
only ever be kept/dropped (pacing) or re-framed onto a shot the engine already proposed; it can never
invent a fact, word, name, or camera move. Defaults to **Haiku** (claude-haiku-4-5): the per-beat
choice is trivial and live latency/cost matter, unlike the offline bake (Opus, once per race).

Bounds preserved: same select→validate→merge path as offline; with no ANTHROPIC_API_KEY/SDK every edit
is the identity (engine output), so this is always safe to leave enabled.
"""
from __future__ import annotations

import sys
import threading
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scoring"))
from ai_author_schema import apply_authored  # noqa: E402
from ai_director import AIDirector  # noqa: E402
from ai_schema import fallback_choices, merge_choices  # noqa: E402

MODEL = "claude-haiku-4-5"
TIMEOUT_S = 2.0


class LiveAIEditor:
    """Edits one tick's newly-finalised colour beats before they air. `edit(events, beats)` returns the
    events with dropped beats removed and kept beats re-framed; never raises, never blocks past
    `timeout_s` (the deterministic baseline airs if the model is slow/absent).

    The L2 EDITOR (keep/drop + subject + Part D shot type) is always on (bounded, invents nothing). The
    L4 AUTHOR (rewriting the colour TEXT) is OPT-IN via `author=` and OFF by default: airing AI-written
    text is reviewer-gated (see the offline --author bake + score_author), so it only runs live once a
    caller explicitly enables it. When on, it also runs under the latency budget with a deterministic
    fall-back to the engine line, and only validator-passing lines air."""

    def __init__(self, meta: dict, course: dict, teams: list, *, model: str = MODEL,
                 timeout_s: float = TIMEOUT_S, director: AIDirector | None = None,
                 author=None, verbose: bool = False):
        self.ctx = {"meta": meta, "course": course, "teams": teams}
        self.timeout_s = timeout_s
        # Bound the API call to the same budget as the thread guard: with_options(timeout, no retries)
        # inside AIDirector stops a slow/absent response from leaving a retrying request in flight tick
        # after tick, while the thread+join below still caps the total (call + validate + merge).
        self.director = director or AIDirector(model=model, live_timeout_s=timeout_s)
        self.author = author              # optional L4 author (opt-in); None = selector-only (default)
        self.verbose = verbose            # print each drop/re-frame decision (serve_live --ai console)

    def edit(self, events: list, beats: list, context: dict | None = None) -> list:
        """Return `events` with this tick's `beats` edited. No beats → events unchanged. `context` (the
        scorer's standings/result/events) is only needed when the opt-in author is enabled."""
        if not beats:
            return events
        if self.author is not None:
            events = self._author_within_budget(events, beats, context or {})
        choices = self._select_within_budget(beats)
        if self.verbose:
            self._log(beats, choices)
        return merge_choices(events, beats, choices)

    def _author_within_budget(self, events: list, beats: list, context: dict) -> list:
        """Rewrite this tick's colour lines under the latency budget; on timeout/absence the engine text
        airs. The author only emits validator-passing lines, so a slow/failed call costs nothing but the
        engine baseline. Needs the scorer's standings/result/events (context) for the per-beat fact sheet."""
        result: dict = {}

        def work():
            result["authored"] = self.author.write({**self.ctx, **context, "aiBeats": beats})

        th = threading.Thread(target=work, daemon=True)
        th.start()
        th.join(self.timeout_s)
        authored = result.get("authored")
        return apply_authored(events, beats, authored) if authored else events

    @staticmethod
    def _log(beats: list, choices: list):
        by_t = {b["tSec"]: b for b in beats}
        for c in choices:
            line = (by_t.get(c["tSec"], {}).get("text", "") or "")[:72]
            if not c["keep"]:
                print(f"  ai: DROP    @{c['tSec']:>3}  {line}")
            elif c["shot"] != 0:
                print(f"  ai: RESHOOT @{c['tSec']:>3} (shot {c['shot']})  {line}")

    def _select_within_budget(self, beats: list) -> list:
        """Run the bounded selector under the latency budget; fall back to the engine choice on timeout.
        AIDirector.select already falls back (and never raises) with no key/SDK or on error — the timeout
        guards only the case where a real call is too slow to air on time."""
        result: dict = {}

        def work():
            result["choices"] = self.director.select({**self.ctx, "aiBeats": beats})

        th = threading.Thread(target=work, daemon=True)
        th.start()
        th.join(self.timeout_s)
        return result.get("choices") or fallback_choices(beats)
