"""The guided EXCURSION — a continuous narrated flight over the venue's surroundings.

Filler for the pauses in a broadcast day: the camera leaves the course, glides past the places
that stand around the water while a guide speaks about them, and comes home — a small scenic
flight, never a slideshow. The one rule that shapes everything here is that the aim point is
ALWAYS MOVING (Ulf 2026-08-10): the tour never parks at a stop; a long narration slows the leg
that carries it rather than halting the flight.

This module is pure — no clock, no store, no server (the `cruise_tour.py` discipline). It emits
one `tour_poi` beat per second with an interpolated aim point; the renderer's one critically
damped camera spring (scene.js `_driveCamera`, retuned to TOUR) turns that 1 Hz stair-step into
smooth flight, trailing the aim by design. Three beat fields beyond the landmark tour's:

  * `poiBearingDeg` — the TRUE compass bearing from the aim point to where the camera should
    stand: the travel bearing + 180°, so the camera flies BEHIND the aim looking forward along
    the route. Blended by shortest delta over a few seconds at leg joins (angles wrap — root
    CLAUDE.md); the renderer converts to its scene azimuth, this module never knows scene axes.
  * `poiSpeedMps` — present only on the recall home-run: raises the renderer's spring cap for
    the express return (with headroom over the aim's own ground speed, or the camera could
    never close on it).
  * `focusUntilSec = t + 3` — each beat expires almost immediately, so a stalled feed releases
    the camera instead of pinning it to the last aim point for ever.

WHAT it flies to is event data (`venue.tourSites`, published beside `venue.landmarks`), never
authored here: a flat catalogue of `{name, lat, lon, line?, tour?}`. `line` optional — an entry
without one is a shape point that bends the path silently. Entries sharing a `tour` id form a
STORY ROUTE (the Vasa story, the Lidingö Runt loop): an indivisible, ordered unit the planner
flies whole, chapters in listed order, never re-sorted and never interleaved with other sites.

Eligibility is COMPUTED, never tagged: a unit qualifies when every point lies within `radius_m`
of home (None = unbounded — the cruise day's whole catalogue) and its flight time fits the
`excursion_target_s` budget. On a race day the caller derives a conservative radius from the return
knobs (`recall_speed_mps * recall_budget_s`). The broadcast's ARM is now both recall and immediate
programme hand-over, so the venue/course opening re-establishes the picture without delaying the
countdown; keeping the waiting flight local makes that transition visually coherent. `recall()`
remains the graceful return for callers that have advance notice: the remaining route is replaced
by a single home leg while narration already playing is allowed to finish.
"""
from __future__ import annotations

import math
from dataclasses import dataclass

from cruise_tour import _speech_seconds

#: Seconds over which the camera's stand-off bearing blends at a leg join — long enough that a
#: 90° turn reads as a bank, short enough that the new leg's forward view arrives promptly.
BLEND_S = 4
#: A narrated leg is sized so its line FINISHES this margin before arrival at the site.
SAY_MARGIN_S = 2
#: Headroom factor for the recall spring cap over the aim's ground speed — a cap equal to the
#: aim's own speed can only ever trail further behind; 1.5 lets the spring close and settle.
RECALL_CAP_FACTOR = 1.5


@dataclass(frozen=True)
class ExcursionConfig:
    """The excursion's knobs. Defaults here; an event overrides via the same `presentation.cruise`
    (camelCase) block the tour's other knobs ride — event data, never a renderer constant."""
    excursion_speed_mps: int = 30   #: the aim point's ground speed along a leg
    excursion_standoff_m: int = 240  #: camera stand-off from the aim (the landmark framing)
    excursion_target_s: int = 600   #: budget one excursion plans to — a longer unit is left out
    recall_speed_mps: int = 70      #: the home run's ground speed when a race is armed
    recall_budget_s: int = 60       #: sizing budget for the race-day excursion radius

    _KEYS = {"excursionSpeedMps": "excursion_speed_mps",
             "excursionStandoffM": "excursion_standoff_m",
             "excursionTargetS": "excursion_target_s",
             "recallSpeedMps": "recall_speed_mps",
             "recallBudgetS": "recall_budget_s"}

    @classmethod
    def from_meta(cls, block: dict | None) -> ExcursionConfig:
        """`presentation.cruise` → a config; unknown keys ignored, non-numeric values ignored (a
        malformed knob falls back to the default rather than crashing the show — `CruiseConfig`)."""
        kwargs = {}
        for wire, field_name in cls._KEYS.items():
            v = (block or {}).get(wire)
            if isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) and v >= 0:
                kwargs[field_name] = int(v)
        return cls(**kwargs)

    def recall_radius_m(self) -> float:
        """The race-day eligibility radius, computed from the return knobs and never tagged on a
        site. It keeps waiting excursions local even when ARM hands directly to the opening."""
        return float(max(1, self.recall_speed_mps) * max(1, self.recall_budget_s))


# --- geometry (equirectangular — error <0.1 % at the <=60 km excursion scale) -------------------

def _dist_m(lat0: float, lon0: float, lat1: float, lon1: float) -> float:
    kx = 111_320.0 * math.cos(math.radians((lat0 + lat1) / 2))
    return math.hypot((lon1 - lon0) * kx, (lat1 - lat0) * 110_574.0)


def _bearing_deg(lat0: float, lon0: float, lat1: float, lon1: float) -> float:
    """TRUE compass bearing from point 0 to point 1 (0 = north, 90 = east)."""
    kx = math.cos(math.radians((lat0 + lat1) / 2))
    return math.degrees(math.atan2((lon1 - lon0) * kx, lat1 - lat0)) % 360.0


def _wrap_delta(a: float, b: float) -> float:
    """Shortest signed delta a→b in degrees — bearings live on a circle (root CLAUDE.md)."""
    return ((b - a + 540.0) % 360.0) - 180.0


# --- the catalogue -------------------------------------------------------------------------------

def _valid_sites(sites: list | None) -> list[dict]:
    """Entries a camera can actually be aimed at: finite lat/lon and a non-empty name (the plate
    reads the name of the site being approached, a nameless aim point would blank it). Anything
    else is dropped — an invalid entry must never reach the geometry (the sensor-values rule)."""
    out = []
    for s in sites or []:
        if not isinstance(s, dict):
            continue
        lat, lon = s.get("lat"), s.get("lon")
        if not (isinstance(lat, (int, float)) and isinstance(lon, (int, float))
                and math.isfinite(lat) and math.isfinite(lon)):
            continue
        if not str(s.get("name") or "").strip():
            continue
        out.append(s)
    return out


def _units(sites: list[dict]) -> list[list[dict]]:
    """Route units in catalogue order: a free site is its own unit; sites sharing a `tour` id form
    ONE ordered unit (chapters in listed order), seated where its first chapter appears."""
    units: list[list[dict]] = []
    by_tour: dict[str, list[dict]] = {}
    for s in sites:
        tid = str(s.get("tour") or "").strip()
        if not tid:
            units.append([s])
        elif tid in by_tour:
            by_tour[tid].append(s)
        else:
            by_tour[tid] = [s]
            units.append(by_tour[tid])
    return units


class Excursion:
    """One planner/flyer, owned by a driver (the cruise scheduler on its excursion laps, the
    waiting picture directly). `begin(t)` plans a route and starts flying; `tick(t)` returns this
    second's beat; `recall(t)` turns for home; `update_sites` swaps the catalogue live."""

    def __init__(self, cfg: ExcursionConfig, *, sites: list | None = None,
                 centre: tuple[float, float] | None = None, radius_m: float | None = None,
                 home_name: str | None = None) -> None:
        self.cfg = cfg
        self.sites = _valid_sites(sites)
        ok_centre = (centre is not None and len(centre) == 2
                     and all(isinstance(c, (int, float)) and math.isfinite(c) for c in centre))
        self.centre = (float(centre[0]), float(centre[1])) if ok_centre else None
        self.radius_m = radius_m
        #: What the plate reads on the glide home — the venue's own name, never invented here.
        self.home_name = home_name
        self._n = 0                       #: excursions planned so far — the rotation seed
        self._legs: list[dict] = []
        self._i: int | None = None        #: current leg index; None = not flying
        self._leg_t0 = 0
        self._recalled = False

    # --- state ----------------------------------------------------------------------------------

    @property
    def flying(self) -> bool:
        return self._i is not None

    @property
    def done(self) -> bool:
        return self._i is None

    def available(self) -> bool:
        """Is there anything to fly at all — a home point and at least one unit that fits the
        radius AND the time budget? (What `cruise_tour` asks before scheduling an excursion lap.)"""
        return self.centre is not None and bool(self._plan_route())

    def update_sites(self, sites: list | None) -> bool:
        """Swap the catalogue mid-broadcast (an operator — or Claude — republished the event).
        Returns whether anything changed. A publish whose every entry is invalid keeps the working
        catalogue (hold-last: a malformed read must not blank a running tour), while a deliberate
        EMPTY list is honoured — that is how the tour is retired by data. Legs already planned
        keep flying; the next `begin()` sees the new list."""
        fresh = _valid_sites(sites)
        if sites and not fresh:
            return False
        changed = fresh != self.sites
        self.sites = fresh
        return changed

    # --- planning -------------------------------------------------------------------------------

    def _unit_time_s(self, unit: list[dict], from_lat: float, from_lon: float) -> float:
        """Estimated flying time through a unit from a given point — each leg the LONGER of its
        travel time and its narration (a long line slows the leg, never stops it)."""
        speed = max(1, self.cfg.excursion_speed_mps)
        total, la, lo = 0.0, from_lat, from_lon
        for s in unit:
            travel = _dist_m(la, lo, s["lat"], s["lon"]) / speed
            line = str(s.get("line") or "")
            total += max(travel, (_speech_seconds(line) + SAY_MARGIN_S) if line else 0.0)
            la, lo = s["lat"], s["lon"]
        return total

    def _plan_route(self, n: int | None = None) -> list[list[dict]]:
        """The deterministic route for excursion `n`: eligible units (every point within the
        radius), the starting unit rotated by `n`, then nearest-neighbour among the rest — a story
        route always whole, in chapter order. Units are added only while the running total plus
        the flight home fits `excursion_target_s`; a unit too long for the whole budget simply
        never flies until an event raises the budget. No randomness anywhere — the same catalogue
        and the same counter always plan the same route (the scheduler is pure and replayable)."""
        if self.centre is None:
            return []
        clat, clon = self.centre
        units = _units(self.sites)
        if self.radius_m is not None:
            units = [u for u in units
                     if all(_dist_m(clat, clon, s["lat"], s["lon"]) <= self.radius_m for s in u)]
        if not units:
            return []
        speed = max(1, self.cfg.excursion_speed_mps)
        k = (self._n if n is None else n) % len(units)
        pool = units[k:] + units[:k]
        route: list[list[dict]] = []
        used = 0.0
        lat, lon = clat, clon
        while pool:
            # nearest first (by entry point) — except the rotated start, which leads by decree
            nxt = (pool[0] if not route else
                   min(pool, key=lambda u: _dist_m(lat, lon, u[0]["lat"], u[0]["lon"])))
            t_unit = self._unit_time_s(nxt, lat, lon)
            t_home = _dist_m(nxt[-1]["lat"], nxt[-1]["lon"], clat, clon) / speed
            if used + t_unit + t_home <= self.cfg.excursion_target_s:
                pool.remove(nxt)
                route.append(nxt)
                used += t_unit
                lat, lon = nxt[-1]["lat"], nxt[-1]["lon"]
            elif not route:
                pool.pop(0)               # the rotated start itself does not fit — try the next
            else:
                break
        return route

    def begin(self, t: int) -> None:
        """Plan the next excursion and start flying at second `t` (nothing eligible → immediately
        `done`, the caller falls back to its ordinary programme)."""
        self._recalled = False
        route = self._plan_route()
        self._n += 1
        if not route or self.centre is None:
            self._legs, self._i = [], None
            return
        clat, clon = self.centre
        speed = max(1, self.cfg.excursion_speed_mps)
        legs, la, lo = [], clat, clon
        for site in [s for u in route for s in u]:
            legs.append(self._leg(la, lo, site["lat"], site["lon"], speed,
                                  name=str(site["name"]), line=str(site.get("line") or "")))
            la, lo = site["lat"], site["lon"]
        legs.append(self._leg(la, lo, clat, clon, speed, name=self.home_name, line=""))
        self._legs, self._i, self._leg_t0 = legs, 0, int(t)

    @staticmethod
    def _leg(lat0: float, lon0: float, lat1: float, lon1: float, speed: float, *,
             name: str | None, line: str) -> dict:
        dist = _dist_m(lat0, lon0, lat1, lon1)
        speech = _speech_seconds(line) + SAY_MARGIN_S if line else 0
        dur = max(1, math.ceil(max(dist / speed, speech)))
        return {"lat0": lat0, "lon0": lon0, "lat1": lat1, "lon1": lon1,
                "dur_s": dur, "bearing": _bearing_deg(lat0, lon0, lat1, lon1),
                "name": name, "line": line, "said": False,
                #: the line starts so it FINISHES ~SAY_MARGIN_S before arrival at the site
                "say_at": max(0, dur - speech) if line else None}

    # --- flying ---------------------------------------------------------------------------------

    def tick(self, t: int) -> list[dict]:
        """The beat for second `t`: the aim point moved along the route — EVERY tick, never
        stationary. Empty once the route is flown (and `done` turns true)."""
        if self._i is None:
            return []
        t = int(t)
        leg = self._legs[self._i]
        elapsed = t - self._leg_t0
        while elapsed >= leg["dur_s"]:
            elapsed -= leg["dur_s"]
            self._i += 1
            self._leg_t0 = t - elapsed
            if self._i >= len(self._legs):
                self._i = None
                return []
            leg = self._legs[self._i]
        f = elapsed / leg["dur_s"]
        beat = {"tSec": t, "type": "tour_poi", "claimClass": "structure",
                "commentaryRole": "lead", "shotType": "boat-feature",
                "poi": {"lat": leg["lat0"] + (leg["lat1"] - leg["lat0"]) * f,
                        "lon": leg["lon0"] + (leg["lon1"] - leg["lon0"]) * f},
                "poiDistM": self.cfg.excursion_standoff_m,
                "poiBearingDeg": round(self._camera_bearing(elapsed), 1),
                "focusUntilSec": t + 3}
        if leg["name"]:
            beat["name"] = leg["name"]
        if self._recalled:
            beat["poiSpeedMps"] = int(max(1, self.cfg.recall_speed_mps) * RECALL_CAP_FACTOR)
        if leg["line"] and not leg["said"] and elapsed >= (leg["say_at"] or 0):
            leg["said"] = True
            beat["text"] = leg["line"]
        return [beat]

    def _camera_bearing(self, elapsed: int) -> float:
        """Compass bearing aim→camera: behind the aim along the travel direction, blended by the
        SHORTEST delta over the first `BLEND_S` of a leg (a turn banks; it never whips the long
        way round the circle)."""
        leg = self._legs[self._i]
        behind = (leg["bearing"] + 180.0) % 360.0
        if self._i == 0 or elapsed >= BLEND_S:
            return behind
        prev = (self._legs[self._i - 1]["bearing"] + 180.0) % 360.0
        return (prev + _wrap_delta(prev, behind) * (elapsed / BLEND_S)) % 360.0

    def recall(self, t: int) -> None:
        """A race was armed (T-4): drop the rest of the route and fly straight home at the recall
        speed. The narration already playing is left to finish (no beat retracts a line); no NEW
        line is spoken. Idempotent, and a no-op when not flying."""
        if self._i is None or self._recalled or self.centre is None:
            return
        t = int(t)
        leg = self._legs[self._i]
        f = min(1.0, max(0, t - self._leg_t0) / leg["dur_s"])
        lat = leg["lat0"] + (leg["lat1"] - leg["lat0"]) * f
        lon = leg["lon0"] + (leg["lon1"] - leg["lon0"]) * f
        speed = max(1, self.cfg.recall_speed_mps)
        home = self._leg(lat, lon, self.centre[0], self.centre[1], speed,
                         name=self.home_name, line="")
        #: the abandoned leg stays in front so the turn's bearing blend has its FROM value
        self._legs = [dict(leg), home]
        self._i, self._leg_t0 = 1, t
        self._recalled = True
