"""
The EVENT configuration → feed meta overlay, in one place.

The competition's own presentation — which venue and which WORLD (photoreal vs the stylised water
plane), the boat class, the fleet's hull livery, whether there are RC vessels, the start-sequence
length — is `event_config` data, and the renderer reads it off the feed meta. Two producers derive
that meta: the store-backed one (`serve_v3_live.apply_event_config`, reading the config out of the
database) and the REST one (`rest_source`, reading it over `/v3/manage/events/{id}/config`), and
they must land on the SAME meta or the same race looks like two different productions depending on
where the producer happens to run.

It did. The REST path built its meta from scratch and never applied the event config at all, so a
producer run with `--follow --read-base` — the deployment the whole live operation is built on,
render PC or cloud — rendered a bare stylised sea on an event whose config asks for the photoreal
venue. Verified on the field-practice broadcast 2026-08-01: `meta.world` was absent and the scene
came up as flat water with no buildings.

`integrations` (secret refs) are NEVER copied into meta — they are server-side only.
"""
from __future__ import annotations

#: The line ends an event declares as RACE-COMMITTEE VESSELS, as camera channels.
#:
#: These two are not boats and can never appear in `cameraSlots`, which is a position within the
#: RACING FLEET (A..D, derived from the teams). A race-committee vessel has no team, no sail number and no
#: place in the order — it is a phone on a buoy slot pointed down a line — so the picture reaches it
#: by ROLE and by nothing else.
VESSEL_CAMERA_NAMES = {
    "start_port": "start", "start_stbd": "start",
    "finish_port": "finish", "finish_stbd": "finish",
}


def _line_camera_overlay_meta(raw) -> dict | None:  # noqa: ANN001
    """Sanitize the event's visual line-detector controls before they reach browser clients.

    Camera calibration is event data, while detector profiles are a closed renderer capability.
    Keeping that boundary here prevents an arbitrary stored document from expanding the detector's
    work area, cadence or hold time beyond the limits the on-air browser source was tested for.
    """
    if not isinstance(raw, dict):
        return None
    out = {"enabled": bool(raw.get("enabled", False))}
    try:
        out["sampleFps"] = max(1.0, min(6.0, float(raw.get("sampleFps", 4))))
        out["maxHoldMs"] = max(0, min(750, int(raw.get("maxHoldMs", 350))))
    except (TypeError, ValueError):
        return None
    roles = {}
    for name in ("start", "finish"):
        value = (raw.get("roles") or {}).get(name) if isinstance(raw.get("roles"), dict) else None
        if not isinstance(value, dict):
            continue
        detector = str(value.get("detector", "")).lower()
        if detector not in {"blue", "red", "redorange", "green", "yellow", "checkered",
                            "darklight"}:
            continue
        role = {"detector": detector}
        target = value.get("targetRole")
        if target:
            role["targetRole"] = str(target)
        for key, length in (("anchor", 2), ("search", 4), ("targetBox", 4),
                            ("targetSize", 2)):
            declared = value.get(key)
            if isinstance(declared, (list, tuple)) and len(declared) == length:
                try:
                    nums = [float(item) for item in declared]
                except (TypeError, ValueError):
                    continue
                if all(0 <= item <= 1 for item in nums):
                    role[key] = nums
        try:
            if "minConfidence" in value:
                role["minConfidence"] = max(.5, min(.98, float(value["minConfidence"])))
            if "minPixels" in value:
                role["minPixels"] = max(3, min(800, int(value["minPixels"])))
        except (TypeError, ValueError):
            continue
        roles[name] = role
    if not roles:
        return None
    out["roles"] = roles
    return out


def vessel_camera_slots(cfg: dict | None, owner: str | None) -> dict:
    """`{"start"|"finish": slot}` for the line ends this event puts a phone on.

    The slot is built rather than looked up, and it is built from the account NAME the QR was minted
    with (`trwc2026:buoy:A`) — never from an owner id. The two spellings are one of this codebase's
    standing traps: every owner column in the database is a `users.id`, while a slot prefix is the
    username, and comparing one to the other matches nothing without failing.

    No owner, or an event that declares no vessels ⇒ `{}`. A club Saturday whose line runs between
    two buoys simply has no vessel cameras, which is absence rather than a camera that is off.
    """
    if not owner:
        return {}
    vessels = ((cfg or {}).get("devices") or {}).get("vessels") or {}
    out = {}
    for role, bid in vessels.items():
        name = VESSEL_CAMERA_NAMES.get(role)
        if name and bid:
            out[name] = f"{owner}:buoy:{bid}"
    return out


def overlay_event_config(meta: dict, cfg: dict | None, *, owner: str | None = None) -> dict:
    """Overlay an event-configuration document onto derived feed meta and return the result.

    Applied BEFORE any per-instance broadcast config, so instance keys win:
    engine-derived defaults < event config < instance broadcast config. An empty/None config
    leaves the meta untouched (full backwards compatibility).

    `owner` is the account NAME the devices registered under, and it is needed for one thing:
    turning the event's declared vessel buoy ids into slot refs the picture can ask for clips by.
    Callers derive it from a boat ref they already hold rather than from an owner column, because
    a slot prefix is the username and every owner column is a `users.id` — the two spellings that
    match nothing when compared and fail silently when they do."""
    if not cfg:
        return meta
    out = dict(meta)
    # The two line cameras, by ROLE. They can never be `cameraSlots`, which is a position in the
    # racing fleet: a race-committee vessel has no team, no sail number and no place in the order.
    vessels = vessel_camera_slots(cfg, owner)
    if vessels:
        out["vesselCameras"] = vessels
    # The visual line end is not necessarily the starboard end.  Carry the declared roles to the
    # renderer so an event can put its finish vessel on either physical end without a renderer
    # special case.  Camera names intentionally remain start/finish while this records geometry.
    vessel_roles = ((cfg.get("devices") or {}).get("vessels") or {})
    if isinstance(vessel_roles, dict):
        declared_roles = [str(role) for role, buoy_id in vessel_roles.items()
                          if role in VESSEL_CAMERA_NAMES and buoy_id]
        if declared_roles:
            out["vesselRoles"] = declared_roles
    # The presented EVENT NAME — the picture's upper-left anchor
    # (docs/streamed-picture-style-guide.md §1). Read from the CONFIG document rather than the
    # `events` row, because neither producer fetches that row.
    if cfg.get("name"):
        out["eventName"] = str(cfg["name"])
    try:
        from event_config import course_line_length_m  # noqa: PLC0415
        _line_m = course_line_length_m(cfg)
    except (ImportError, TypeError, ValueError):
        _line_m = None
    if _line_m is not None:
        out["courseLineM"] = _line_m
    venue = cfg.get("venue") or {}
    if venue:
        v = dict(out.get("venue") or {})
        if venue.get("name"):
            v["name"] = str(venue["name"])
        if venue.get("timeZone"):
            # IANA zone for the picture's timestamp, which is VENUE-local with its offset. Carried
            # as the zone id and never as a fixed offset: a replay can span a DST transition, and
            # the render machine's own zone is wrong by construction (the cloud gateway VM runs UTC,
            # which looked right in dev and wrong on air under a label that said LOCAL).
            v["timeZone"] = str(venue["timeZone"])
        if venue.get("displayTimeZoneLabel"):
            # Optional viewer wording such as CEST. The IANA zone above remains authoritative for
            # time calculation; this is supplied only by events that stay in one DST period.
            v["displayTimeZoneLabel"] = str(venue["displayTimeZoneLabel"])
        # WHERE THE EVENT SAYS IT IS SAILED. Carried through because consumers need the DECLARED
        # venue, not a derived one: the guided excursion takes its home point from here and is simply
        # never armed without it (`_Cruise`: no centre → no Excursion), and the course centre falls
        # back to a boat's position when nothing declares a venue. Both were silently degraded — the
        # keys never reached meta at all, because this block seeds `v` from the engine-derived venue
        # and then copies only the keys it lists, and these two were not on the list.
        for _k in ("centreLat", "centreLon"):
            if isinstance(venue.get(_k), (int, float)) and not isinstance(venue.get(_k), bool):
                v[_k] = float(venue[_k])
        if venue.get("cityBearingDeg") is not None:
            v["cityBearingDeg"] = float(venue["cityBearingDeg"])
        if venue.get("world"):
            v["world"] = str(venue["world"])
        if venue.get("waterSurface"):
            # A venue may explicitly choose the water ownership model. ``native`` leaves the
            # streamed terrain's water as the only surface; ``broadcast`` is reserved for a
            # separately validated, venue-defined footprint. This is venue data because a renderer
            # cannot safely infer where a particular harbour's open water ends.
            v["waterSurface"] = dict(venue["waterSurface"])
        if venue.get("navMarks"):
            v["navMarks"] = venue["navMarks"]           # real venue aids to navigation (renderer draws them)
        if venue.get("speechLang"):
            # The language the venue's proper names are SPOKEN in when they occur inside another
            # language's narration (event_config.venue_speech_names → the TTS `<lang>` wrapping
            # team names already get). Carried with `speechNames`, the extra in-line names.
            v["speechLang"] = str(venue["speechLang"])
        if venue.get("speechNames"):
            v["speechNames"] = list(venue["speechNames"])
        if venue.get("speechIpa"):
            # IPA per name — what actually fires on Studio voices, which reject <lang> whole-line
            v["speechIpa"] = dict(venue["speechIpa"])
        # The guided-tour PLACES, split back to the two lists the consumers already read — the
        # cruise venue-pan's `landmarks` (`cruise_tour.TourScheduler`) and the guided flight's
        # `tourSites` (`excursion.Excursion`) — from the merged `venue.places`. The organizer's
        # data-only dial `presentation.cruise.tourCategories` (an allow-list of theme slugs; absent =
        # all) narrows which themes fly, applied HERE so the tour code never needs to know categories
        # exist. Venue facts, same rule as `navMarks`; LIVE data — a republish updates the next tour.
        # `venue.landmarks`/`venue.tourSites` still resolve as a fallback for an un-migrated event.
        from event_config import venue_place_lists  # noqa: PLC0415 — v3/ingest is on the producer path
        _cats = ((cfg.get("presentation") or {}).get("cruise") or {}).get("tourCategories")
        _landmarks, _tour_sites = venue_place_lists(venue, categories=_cats)
        if _landmarks:
            v["landmarks"] = _landmarks
        if _tour_sites:
            v["tourSites"] = _tour_sites
        out["venue"] = v
    if cfg.get("boatClass"):
        out["boatClass"] = str(cfg["boatClass"])
    if cfg.get("teamBibs"):
        # The event's team BIB table, `{teamKey: {number, colour, label}}`. The renderer leads every
        # boat reference with the bib chip, and it must come from event data — never inferred from a
        # team's display NAME, which is what `flags.js` used to do.
        out["teamBibs"] = cfg["teamBibs"]
    pres = cfg.get("presentation") or {}
    if pres.get("neuralTtsOnly") is not None:
        # A pinned broadcast cast is an event-level programme choice.  Per-instance broadcast
        # configuration is applied afterwards and may still override it for a rehearsal.
        out["neuralTtsOnly"] = bool(pres["neuralTtsOnly"])
    if pres.get("neuralTtsFallback") is not None:
        out["neuralTtsFallback"] = bool(pres["neuralTtsFallback"])
    if pres.get("noDownwindSails") is not None:
        out["noDownwindSails"] = bool(pres["noDownwindSails"])
    if pres.get("rcVessels") is not None:
        out["rcVessels"] = bool(pres["rcVessels"])
    if pres.get("markColors"):
        # The physical colour of each course-mark bot, by role ("blue", "yellow", …, "checkered") —
        # the race committee's own fleet list (an event fact: which bot holds which spot is theirs
        # to declare), published so the picture matches the water. The renderer owns the paint each
        # word maps to and keeps its SI defaults for any role left undeclared (scene.js).
        out["markColors"] = dict(pres["markColors"])
    if pres.get("hullLivery"):                             # named fleet paint scheme (renderer j80.js;
        out["hullLivery"] = str(pres["hullLivery"])        # 'gsys' = the GSYS hulls TRWC races on)
    if isinstance(pres.get("hullProfiles"), dict):
        # A physical hull can differ from the fleet default. This is keyed by hull rather than
        # team: teams rotate through hulls, while paint, jib colour and reserve markings do not.
        out["hullProfiles"] = {str(hull): dict(profile)
                               for hull, profile in pres["hullProfiles"].items()
                               if isinstance(profile, dict)}
    if isinstance(pres.get("hullSubstitutions"), dict):
        # Race identity remains intact for tracking and commentary; this changes only the physical
        # boat the renderer draws. Copy so a feed consumer cannot mutate event configuration.
        out["hullSubstitutions"] = {
            str(slot): dict(replacement) if isinstance(replacement, dict) else str(replacement)
            for slot, replacement in pres["hullSubstitutions"].items()
        }
    if isinstance(pres.get("cameraDisplayRotationDegByRef"), dict):
        # A field camera may be mounted in a pose an older phone build encoded incorrectly. This is
        # a PRESENTATION correction only: raw/archived video is immutable, and the generic renderer
        # consumes stable camera refs rather than learning an event's hulls or start-vessel ids.
        # Only quarter-turns are meaningful for phone capture; reject arbitrary angles instead of
        # letting a typo put a slanted picture on air.
        rotations = {}
        for ref, value in pres["cameraDisplayRotationDegByRef"].items():
            try:
                deg = int(value)
            except (TypeError, ValueError):
                continue
            deg = ((deg % 360) + 360) % 360
            if deg > 180:
                deg -= 360
            if deg in (-180, -90, 0, 90, 180):
                rotations[str(ref)] = deg
        if rotations:
            out["cameraDisplayRotationDegByRef"] = rotations
    line_camera_overlay = _line_camera_overlay_meta(pres.get("lineCameraOverlay"))
    if line_camera_overlay:
        # A line overlay is a calibrated statement about a physical camera and its opposite mark.
        # The browser still verifies the mark in the pixels and hides the line on ambiguity; this
        # block only carries the event-owned calibration and detector profile.
        out["lineCameraOverlay"] = line_camera_overlay
    if pres.get("showJibNumbers") is not None:
        out["showJibNumbers"] = bool(pres["showJibNumbers"])
    if pres.get("sailPlan") is not None:
        # A fleet may keep its supplied cloth artwork (`"class"`) or deliberately present clean
        # sails (`"plain"`). The renderer consumes this as a skin choice only; rig geometry remains
        # the same verified J/80 model.
        out["sailPlan"] = pres["sailPlan"]
    if pres.get("sailFlagByBib"):
        # A presentation bib and a national sail flag answer separate identity questions.  Copy the
        # event mapping rather than teaching the renderer that any special entry belongs to a country.
        out["sailFlagByBib"] = dict(pres["sailFlagByBib"])
    if isinstance(pres.get("identityStreamers"), dict):
        # Championship-specific artwork shared by masthead and mainsail is event identity, not a
        # renderer constant. Copy each declaration so a live feed consumer cannot mutate config.
        out["identityStreamers"] = {
            str(code): dict(spec) for code, spec in pres["identityStreamers"].items()
            if isinstance(spec, dict) and spec.get("asset")
        }
    if isinstance(pres.get("sailIdentityStreamers"), dict):
        # A special entry can retain supplied mainsail artwork while carrying a different masthead
        # identifier. This explicit sail-only channel prevents the artwork from leaking onto both
        # surfaces merely because they once shared one configuration table.
        out["sailIdentityStreamers"] = {
            str(code): dict(spec) for code, spec in pres["sailIdentityStreamers"].items()
            if isinstance(spec, dict) and spec.get("asset")
        }
    if isinstance(pres.get("mastheadIdentityLabelByBib"), dict):
        # A label override handles multiple entries under one national flag without baking an
        # event-specific ordinal into the generic masthead renderer.
        out["mastheadIdentityLabelByBib"] = {
            str(code): str(label) for code, label in pres["mastheadIdentityLabelByBib"].items()
            if str(label).strip()
        }
    # Long genuine holds get a steady course orientation without attaching a rapid tour to race
    # hand-overs. Three minutes is the product default for every event; an event may tune or disable
    # the waiting presentation independently with an explicit zero.
    waiting_course_s = pres.get("waitingCoursePresentationEveryS")
    if waiting_course_s is None:
        waiting_course_s = 180
    if waiting_course_s is not None:
        waiting_course_s = int(waiting_course_s)
        if waiting_course_s > 0:
            out["waitingCoursePresentationEveryS"] = max(60, waiting_course_s)
    if pres.get("mode"):
        out["presentationMode"] = str(pres["mode"])        # "race" | "track" | "cruise"
    if pres.get("cruise"):
        # The cruise tour's knobs (dwell, fly-in, landmark cadence — see `cruise_tour.CruiseConfig`).
        # Producer-read only; the renderer never branches on them.
        out["cruise"] = dict(pres["cruise"])
    if pres.get("crew") is not None:
        # PEOPLE ON THE BOATS. The renderer ships the capability (scene.js `_showCrew`, j80.js
        # `buildCrew` — four hands on the windward rail, wearing the team's race bib); WHETHER a
        # fleet is presented crewed is the event's call. It defaulted to off, so a championship
        # sailed with nobody aboard unless somebody remembered `?crew=1` on the URL — the kind of
        # default that is invisible until you look at a close-up.
        out["crew"] = bool(pres["crew"])
    if pres.get("signalFlags"):
        # What the RC vessels fly from their aft flagpoles (`signalflags.js`). The LINE flags default
        # to the rules' own values in the renderer — orange marks a starting-line end, RRS Race
        # Signals' blue says an RC boat is on station at the finishing line — because those are rules
        # rather than anyone's identity, so a default states nothing an event has not agreed to.
        #
        # `classFlag` has NO default and is the reason this key exists: which flag goes up at the
        # warning signal is a class's or an event's own device, and a renderer constant would put one
        # class's artwork on every stream this product makes. Undeclared ⇒ that halyard flies nothing.
        out["signalFlags"] = dict(pres["signalFlags"])
    if pres.get("penaltyRegime"):
        out["penaltyRegime"] = str(pres["penaltyRegime"])   # what a penalty IS here (SI-dependent)
    if pres.get("observedGeometryGuard") in ("rie", "off"):
        # An event can retain Enrich as its programme voice while borrowing RIE's conservative
        # tracking-quality boundary for claims and geometry graphics.
        out["observedGeometryGuard"] = str(pres["observedGeometryGuard"])
    if pres.get("sanctionMark"):
        # The body under whose AUTHORITY the competition is sailed — its mark is stacked above ours
        # in the picture's lower-right corner (`sanction.js`). Event data for the obvious reason: a
        # club Saturday is sanctioned by nobody, and a renderer carrying a world federation's mark
        # by default would put a claim of sanction on every stream that federation never made.
        # Unlike `partnerMarks` this IS overlay furniture — it answers the corner's own question
        # (whose broadcast / under whose authority), where a sponsor answers none of the four.
        out["sanctionMark"] = dict(pres["sanctionMark"])
    if pres.get("partnerMarks"):
        # The organisations shown in the SCENE — the arranging club, the city, a sponsor: a band
        # laid flat on the water framing the competition area, and the start/finish gate portal.
        # This is the only route by which another organisation's name or artwork reaches the
        # renderer: `partner_marks.js` names none of them. It stays out of the picture's overlay
        # furniture on purpose (the four anchors and nothing else, style guide §1/§7), which is also
        # what a third-party brand manual means by "placed separately from the primary sender".
        out["partnerMarks"] = dict(pres["partnerMarks"])
    if pres.get("eventWaterMark"):
        # The host competition's own on-water lock-up is not a sponsor board. It is declared
        # separately so it can be a single, prominent, transparent mark at the course centre while
        # partner marks remain a rotating perimeter/gate programme.
        out["eventWaterMark"] = dict(pres["eventWaterMark"])
    if pres.get("waitingMusic"):
        # A rights-cleared, low-level bed for a declared programme hold.  It is event data, not a
        # renderer default: the stream must never fetch, select, or imply permission for music on
        # behalf of an organiser.  The browser fades this source out whenever speech or a signal
        # begins, so carrying it in live meta keeps simulator and production behaviour identical.
        out["waitingMusic"] = dict(pres["waitingMusic"])
    if pres.get("startSequence"):
        # The event's start-sequence LENGTH (SI 11.1 — e.g. a 3-minute team-racing sequence). The
        # driver derives `trackStartUtcMs = gun - preStartS` from it, so a competition that runs a
        # 5-minute sequence must not silently be served the engine's fallback. Only the duration is
        # the event's choice; the RRS tone patterns + offset shape stay engine capabilities
        # (v3/hooter/schedule.start_sequence_offsets).
        out["startSequence"] = dict(pres["startSequence"])
    return out
