"""What the renderers say about themselves, kept for as long as they keep saying it.

Each renderer posts a small sample every few seconds (`core/web/client_beacon.js`). This holds the
newest one per renderer and forgets any that stops reporting. Deliberately small and deliberately
dumb: it stores what was said and computes nothing about the competition.

**It is a REGISTRY, not a controller.** Nothing here is ever read back by a renderer, and the server
has no way to answer a beacon with an instruction — the channel is one-way by construction, so it
cannot quietly become a route for telling the picture what to do. The broadcast presents; it never
decides, and that includes not being decided at.

Two properties worth stating because they are load-bearing:

* **A renderer is present because it is TALKING, not because it once connected.** Entries expire on
  age. A partner's box that was unplugged stops appearing, rather than being listed for ever as one
  of the three outputs that are fine.
* **Untrusted input.** The beacon is a public POST from a browser we do not control — under the
  World Sailing arrangement, from a browser on someone else's premises. Every field is clamped, the
  id is truncated, the roster is capped, and nothing posted is ever interpolated into a page without
  escaping. A monitor that can be filled with junk by anyone who can reach the producer is a monitor
  that gets ignored.
"""
from __future__ import annotations

import threading
import time
from collections.abc import Callable
from dataclasses import dataclass, field


def _local_day() -> str:
    """The venue operator's own date. Local, not UTC: the person reading the board asks "how many
    today", and their today ends at their midnight."""
    return time.strftime("%Y-%m-%d", time.localtime())

#: A renderer that has not reported within this is gone. Three beacon periods: long enough to ride
#: out one lost POST and a slow frame, short enough that an unplugged box disappears while an
#: operator is still looking at the screen that shows it.
EXPIRY_S = 16.0

#: Hard cap on tracked renderers. The real number is single digits; anything beyond this is either a
#: fault or someone poking the endpoint, and neither should be able to grow the process.
MAX_RENDERERS = 64

#: How many renderer ids the day's PAGE-LOAD count will remember. A day's real figure is dozens; this
#: is the same untrusted-input ceiling as `MAX_RENDERERS`, on the one structure here that would
#: otherwise grow all day. Past it the count keeps rising but is reported as approximate, because a
#: counter that silently stops counting is worse than one that says it stopped being sure.
MAX_COUNTED_IDS = 4096

#: `audioBackend` is the renderer's own answer to "is there a voice coming out of me" — the one thing
#: no outside observer can see. OBS reports its compositor, the producer reports itself, and YouTube's
#: `healthStatus` reads *good* for a well-formed AAC track carrying pure silence; meanwhile a browser
#: source draws a flawless picture with its commentary scheduler permanently shut. That is the audio
#: twin of the 12-fps-inside-a-30-fps-composite this whole channel was built to catch, and it went out
#: on a live stream unnoticed (Ulf 2026-08-11). Values are `blocked` (no line will ever be spoken —
#: the fault), `off` (deliberately muted), `none` (allowed, nothing yet), `neural` | `browser`.
_STR_FIELDS = ("role", "lang", "world", "race", "audioBackend", "worldRequested")
_NUM_FIELDS = (
    "fps", "longFrames", "frames", "windowS", "upS", "w", "h", "feedAgeMs",
    # Race-relative renderer cursor and source frontier make accumulated presentation lag directly
    # observable without trusting clocks on separate machines.
    "cursorSec", "frontierSec", "presentationLagS",
    # Renderer-side race-bound health.  These are diagnostic scalars, not a control channel:
    # they reveal whether hitches grow during a heat and reset at the hand-over.
    "frameMaxMs", "workMaxMs", "hitch250", "hitch1000", "raceAgeS", "raceFrames",
    "raceLongFrames", "raceMaxFrameMs", "raceHitch250", "raceHitch1000",
    # Sparse scene/resource counters are sampled only at the beacon cadence (not per frame).
    "sceneGeometries", "sceneTextures", "sceneCalls", "sceneTriangles", "jsHeapMB",
    "feedHandovers", "feedLastHandoverAgeMs",
)


def _clean_str(value: object, limit: int = 40) -> str | None:
    if not isinstance(value, str):
        return None
    out = "".join(ch for ch in value if ch.isprintable())[:limit].strip()
    return out or None


def _clean_num(value: object) -> float | None:
    """A finite number, or `None`. Guards the boundary the same way the ingest does: a string, a null
    or a NaN must never reach an average or a threshold, where it would either raise or produce a
    plausible-looking figure from nothing."""
    if isinstance(value, bool) or value is None:
        return None
    try:
        out = float(value)
    except (TypeError, ValueError):
        return None
    if out != out or out in (float("inf"), float("-inf")):
        return None
    return out


@dataclass
class Renderer:
    id: str
    at_s: float
    fields: dict = field(default_factory=dict)

    def to_wire(self, now_s: float) -> dict:
        # Absolute age in ms, because the monitor derives its own display from it — and because the
        # two machines' clocks are not the same clock, so an age travels correctly where a timestamp
        # would not.
        return {"id": self.id, "ageMs": int(max(0.0, now_s - self.at_s) * 1000), **self.fields}


class RendererRegistry:
    """Newest sample per renderer, expiring on silence. Thread-safe: beacons arrive on handler
    threads while the monitor reads on another."""

    def __init__(self, *, expiry_s: float = EXPIRY_S, max_renderers: int = MAX_RENDERERS,
                 day_fn: Callable[[], str] | None = None):
        self._lock = threading.Lock()
        self._rows: dict[str, Renderer] = {}
        self._expiry_s = expiry_s
        self._max = max_renderers
        # THE DAY'S PAGE LOADS, PER WORLD — the only number that maps onto the Cesium ion bill.
        # An ion "session" is one successful GET of an asset's `/endpoint`, which `tiles.js` does once
        # per page load and never again (for photoreal, Google's own plugin takes over the tile
        # traffic from there, so a renderer left open for eight hours still costs exactly one). So a
        # count of page loads by world IS a count of sessions by world, and it is the difference
        # between "the quota went somewhere" and knowing which surface spent it. A beacon id is random
        # per page (`newId()` in client_beacon.js), so first sight of an id is a page load.
        self._day_fn = day_fn or _local_day
        self._day = self._day_fn()
        self._counted: set[str] = set()
        self._loads: dict[str, int] = {}
        self._counted_capped = False

    def record(self, payload: object, *, now_s: float | None = None) -> bool:
        """Take one beacon. Returns False for anything unusable, so the caller can answer 400 rather
        than silently accepting junk."""
        if not isinstance(payload, dict):
            return False
        rid = _clean_str(payload.get("id"), 16)
        if not rid:
            return False
        now_s = time.monotonic() if now_s is None else now_s
        fields: dict = {}
        for key in _STR_FIELDS:
            value = _clean_str(payload.get(key))
            if value is not None:
                fields[key] = value
        for key in _NUM_FIELDS:
            value = _clean_num(payload.get(key))
            if value is not None:
                fields[key] = round(value, 2)
        fields["stale"] = bool(payload.get("stale"))
        with self._lock:
            self._expire(now_s)
            if rid not in self._rows and len(self._rows) >= self._max:
                return False                                     # full: refuse rather than evict a real one
            self._rows[rid] = Renderer(id=rid, at_s=now_s, fields=fields)
            self._count_load(rid, fields.get("world"))
        return True

    def _count_load(self, rid: str, world: str | None) -> None:
        """First sight of a renderer id is one page load, hence one ion session. Caller holds the lock.

        Only ACCEPTED beacons reach here, so a POST refused by `MAX_RENDERERS` is not counted. That is
        deliberate: this number is read by an operator, and a beacon we would not trust in the roster
        must not be able to move it. The cost is an undercount in a state the product does not have —
        the live roster is single digits against a ceiling of 64.
        """
        day = self._day_fn()
        if day != self._day:                                     # a new day starts from nothing
            self._day, self._counted, self._loads, self._counted_capped = day, set(), {}, False
        if rid in self._counted:
            return
        if len(self._counted) >= MAX_COUNTED_IDS:
            self._counted_capped = True                          # keep counting, stop claiming exactness
        else:
            self._counted.add(rid)
        key = world or "unknown"
        self._loads[key] = self._loads.get(key, 0) + 1

    def loads_today(self) -> dict:
        """Page loads so far today, by world — the shape the ion bill is charged in.

        `approximate` says the id memory filled up, so a renderer that flapped may be counted twice.
        It is reported rather than hidden: an over-count that says so is usable, a silent one is not.
        """
        with self._lock:
            if self._day_fn() != self._day:                      # read on the far side of midnight
                self._day, self._counted, self._loads, self._counted_capped = self._day_fn(), set(), {}, False
            return {"day": self._day, "byWorld": dict(self._loads),
                    "approximate": self._counted_capped}

    def snapshot(self, *, now_s: float | None = None) -> list[dict]:
        now_s = time.monotonic() if now_s is None else now_s
        with self._lock:
            self._expire(now_s)
            rows = [r.to_wire(now_s) for r in self._rows.values()]
        # Slowest first: on a board read under pressure the renderer in trouble belongs at the top.
        rows.sort(key=lambda r: (r.get("fps") if isinstance(r.get("fps"), (int, float)) else -1.0,
                                 r["id"]))
        return rows

    def _expire(self, now_s: float) -> None:
        dead = [rid for rid, row in self._rows.items() if now_s - row.at_s > self._expiry_s]
        for rid in dead:
            del self._rows[rid]
