"""Route-map window — an undocked, zoomable schematic over the fairway network.

Two levels:
  * OVERVIEW : fairways in range (coverage proxy) at real positions, vivid per
               route, junction dots, and a live ▲out / ▼in count per route. No
               individual ships. Route names appear once zoomed in.
  * DETAIL   : double-click a route -> it becomes a single straight strip with
               its first/last junction and the live vessels placed by route-km
               (outbound above the line, inbound below).

Pure rendering: all geometry/tallies come from RouteMapModel, so this file holds
no maths. Data is pulled live via the providers passed in by the host widget.
"""
from qgis.PyQt.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QLabel,
    QGraphicsScene, QGraphicsView, QGraphicsTextItem, QGraphicsItem)
from qgis.PyQt.QtGui import QPen, QBrush, QColor, QPolygonF, QPainter, QPainterPath
from qgis.PyQt.QtCore import Qt, QPointF, QRectF, QTimer
import html
import os

from .route_map_model import RouteMapModel

try:
    from qgis.gui import QgsMapToolPan
except Exception:                          # pragma: no cover - headless import
    QgsMapToolPan = object

# Name of the chart display layer: the vaarwegen passages (individual stretches
# + names), shown for context only — binding runs on the separate route network.
VAARWEGEN_LAYER_NAME = "Fairways (vaarwegen)"


def _enum(obj, scope, name):
    """Qt5/Qt6-safe enum lookup, e.g. _enum(Qt,'AspectRatioMode','KeepAspectRatio')."""
    return getattr(getattr(obj, scope, obj), name)


def _str_field(name):
    """Create a string QgsField without the deprecated-constructor warning
    on newer QGIS (QMetaType) while still working on 3.22 (QVariant)."""
    from qgis.core import QgsField
    try:
        from qgis.PyQt.QtCore import QMetaType
        try:
            return QgsField(name, QMetaType.Type.QString)   # Qt6 / QGIS 3.38+
        except Exception:
            return QgsField(name, QMetaType.QString)        # Qt5 spelling
    except Exception:
        from qgis.PyQt.QtCore import QVariant
        return QgsField(name, QVariant.String)              # legacy fallback


_ANTIALIAS = _enum(QPainter, "RenderHint", "Antialiasing")
_SCROLLDRAG = _enum(QGraphicsView, "DragMode", "ScrollHandDrag")
_ANCHOR_MOUSE = _enum(QGraphicsView, "ViewportAnchor", "AnchorUnderMouse")
_KEEP_ASPECT = _enum(Qt, "AspectRatioMode", "KeepAspectRatio")
_IGNORE_TF = _enum(QGraphicsItem, "GraphicsItemFlag", "ItemIgnoresTransformations")
_DOTLINE = _enum(Qt, "PenStyle", "DotLine")

_PAL = [(214, 40, 40), (0, 119, 182), (46, 160, 67), (230, 150, 0),
        (123, 44, 191), (0, 150, 136), (233, 30, 99), (63, 81, 181),
        (255, 87, 34), (3, 140, 200), (120, 160, 40), (156, 39, 176)]
_OUT = QColor(230, 57, 70)
_IN = QColor(29, 53, 87)
_NAME_ZOOM = 2.2            # show route names once view scale exceeds this

# Route-CPA accent: blue for a head-on meeting, green for an overtaking.
_CPA_BLUE = QColor(0, 90, 255)
_CPA_GREEN = QColor(0, 170, 70)

# Fixed infrastructure on the route, fetched live from the RWS FIS-VNDS WFS
# (same source as the bundled fairway network, so routeid/route-km line up). Both
# layers are points carrying routeid + routekmbegin, so they place directly on
# the strip by route-km — no geometry snapping needed.
_WFS_BASE = "https://geo.rijkswaterstaat.nl/services/ogc/gdr/fis_vnds/ows"
_WFS_LAYERS = (("brug", "bridge"), ("sluiskolk", "lock"))
_BRIDGE_COLOR = QColor(140, 40, 170)     # purple
_LOCK_COLOR = QColor(230, 140, 0)        # orange
_MARKER_COLOR = QColor(0, 150, 136)      # teal — user reference markers

# Live display palette (day/dusk/night). Refreshed from ais_palette on every
# rebuild so the strip follows the active chart-light mode. The line-colour
# globals above are reassigned too (dark navy inbound is invisible on a night bg).
_BG = QColor(255, 255, 255)
_AXIS = QColor(35, 35, 35)
_TEXT = QColor(20, 20, 20)
_HALO = QColor("white")
_ENDPOINT = QColor(60, 60, 60)


def _refresh_palette():
    """Reload day/dusk/night colours from ais_palette into the module globals the
    draw code reads. Best-effort; leaves the day defaults on any problem."""
    global _OUT, _IN, _CPA_BLUE, _CPA_GREEN, _BRIDGE_COLOR, _LOCK_COLOR
    global _MARKER_COLOR, _BG, _AXIS, _TEXT, _HALO, _ENDPOINT
    try:
        from . import ais_palette as _p
    except Exception:                          # noqa: BLE001
        return
    _OUT = _p.color("leg_out"); _IN = _p.color("leg_in")
    _CPA_BLUE = _p.color("leg_cpa_blue"); _CPA_GREEN = _p.color("leg_cpa_green")
    _BRIDGE_COLOR = _p.color("leg_bridge"); _LOCK_COLOR = _p.color("leg_lock")
    _MARKER_COLOR = _p.color("leg_marker")
    _BG = _p.color("leg_bg"); _AXIS = _p.color("leg_axis")
    _TEXT = _p.color("leg_text"); _HALO = _p.color("leg_halo")
    _ENDPOINT = _p.color("leg_endpoint")

# User-definable reference markers: an editable QGIS point layer the user fills
# with real-world references near a fairway (a buoy, a VTS sector boundary, a
# berth). Persisted in markers.gpkg in the profile dir so edits survive restarts;
# snapped to route-km and drawn on the strip exactly like bridges/locks.
MARKER_LAYER_NAME = "Route reference markers"


def _object_color(kind):
    """Marker colour for a strip object: orange lock / teal user marker / purple
    bridge (the default)."""
    if kind == "lock":
        return _LOCK_COLOR
    if kind == "marker":
        return _MARKER_COLOR
    return _BRIDGE_COLOR


def _parse_objects_geojson(geojson, kind):
    """Pure: WFS GeoJSON FeatureCollection -> [{name, kind, routeid, km}].

    Uses each feature's own routeid + routekmbegin (falling back to routekmend),
    so objects place on the strip by route-km directly. Features missing a route
    or km are skipped. No Qt/QGIS — testable offline."""
    out = []
    for f in (geojson or {}).get("features", []) or []:
        p = f.get("properties") or {}
        rid = p.get("routeid")
        km = p.get("routekmbegin")
        if km is None:
            km = p.get("routekmend")
        if rid is None or km is None:
            continue
        try:
            km = float(km)
        except (TypeError, ValueError):
            continue
        out.append({"name": p.get("name") or "", "kind": kind,
                    "routeid": rid, "km": km})
    return out

# Speed colour ramp for the detail-strip markers.
_SOG_MIN_KN = 2.0          # at/below this the marker is greenest
_SOG_MAX_KN = 20.0         # at/above this the marker is reddest (clamped)
_STRIP_MIN_SOG_KN = 2.0    # vessels slower than this are dropped from the strip

# Detail-strip vertical layout, in SCREEN pixels. Positions are divided by the
# live view scale at draw time so the spacing looks the SAME zoomed in or out,
# and stacked labels stay clear of each other when zoomed out.
_LANE_PX = 34.0            # triangle lane: base distance from the axis
_SYM_STEP_PX = 20.0        # extra perpendicular offset per stacked triangle
_SYM_ALONG_PX = 22.0       # triangles closer than this along the axis would overlap
_NAME_BEYOND_PX = 16.0     # keep a name this far past its triangle's outer edge
_NAME_GAP_PX = 56.0        # axis -> nearest edge of a ship name (beyond the lane)
_NAME_STEP_PX = 17.0       # extra offset per declutter level
_NAME_XGAP_PX = 16.0       # min screen gap in x before two names are stacked
_OBJ_NAME_PX = 240.0       # axis -> bridge/lock name (well above the ship names)
_CPA_HALF_PX = 120.0       # half-height of the route-CPA bar


def _route_color(rid):
    """Stable vivid colour for a route id (grey when unknown)."""
    if rid is None:
        return QColor(120, 120, 120)
    return QColor(*_PAL[hash(rid) % len(_PAL)])


def _sog_color(sog):
    """Heat colour for a vessel by speed: green at 2 kn ramping to red at 20 kn,
    clamped red above 20. Grey when the speed is unknown."""
    if sog is None:
        return QColor(150, 150, 150)
    t = (sog - _SOG_MIN_KN) / (_SOG_MAX_KN - _SOG_MIN_KN)
    t = 0.0 if t < 0 else (1.0 if t > 1.0 else t)
    hue = int(round(120 * (1.0 - t)))      # 120° green -> 0° red
    return QColor.fromHsv(hue, 220, 230)


def _cpa_color(overtaking):
    """Accent colour for a route-CPA: green when overtaking, else blue."""
    return _CPA_GREEN if overtaking else _CPA_BLUE


def add_vaarwegen_layer(gpkg_path, layer_name="vaarwegen"):
    """Add the vaarwegen passages from ``gpkg_path`` as a quiet chart display
    layer: each individual stretch labelled with its own name (Wielingen, Honte,
    Vlissingen Rede, ...), thin, and only drawn when zoomed in past 1:12500.

    Display only — the route manager and binding run on the separate RWS route
    network. Idempotent: returns the existing layer if already added, else loads
    and styles it. Best-effort; returns the layer or None on failure."""
    from qgis.core import (
        QgsProject, QgsVectorLayer, QgsLineSymbol,
        QgsPalLayerSettings, QgsVectorLayerSimpleLabeling, QgsTextFormat,
        QgsTextBufferSettings)
    proj = QgsProject.instance()
    for l in proj.mapLayersByName(VAARWEGEN_LAYER_NAME):
        return l                                # already present
    lyr = QgsVectorLayer("%s|layername=%s" % (gpkg_path, layer_name),
                         VAARWEGEN_LAYER_NAME, "ogr")
    if not lyr.isValid():
        return None
    try:
        sym = QgsLineSymbol.createSimple(
            {"width": "0.26", "color": "90,120,150,170"})
        lyr.renderer().setSymbol(sym)
    except Exception:                          # noqa: BLE001  # nosec B110
        pass
    try:
        st = QgsPalLayerSettings()
        st.fieldName = "name"
        try:
            st.placement = _enum(QgsPalLayerSettings, "Placement", "Line")
        except Exception:  # nosec B110
            pass
        fmt = QgsTextFormat()
        fmt.setSize(8)
        buf = QgsTextBufferSettings()
        buf.setEnabled(True)
        buf.setSize(1.0)
        buf.setColor(QColor("white"))
        fmt.setBuffer(buf)
        st.setFormat(fmt)
        lyr.setLabeling(QgsVectorLayerSimpleLabeling(st))
        lyr.setLabelsEnabled(True)
    except Exception:                          # noqa: BLE001  # nosec B110
        pass
    try:
        lyr.setScaleBasedVisibility(True)
        lyr.setMinimumScale(12500.0)           # only drawn when zoomed in past 1:12500
        lyr.setMaximumScale(0.0)
    except Exception:                          # noqa: BLE001  # nosec B110
        pass
    proj.addMapLayer(lyr)
    lyr.triggerRepaint()
    return lyr


def marker_gpkg_path():
    """Path to the user marker store: markers.gpkg in the plugin's profile dir
    (user data — survives plugin upgrades, unlike the shipped data/ resources)."""
    from .guardian_store import default_base_dir
    return os.path.join(default_base_dir(), "markers.gpkg")


def _create_marker_gpkg(path):
    """Create an empty markers.gpkg (point layer 'markers' with a 'name' field)."""
    from osgeo import ogr, osr
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    ds = ogr.GetDriverByName("GPKG").CreateDataSource(path)
    srs = osr.SpatialReference()
    srs.ImportFromEPSG(4326)
    lyr = ds.CreateLayer("markers", srs, ogr.wkbPoint)
    lyr.CreateField(ogr.FieldDefn("name", ogr.OFTString))
    ds = None


def ensure_marker_layer():
    """Create (if needed) and add the editable 'Route reference markers' point
    layer, file-backed by markers.gpkg in the profile dir so the user's markers
    persist across sessions. The user digitises points and names them with QGIS's
    native editing; the route strip reads this layer live. Idempotent; returns the
    layer or None on failure."""
    from qgis.core import (QgsProject, QgsVectorLayer, QgsMarkerSymbol,
                           QgsPalLayerSettings, QgsVectorLayerSimpleLabeling,
                           QgsTextFormat, QgsTextBufferSettings)
    proj = QgsProject.instance()
    for l in proj.mapLayersByName(MARKER_LAYER_NAME):
        return l
    path = marker_gpkg_path()
    try:
        if not os.path.exists(path):
            _create_marker_gpkg(path)
    except Exception:                          # noqa: BLE001
        return None
    lyr = QgsVectorLayer(path + "|layername=markers", MARKER_LAYER_NAME, "ogr")
    if not lyr.isValid():
        return None
    try:                                       # a teal diamond + name label
        sym = QgsMarkerSymbol.createSimple(
            {"name": "diamond", "color": "0,150,136",
             "outline_color": "white", "size": "3.2"})
        lyr.renderer().setSymbol(sym)
        st = QgsPalLayerSettings()
        st.fieldName = "name"
        fmt = QgsTextFormat()
        fmt.setSize(8)
        buf = QgsTextBufferSettings()
        buf.setEnabled(True)
        buf.setSize(1.0)
        buf.setColor(QColor("white"))
        fmt.setBuffer(buf)
        st.setFormat(fmt)
        lyr.setLabeling(QgsVectorLayerSimpleLabeling(st))
        lyr.setLabelsEnabled(True)
    except Exception:                          # noqa: BLE001  # nosec B110
        pass
    proj.addMapLayer(lyr)
    return lyr


def read_markers(layer):
    """[{name, lon, lat}] from a point layer (markers are stored in EPSG:4326).

    Reads live, so points the user has added/edited in the current QGIS edit
    session are reflected without a commit."""
    out = []
    if layer is None:
        return out
    try:
        has_name = "name" in [f.name() for f in layer.fields()]
        for f in layer.getFeatures():
            g = f.geometry()
            if g is None or g.isEmpty():
                continue
            p = g.asPoint()
            out.append({"name": (f["name"] if has_name else "") or "",
                        "lon": p.x(), "lat": p.y()})
    except Exception:                          # noqa: BLE001
        return out
    return out


class _ZoomView(QGraphicsView):
    """QGraphicsView with wheel-zoom, drag-pan, and double-click routing."""

    def __init__(self, scene, on_scale=None, on_dblclick=None, on_click=None):
        """Build the view with antialiasing, drag-pan and mouse-anchored zoom,
        wiring optional scale/double-click/click callbacks."""
        super().__init__(scene)
        self.setRenderHint(_ANTIALIAS, True)
        self.setDragMode(_SCROLLDRAG)
        self.setTransformationAnchor(_ANCHOR_MOUSE)
        self._on_scale = on_scale
        self._on_dblclick = on_dblclick
        self._on_click = on_click
        self.zoom_enabled = True              # overview zooms; detail scrolls

    def wheelEvent(self, ev):
        """Overview: zoom about the cursor and report the new scale. Detail:
        zoom is disabled (the strip is sized to the panel), so the wheel just
        scrolls — useful for a long route that overflows the panel."""
        if not self.zoom_enabled:
            return super().wheelEvent(ev)
        f = 1.15 if ev.angleDelta().y() > 0 else 1.0 / 1.15
        self.scale(f, f)
        if self._on_scale:
            self._on_scale(self.transform().m11())

    def mousePressEvent(self, ev):
        """Forward the click (in scene coords) to the click callback, then pan."""
        if self._on_click:
            self._on_click(self.mapToScene(ev.pos()))
        super().mousePressEvent(ev)

    def mouseDoubleClickEvent(self, ev):
        """Forward a double-click (in scene coords) to open a route's strip view."""
        if self._on_dblclick:
            self._on_dblclick(self.mapToScene(ev.pos()))
        super().mouseDoubleClickEvent(ev)


class RouteMapWindow(QDialog):
    def __init__(self, matcher, points_provider, bindings_provider,
                 name_provider=None, parent=None):
        """Build the route-map dialog over a matcher's network. The three
        provider callables supply, live, the footprint points, the active
        bindings and an MMSI->name lookup. Detail-strip only: the route is chosen
        by clicking it on the on-canvas 'Routes in coverage' layer, which calls
        open_route(rid). Refreshes on a 2 s timer."""
        super().__init__(parent)
        self.setWindowTitle("Route leg")
        self.resize(1040, 740)
        self.matcher = matcher
        self.model = RouteMapModel(matcher.net) if matcher else None
        self._points = points_provider          # callable -> [(lon, lat)]
        self._bindings = bindings_provider       # callable -> [Binding]
        self._name_provider = name_provider      # callable mmsi -> name|None
        self._mode = "detail"                    # detail-strip only (no overview)
        self._detail_route = None
        self._name_items = []
        self._scale = 1.0
        self._cpa_select = False
        self._cpa_pick = []
        self._cpa_mmsis = None
        self._cpa = None
        self._vessel_items = []                  # [(mmsi, x, y, lon, lat)] on strip
        self._object_items = []                  # [(km, name, kind, x, y)] on strip
        self._obj_eta = None                     # vessel->bridge/lock arrival to draw
        self._cpa_bar = None                     # (cx, cy, lon, lat) of the CPA bar
        self._objects = []                       # bridges/locks on the open route
        self._obj_cache = {}                     # routeid -> [obj] (WFS fetch cache)
        self._portrait = False                   # False=landscape, True=portrait

        _refresh_palette()
        self.scene = QGraphicsScene(self)
        self.scene.setBackgroundBrush(QBrush(_BG))
        self.view = _ZoomView(self.scene, self._on_scale, self._on_dblclick,
                              self._on_click)
        try:                       # follow later day/dusk/night mode switches
            from . import ais_palette as _p
            _p.register_listener(self._on_palette_changed)
        except Exception:          # noqa: BLE001  # nosec B110
            pass
        try:                       # follow unit-system switches (km->nm etc.)
            from . import units as _u
            _u.register_listener(self._on_palette_changed)
        except Exception:          # noqa: BLE001  # nosec B110
            pass
        # Panel-fit 1:1 to start; the user can wheel-zoom and drag-pan from there
        # (the zoom is preserved across the periodic redraws).
        self.view.zoom_enabled = True

        bar = QHBoxLayout()
        self.btn_back = QPushButton("\u2190 Overview")
        self.btn_back.clicked.connect(self._to_overview)
        self.btn_back.setVisible(False)
        self.btn_cpa = QPushButton("Route CPA")
        self.btn_cpa.setToolTip("Click, then pick two vessels on the same route — "
                                "opposing (head-on) or same course (overtaking)")
        self.btn_cpa.clicked.connect(self._start_cpa)
        self.btn_recalc = QPushButton("Recalculate")
        self.btn_recalc.clicked.connect(self._recalc_cpa)
        self.btn_clear = QPushButton("Clear")
        self.btn_clear.clicked.connect(self._clear_cpa)
        for b in (self.btn_cpa, self.btn_recalc, self.btn_clear):
            b.setVisible(False)
        self.header = QLabel("Overview \u2014 double-click a fairway to open it")
        self.btn_rotate = QPushButton("\u27f3 Portrait")
        self.btn_rotate.setToolTip("Lay the route out vertically / horizontally "
                                   "(toggle portrait / landscape)")
        self.btn_rotate.clicked.connect(self._toggle_orientation)
        self.btn_rotate.setVisible(False)
        self.btn_refresh = QPushButton("Refresh")
        self.btn_refresh.clicked.connect(lambda: self._rebuild(fit=True))
        bar.addWidget(self.btn_cpa)
        bar.addWidget(self.btn_recalc)
        bar.addWidget(self.btn_clear)
        bar.addWidget(self.header)
        bar.addStretch(1)
        bar.addWidget(self.btn_rotate)
        bar.addWidget(self.btn_refresh)

        # Symbol key lives in a real label UNDER the view (not in the scene), so
        # it can never collide with the strip's vessel/object labels at any zoom.
        self.legend = QLabel("")
        self.legend.setWordWrap(True)
        self.legend.setStyleSheet("color:#666;font-size:11pt;padding:2px 4px;")

        lay = QVBoxLayout(self)
        lay.addLayout(bar)
        lay.addWidget(self.view)
        lay.addWidget(self.legend)

        self._timer = QTimer(self)
        self._timer.timeout.connect(self._tick)
        self._timer.start(2000)
        self._rebuild(fit=True)

    # ---- helpers --------------------------------------------------------- #
    def _pull(self):
        """Fetch live (footprint_points, bindings) from the providers, each
        defaulting to [] on any error so a refresh never throws."""
        try:
            pts = list(self._points() or [])
        except Exception:
            pts = []
        try:
            binds = list(self._bindings() or [])
        except Exception:
            binds = []
        return pts, binds

    def _scene_xy(self, lon, lat):
        """Project lon/lat to scene coordinates (y flipped, since scene y grows
        downward)."""
        x, y = self.matcher.net.to_m(lon, lat)
        return x, -y                          # flip: scene y grows downward

    def _on_scale(self, s):
        """Overview only (detail has wheel-zoom disabled): reveal route-name
        labels once zoomed in."""
        self._scale = s
        for it in self._name_items:
            it.setVisible(s >= _NAME_ZOOM)

    def resizeEvent(self, ev):
        """Re-fit the strip to the new panel size (the axis fills the panel)."""
        super().resizeEvent(ev)
        if self._mode == "detail":
            self._rebuild(fit=True)

    def _on_dblclick(self, scene_pt):
        """Overview double-click: find the route link under the point and open
        its detail strip."""
        if self._mode != "overview":
            return
        rid = None
        rect = QRectF(scene_pt.x() - 14, scene_pt.y() - 14, 28, 28)
        for it in self.scene.items(rect):
            d = it.data(0)
            if d is not None:
                rid = d
                break
        if rid is not None:
            self._open_detail(rid)

    # ---- mode switches --------------------------------------------------- #
    def _open_detail(self, rid):
        """Switch to the single-route strip view and reveal the CPA toolbar.
        Fetches the route's bridges/locks once (WFS), so the 2 s refresh redraws
        them without re-hitting the network."""
        self._mode = "detail"
        self._detail_route = rid
        self.btn_back.setVisible(True)
        self.btn_rotate.setVisible(True)
        for b in (self.btn_cpa, self.btn_recalc, self.btn_clear):
            b.setVisible(True)
        self.view.zoom_enabled = False        # strip is panel-sized; wheel scrolls
        self._objects = self._fetch_objects(rid) + self._fetch_markers(rid)
        self._rebuild(fit=True)

    def _to_overview(self):
        """Return to the schematic overview, hiding the CPA toolbar and exiting
        any in-progress vessel selection."""
        self._mode = "overview"
        self._detail_route = None
        self._objects = []
        self.btn_back.setVisible(False)
        self.btn_rotate.setVisible(False)
        for b in (self.btn_cpa, self.btn_recalc, self.btn_clear):
            b.setVisible(False)
        self.view.zoom_enabled = True
        self.view.resetTransform()            # leave the panel-sized 1:1 strip mode
        self._exit_select()
        self._rebuild(fit=True)

    def _fetch_objects(self, rid):
        """Bridges (brug) + locks (sluiskolk) on route `rid` from the FIS-VNDS
        WFS, server-side filtered by routeid so only the few on this route come
        back. Cached per route. Best-effort: any network/parse error (e.g.
        offline) yields [] so the strip still draws."""
        if rid in self._obj_cache:
            return self._obj_cache[rid]
        objs = []
        try:
            import urllib.request
            import urllib.parse
            import json
            for typename, kind in _WFS_LAYERS:
                q = urllib.parse.urlencode({
                    "service": "WFS", "version": "2.0.0",
                    "request": "GetFeature", "typeNames": typename,
                    "outputFormat": "application/json",
                    "CQL_FILTER": "routeid=%d" % int(rid)})
                url = "%s?%s" % (_WFS_BASE, q)
                if not url.lower().startswith("https://"):
                    continue                       # only ever our fixed HTTPS host
                with urllib.request.urlopen(url, timeout=8) as resp:   # nosec B310 - https only
                    data = json.loads(resp.read().decode("utf-8"))
                objs.extend(_parse_objects_geojson(data, kind))
        except Exception:                          # noqa: BLE001
            objs = []
        self._obj_cache[rid] = objs
        return objs

    def _fetch_markers(self, rid):
        """User reference markers that snap to route `rid`, as strip objects
        ``{name, kind:'marker', routeid, km}``. Read live from the marker layer
        (so the user's edits show on the next redraw) and snapped to route-km via
        the fairway network. Best-effort: any trouble yields []."""
        if self.model is None:
            return []
        out = []
        try:
            from qgis.core import QgsProject
            lyr = None
            for l in QgsProject.instance().mapLayersByName(MARKER_LAYER_NAME):
                lyr = l
                break
            for mk in read_markers(lyr):
                snap = self.model.snap_marker(mk["lon"], mk["lat"])
                if snap is None:
                    continue
                mrid, km, _off = snap
                if mrid == rid:
                    out.append({"name": mk["name"] or "marker", "kind": "marker",
                                "routeid": mrid, "km": km})
        except Exception:                      # noqa: BLE001
            return []
        return out

    # ---- route CPA ------------------------------------------------------- #
    def _bindings_map(self):
        """Current bindings keyed by MMSI (snapshot for CPA lookups)."""
        _, binds = self._pull()
        return {b.mmsi: b for b in binds}

    def _name_of(self, mmsi):
        """Vessel display name from the name provider, falling back to 'MMSI n'."""
        nm = None
        if self._name_provider:
            try:
                nm = self._name_provider(mmsi)
            except Exception:
                nm = None
        return nm or ("MMSI %s" % mmsi)

    def _start_cpa(self):
        """Enter route-CPA selection: disable panning and prompt for the first of
        two opposing vessels."""
        self._cpa_select = True
        self._cpa_pick = []
        try:
            self.view.setDragMode(_enum(QGraphicsView, "DragMode", "NoDrag"))
        except Exception:  # nosec B110
            pass
        self.header.setText("Route CPA \u2014 click a vessel, then a second vessel "
                            "(meeting) or a bridge, lock or marker (ETA)")

    def _exit_select(self):
        """Leave route-CPA selection mode and restore drag-pan."""
        self._cpa_select = False
        self._cpa_pick = []
        try:
            self.view.setDragMode(_SCROLLDRAG)
        except Exception:  # nosec B110
            pass

    def _click_zoom(self, scene_pt):
        """Not selecting: a click near a vessel zooms the main QGIS map to that
        ship; a click on the route-CPA bar zooms it to the meeting point."""
        best, bd = None, 1e18
        for mmsi, x, y, lon, lat in self._vessel_items:
            d = (x - scene_pt.x()) ** 2 + (y - scene_pt.y()) ** 2
            if d < bd:
                bd, best = d, (lon, lat)
        if best is not None and bd <= 60.0 ** 2 and best[0] is not None:
            self._zoom_main_map(best[0], best[1])
            return
        if self._cpa_bar is not None:
            cx, cy, blon, blat = self._cpa_bar
            if (cx - scene_pt.x()) ** 2 + (cy - scene_pt.y()) ** 2 <= 80.0 ** 2:
                self._zoom_main_map(blon, blat)

    def _zoom_main_map(self, lon, lat, scale=8000.0):
        """Recentre + zoom the main QGIS map canvas on a lon/lat point (WGS84),
        transformed to the canvas CRS, and flash it. Best-effort: a missing
        iface or transform never raises."""
        if lon is None or lat is None:
            return
        try:
            from qgis.utils import iface
            from qgis.core import (
                QgsCoordinateReferenceSystem, QgsCoordinateTransform,
                QgsProject, QgsPointXY, QgsGeometry)
            if iface is None:
                return
            canvas = iface.mapCanvas()
            src = QgsCoordinateReferenceSystem("EPSG:4326")
            dst = canvas.mapSettings().destinationCrs()
            pt = QgsPointXY(lon, lat)
            try:
                tr = QgsCoordinateTransform(src, dst, QgsProject.instance())
                pt = tr.transform(pt)
            except Exception:  # nosec B110
                pass
            canvas.setCenter(pt)
            try:
                canvas.zoomScale(scale)
            except Exception:  # nosec B110
                pass
            canvas.refresh()
            try:
                iface.mapCanvas().flashGeometries(
                    [QgsGeometry.fromPointXY(pt)], dst)
            except Exception:  # nosec B110
                pass
        except Exception:  # nosec B110
            pass

    def _on_click(self, scene_pt):
        """In the detail strip: during CPA selection, snap the click to the
        nearest vessel and (after two) compute the route-CPA. Otherwise a click
        on a vessel zooms the main QGIS map to that ship, and a click on the
        route-CPA bar zooms the main map to the CPA meeting point."""
        if self._mode != "detail":
            return
        if not self._cpa_select:
            self._click_zoom(scene_pt)
            return
        # nearest vessel OR bridge/lock marker to the click
        best, bd = None, 1e18
        for mmsi, x, y, _lon, _lat in self._vessel_items:
            d = (x - scene_pt.x()) ** 2 + (y - scene_pt.y()) ** 2
            if d < bd:
                bd, best = d, ("v", mmsi)
        for km, name, kind, ox, oy in self._object_items:
            d = (ox - scene_pt.x()) ** 2 + (oy - scene_pt.y()) ** 2
            if d < bd:
                bd, best = d, ("o", km, name, kind)
        if best is None or bd > 60.0 ** 2:
            return
        if best not in self._cpa_pick:
            self._cpa_pick.append(best)
        if len(self._cpa_pick) == 1:
            self.header.setText("Route CPA \u2014 now click the second vessel, "
                                "or a bridge, lock or marker")
        elif len(self._cpa_pick) >= 2:
            picks = list(self._cpa_pick)        # snapshot BEFORE _exit_select clears it
            self._exit_select()
            self._resolve_cpa_picks(picks)

    def _resolve_cpa_picks(self, picks):
        """Two targets picked: vessel+vessel -> route-CPA (meeting); vessel+object
        -> the vessel's ETA to that bridge/lock; anything else -> reprompt."""
        picks = picks[:2]
        vessels = [p for p in picks if p[0] == "v"]
        objects = [p for p in picks if p[0] == "o"]
        if len(vessels) == 2:
            self._obj_eta = None
            self._cpa_mmsis = (vessels[0][1], vessels[1][1])
            self._compute_cpa()
        elif len(vessels) == 1 and len(objects) == 1:
            self._cpa = None
            self._cpa_mmsis = None
            self._update_qgis_cpa(None)        # clear any vessel-vessel CPA
            self._compute_obj_eta(vessels[0][1], objects[0])
        else:
            self.header.setText("Route CPA \u2014 pick two vessels, or a vessel and a "
                                "bridge, lock or marker")
            self._cpa_pick = []

    def _compute_obj_eta(self, mmsi, opick):
        """A picked vessel's ETA to a picked bridge/lock (route-CPA vs a fixed
        object). Stashes _obj_eta for the strip and updates the header."""
        _tag, km_obj, name, kind = opick
        b = self._bindings_map().get(mmsi)
        eta = self.model.object_eta(b, km_obj) if b is not None else None
        if eta is None:
            self._obj_eta = None
            self.header.setText("ETA \u2014 %s is not approaching %s"
                                % (self._name_of(mmsi), name))
        else:
            import time as _t
            eta["epoch"] = _t.time() + eta["t_min"] * 60.0
            eta["mmsi"] = mmsi
            eta["vessel"] = self._name_of(mmsi)
            eta["obj_name"] = name
            eta["kind"] = kind
            self._obj_eta = eta
            ts = _t.strftime("%H:%M", _t.localtime(eta["epoch"]))
            from . import units
            self.header.setText("%s \u2192 %s: ETA %s (DTG %s)"
                                % (self._name_of(mmsi), name, ts,
                                   units.fmt_route(eta["dtg_km"])))
        self._rebuild(fit=False)

    def _obj_eta_timestr(self):
        """Object-ETA arrival as a 24-hour local clock (HH:MM)."""
        ep = (self._obj_eta or {}).get("epoch")
        if ep is None:
            return ""
        import time as _t
        return _t.strftime("%H:%M", _t.localtime(ep))

    def _detail_header(self, strip):
        """Header for the strip: route + km range, prefixed with the active
        route-CPA (vessel-vessel meeting) or object-ETA (vessel -> bridge/lock),
        so that status survives the periodic redraws (which rebuild the header)."""
        from . import units
        base = ("%s - %s to %s"
                % (strip["name"], units.fmt_route(strip["km_min"]),
                   units.fmt_route(strip["km_max"])))
        if self._obj_eta is not None:
            return ("%s -> %s: ETA %s (DTG %s)  |  %s"
                    % (self._obj_eta.get("vessel", ""),
                       self._obj_eta.get("obj_name", ""),
                       self._obj_eta_timestr(),
                       units.fmt_route(self._obj_eta.get("dtg_km", 0.0)), base))
        if self._cpa and self._cpa.get("routeid") == self._detail_route:
            kind = "overtaking" if self._cpa.get("overtaking") else "head-on"
            return ("Route CPA (%s) - meeting %s, %s  |  %s"
                    % (kind, self._cpa_timestr(),
                       units.fmt_route(self._cpa.get("km", 0.0)), base))
        return base

    def _compute_cpa(self):
        """Resolve the two picked MMSIs to live bindings, run the engine's
        route-CPA, stash the meeting time + which side each name sits, and
        redraw both the strip and the QGIS chart."""
        if not self._cpa_mmsis:
            return
        bm = self._bindings_map()
        a = bm.get(self._cpa_mmsis[0])
        b = bm.get(self._cpa_mmsis[1])
        if a is None or b is None:
            self.header.setText("Route CPA \u2014 a vessel is no longer in range")
            self._cpa = None
        else:
            self._cpa = self.model.route_cpa(a, b)
            if self._cpa is None:
                self.header.setText("Route CPA \u2014 vessels not closing on a "
                                    "shared route")
            else:
                import time as _t
                self._cpa["meet_epoch"] = _t.time() + self._cpa["t_min"] * 60.0
                # which way along the fairway the outbound ship travels, so the
                # two names land on opposite sides of the line
                bm2 = {a.mmsi: a, b.mmsi: b}
                ob = bm2.get(self._cpa["mmsi_out"])
                s = self.model._along_speed_kmh(ob) if ob is not None else None
                self._cpa["out_sign"] = 1 if (s is None or s >= 0) else -1
                self._cpa["out_name"] = self._name_of(self._cpa["mmsi_out"])
                self._cpa["in_name"] = self._name_of(self._cpa["mmsi_in"])
                kind = "overtaking" if self._cpa.get("overtaking") else "head-on"
                self.header.setText("Route CPA (%s) — meeting at %s, km %.1f"
                                    % (kind, self._cpa_timestr(),
                                       self._cpa["km"]))
        self._update_qgis_cpa(self._cpa)
        self._rebuild(fit=False)

    def _recalc_cpa(self):
        """Recompute the CPA for the same two vessels with fresh positions."""
        if self._cpa_mmsis:
            self._compute_cpa()

    def _clear_cpa(self):
        """Clear the current CPA / object-ETA and remove its chart layers."""
        self._cpa = None
        self._cpa_mmsis = None
        self._obj_eta = None
        self._exit_select()
        self._update_qgis_cpa(None)
        self._rebuild(fit=False)

    def _tick(self):
        """Timer refresh: redraw contents without changing the view transform."""
        # keep the current view transform; just refresh contents
        self._rebuild(fit=False)

    def _rebuild(self, fit=False):
        """Clear and redraw the detail strip; `fit` re-fits the view. With no
        route open yet, show a hint (the route is chosen by clicking it on the
        on-canvas 'Routes in coverage' layer)."""
        if self.model is None or self.matcher is None:
            return
        _refresh_palette()
        self.scene.clear()
        self.scene.setBackgroundBrush(QBrush(_BG))
        self._name_items = []
        if self._detail_route is None:
            self.header.setText("Pick a route in the Routes table to open its leg")
            self.legend.setText("")
            self.scene.addText("Pick a route in the Routes table to open its "
                               "leg here.")
            self._recolor_text()
            return
        _pts, binds = self._pull()
        self._draw_detail(self._detail_route, binds, fit)
        self._recolor_text()

    def _recolor_text(self):
        """Set every scene text item to the active palette's text colour, so labels
        stay readable when the background flips between day, dusk and night."""
        try:
            from qgis.PyQt.QtWidgets import QGraphicsTextItem
        except Exception:                          # noqa: BLE001
            return
        for it in self.scene.items():
            if isinstance(it, QGraphicsTextItem):
                try:
                    it.setDefaultTextColor(_TEXT)
                except Exception:                  # noqa: BLE001  # nosec B110
                    pass

    def _on_palette_changed(self):
        """Chart-light mode changed elsewhere — redraw the strip in the new palette
        if this window is currently shown."""
        try:
            if self.isVisible():
                self._rebuild(fit=False)
        except Exception:                          # noqa: BLE001  # nosec B110
            pass

    def open_route(self, rid):
        """Open a route's detail strip — called when a route is picked in the
        Routes table. Reveals the strip controls, fetches the route's
        bridges/locks + user markers, draws it, and shows/raises the window."""
        self._detail_route = rid
        self._mode = "detail"
        self.btn_rotate.setVisible(True)
        for b in (self.btn_cpa, self.btn_recalc, self.btn_clear):
            b.setVisible(True)
        self.view.zoom_enabled = True
        self._objects = self._fetch_objects(rid) + self._fetch_markers(rid)
        self.show()
        self.raise_()
        self.activateWindow()
        self._rebuild(fit=True)

    # ---- overview (octolinear schematic) --------------------------------- #
    _CELL = 150.0          # grid-cell size in scene units

    def _draw_overview(self, points, binds, fit):
        """Draw the octolinear schematic: per-route coloured links (name on
        hover), junction stations, and a live \u25b2out/\u25bcin chip per route."""
        self.header.setText("Schematic \u2014 hover a fairway for its name, "
                            "double-click to open it")
        self.legend.setText("Hover a fairway for its name; double-click to open "
                            "its strip. \u25b2 outbound \u00b7 \u25bc inbound count "
                            "per route.")
        sch = self.model.octolinear_schematic(points, radius_m=8000.0)
        pos = sch["pos"]
        if not pos:
            self.scene.addText("Waiting for vessels in range\u2026")
            return
        tal = self.model.tally(binds)
        C = self._CELL

        def S(g):
            """Octolinear grid cell -> scene coordinates (y flipped) (closure)."""
            return (g[0] * C, -g[1] * C)       # grid -> scene (flip y)

        # octolinear fairway links, coloured per route, name as hover tip
        route_mids = {}
        for (u, v, rid, name, gpts) in sch["edges"]:
            spts = [S(p) for p in gpts]
            path = QPainterPath(QPointF(*spts[0]))
            for sp in spts[1:]:
                path.lineTo(QPointF(*sp))
            pen = QPen(_route_color(rid))
            pen.setWidthF(11.0)
            pen.setCapStyle(_enum(Qt, "PenCapStyle", "RoundCap"))
            pen.setJoinStyle(_enum(Qt, "PenJoinStyle", "RoundJoin"))
            item = self.scene.addPath(path, pen)
            item.setData(0, rid)
            item.setToolTip(name)              # mouse-over map tip
            mid = spts[len(spts) // 2]
            route_mids.setdefault(rid, []).append(mid)
        # junction stations
        dot = QPen(_ENDPOINT); dot.setWidthF(4.0)
        for nid, g in pos.items():
            x, y = S(g)
            self.scene.addEllipse(x - 16, y - 16, 32, 32, dot,
                                  QBrush(_BG))
        # per-route live in/out chips (constant screen size)
        for rid, mids in route_mids.items():
            cx = sum(p[0] for p in mids) / len(mids)
            cy = sum(p[1] for p in mids) / len(mids)
            row = tal.get(rid, {"out": 0, "in": 0})
            if not (row.get("out") or row.get("in")):
                continue
            chip = QGraphicsTextItem()
            chip.setHtml(
                '<div style="font-size:9pt;font-weight:bold;background:white;">'
                '<span style="color:#e63946">\u25b2%d</span> '
                '<span style="color:#1d3557">\u25bc%d</span></div>'
                % (row.get("out", 0), row.get("in", 0)))
            chip.setPos(cx, cy - 10)
            chip.setFlag(_IGNORE_TF, True)
            self.scene.addItem(chip)
        if fit:
            self.view.fitInView(self.scene.itemsBoundingRect(), _KEEP_ASPECT)

    # ---- detail strip ---------------------------------------------------- #
    # Perpendicular extent of the strip (scene px): reserved for the lanes/ship
    # names on one side and the bridge/lock markers on the other.
    _PERP_OBJ = 460.0          # bridge/lock + outbound side (objects sit far out)
    _PERP_SHIP = 260.0         # inbound side

    def _draw_detail(self, rid, binds, fit):
        """Draw one route as a straight strip \u2014 horizontal (landscape) or vertical
        (portrait). Everything is placed in (along, perp) coordinates and mapped to
        the scene by orientation, so labels read normally either way. The axis is
        sized to fill the panel (min 500 px) at 1:1; a smaller panel scrolls."""
        strip = self.model.route_strip(rid, binds,
                                       min_sog_kn=_STRIP_MIN_SOG_KN)
        if strip is None:
            self.scene.addText("No route data")
            return
        self.header.setText(self._detail_header(strip))
        kmin, kmax = strip["km_min"], strip["km_max"]
        span = (kmax - kmin) or 1.0
        portrait = self._portrait

        # Strip content is laid out 1:1 to fill the panel's long side (floored at
        # 500 px). The view TRANSFORM (the user's wheel-zoom / drag-pan) is only
        # reset to that panel-fit on an explicit fit (open / refresh / resize /
        # rotate); the periodic tick redraws keep whatever zoom the user set.
        if fit:
            self.view.resetTransform()
        vp = self.view.viewport().size()
        margin = 90.0
        along_len = max(500.0, float((vp.height() if portrait else vp.width())
                                     - margin))
        A0 = 40.0
        A1 = A0 + along_len

        def A(km):
            """Route-km -> position ALONG the axis (closure)."""
            return A0 + (km - kmin) / span * (A1 - A0)

        def PT(along, perp):
            """(along, perp) -> scene (x, y) for the current orientation. perp<0
            is the outbound / objects side, perp>0 the inbound side (closure)."""
            return (perp, along) if portrait else (along, perp)

        axis = QPen(_AXIS); axis.setWidthF(4.0)
        self.scene.addLine(*PT(A0, 0.0), *PT(A1, 0.0), axis)
        # first + last junction + km label
        epen = QPen(_route_color(rid)); epen.setWidthF(4.0)
        from . import units as _u
        for km in (kmin, kmax):
            ex, ey = PT(A(km), 0.0)
            self.scene.addEllipse(ex - 9, ey - 9, 18, 18, epen,
                                  QBrush(_BG))
            t = self.scene.addText(_u.fmt_route(km))
            tx, ty = PT(A(km), 16.0)
            t.setPos(tx - 10, ty)
        # live vessels: predictable craft -> directional triangle in a lane
        # (outbound above, inbound below); unpredictable craft (fishing, pilot,
        # tug, dredger, law enforcement, yacht...) -> neutral circle ON the axis.
        # Marker colour is the SOG heat ramp (green 2 kn -> red 20 kn); the two
        # route-CPA vessels override to the CPA accent (blue head-on/green
        # overtaking). Names sit on the SAME side as the lane and stack when
        # neighbours crowd, to declutter.
        self._vessel_items = []
        self._cpa_bar = None
        bmap = {b.mmsi: b for b in binds}
        cpa_pair = set(self._cpa_mmsis or ())
        cpa_overtaking = bool(self._cpa and self._cpa.get("overtaking"))
        eta_mmsi = self._obj_eta.get("mmsi") if self._obj_eta else None
        sym = self._symbol_offsets(strip["vessels"], A)
        offsets, up_max, down_max = self._declutter_offsets(
            strip["vessels"], A, sym)
        for v in strip["vessels"]:
            a = A(v["km"])
            mx, my = PT(a, 0.0)
            b = bmap.get(v["mmsi"])
            vlon = getattr(b, "snap_lon", None) if b is not None else None
            vlat = getattr(b, "snap_lat", None) if b is not None else None
            self._vessel_items.append((v["mmsi"], mx, my, vlon, vlat))
            is_cpa = v["mmsi"] in cpa_pair
            is_eta = (eta_mmsi is not None and v["mmsi"] == eta_mmsi)
            if is_cpa:
                mcol = _cpa_color(cpa_overtaking)
            elif is_eta:
                mcol = _object_color(self._obj_eta.get("kind"))
            else:
                mcol = _sog_color(v.get("sog"))
            ncol = mcol.name() if (is_cpa or is_eta) else "#808080"
            off = offsets.get(v["mmsi"], _NAME_GAP_PX)
            if not v.get("predictable", True):
                self.scene.addEllipse(mx - 9, my - 9, 18, 18,
                                      QPen(QColor("white")), QBrush(mcol))
                self._add_name(a, "axis", v["mmsi"], ncol, off, portrait, PT)
                continue
            out = v["flow"] == "outbound"
            lane = sym.get(v["mmsi"], -_LANE_PX if out else _LANE_PX)
            d = 1 if (v.get("sense", 0) >= 0) else -1
            tri = QPolygonF([QPointF(*PT(a + d * 13, lane)),
                             QPointF(*PT(a - d * 7, lane - 9)),
                             QPointF(*PT(a - d * 7, lane + 9))])
            self.scene.addPolygon(tri, QPen(QColor("white")), QBrush(mcol))
            self._add_name(a, "outbound" if out else "inbound", v["mmsi"],
                           ncol, off, portrait, PT)
        # route CPA: thick line at the meeting km (green overtaking / blue
        # head-on), labelled with the 24-hour local meeting time
        if self._cpa and self._cpa.get("routeid") == rid:
            kmm = self._cpa["km"]
            if kmin <= kmm <= kmax:
                a = A(kmm)
                acc = _cpa_color(cpa_overtaking)
                pen = QPen(acc); pen.setWidthF(6.0)
                self.scene.addLine(*PT(a, -_CPA_HALF_PX), *PT(a, _CPA_HALF_PX),
                                   pen)
                # remember the bar so a click on it zooms the main map to the CPA
                cx, cy = PT(a, 0.0)
                self._cpa_bar = (cx, cy, self._cpa.get("lon"),
                                 self._cpa.get("lat"))
                lbl = QGraphicsTextItem()
                lbl.setHtml(
                    '<div style="font-size:10pt;color:%s;font-weight:bold;'
                    'background:white;">CPA %s</div>'
                    % (acc.name(), self._cpa_timestr()))
                lx, ly = PT(a, -_CPA_HALF_PX - 20)
                lbl.setPos(lx, ly)
                lbl.setFlag(_IGNORE_TF, True)
                self.scene.addItem(lbl)
        # fixed infrastructure (bridges/locks) for this route: a dotted line that
        # touches the route axis and runs far upward, named at the top — above
        # the ship labels — in the same orientation as the line.
        self._object_items = []
        for o in self._objects:
            if o.get("routeid") != rid:
                continue
            ok = o.get("km")
            if ok is None or not (kmin <= ok <= kmax):
                continue
            dot = self._add_object_marker(A(ok), o.get("name", ""),
                                          _object_color(o.get("kind")),
                                          portrait, PT)
            if dot is not None:
                self._object_items.append((ok, o.get("name", ""),
                                           o.get("kind"), dot[0], dot[1]))
        # vessel -> bridge/lock ETA (route-CPA against a fixed object): a label at
        # the object marker with the vessel's arrival time + distance-to-go.
        if self._obj_eta is not None:
            import time as _t
            ts = _t.strftime("%H:%M",
                             _t.localtime(self._obj_eta.get("epoch", _t.time())))
            for okm, oname, okind, ox, oy in self._object_items:
                if (abs(okm - self._obj_eta.get("km", -1e9)) < 1e-6
                        or oname == self._obj_eta.get("obj_name")):
                    lbl = QGraphicsTextItem()
                    lbl.setHtml(
                        '<div style="font-size:9pt;font-weight:bold;color:%s;'
                        'background:white;">%s ETA %s · %.1f km</div>'
                        % (_object_color(okind).name(),
                           html.escape(self._obj_eta.get("vessel", "")),
                           ts, self._obj_eta.get("dtg_km", 0.0)))
                    lbl.setPos(ox, oy)
                    lbl.setFlag(_IGNORE_TF, True)
                    self.scene.addItem(lbl)
                    break
        # symbol key: in the label UNDER the view, so it never collides with the
        # strip's vessel/object labels.
        from . import units as _u
        self.legend.setText(
            "outbound \u25b2  \u00b7  inbound \u25bc  \u00b7  \u25cf = unpredictable craft (on axis)  \u00b7  "
            "colour = speed (green %s \u2192 red %s)  \u00b7  \u22ee bridge (purple)  \u00b7  "
            "\u22ee lock (orange)  \u00b7  \u22ee marker (teal)  \u00b7  click a ship or the CPA bar "
            "to zoom the chart" % (_u.fmt_speed(2), _u.fmt_speed(20)))
        # Scene rect = the strip's extent (grown to fit dense, pushed-out name
        # clusters), so the view shows it 1:1 and grows scrollbars when needed.
        perp_up = max(self._PERP_OBJ, up_max + 24.0)     # outbound names + objects
        perp_down = max(self._PERP_SHIP, down_max + 24.0)  # inbound names
        if portrait:
            self.scene.setSceneRect(-perp_up, A0 - 20.0,
                                    perp_up + perp_down, along_len + 40.0)
        else:
            self.scene.setSceneRect(A0 - 20.0, -perp_up,
                                    along_len + 40.0, perp_up + perp_down)

    def _cpa_label_layer(self, proj, QgsVectorLayer, QgsPalLayerSettings,
                         QgsVectorLayerSimpleLabeling, accent=None):
        """Get/create the label layer: short line features oriented ALONG the
        fairway so line-labels read across the CPA line. Text colour follows the
        CPA accent (blue head-on / green overtaking) and is refreshed on every
        call; white halo for legibility over the chart."""
        accent = accent or _CPA_BLUE
        lyr = None
        for l in proj.mapLayersByName("Route CPA labels"):
            lyr = l
            break
        created = lyr is None
        if created:
            lyr = QgsVectorLayer("LineString?crs=EPSG:4326", "Route CPA labels",
                                 "memory")
            lyr.dataProvider().addAttributes([_str_field("label")])
            lyr.updateFields()
        try:
            from qgis.core import QgsTextFormat, QgsTextBufferSettings
            st = QgsPalLayerSettings()
            st.fieldName = "label"
            try:
                st.placement = _enum(QgsPalLayerSettings, "Placement", "Line")
            except Exception:  # nosec B110
                pass
            fmt = QgsTextFormat()
            fmt.setSize(11)
            fmt.setColor(accent)
            buf = QgsTextBufferSettings()
            buf.setEnabled(True)
            buf.setSize(1.0)
            buf.setColor(QColor("white"))
            fmt.setBuffer(buf)
            st.setFormat(fmt)
            lyr.setLabeling(QgsVectorLayerSimpleLabeling(st))
            lyr.setLabelsEnabled(True)
        except Exception:  # nosec B110
            pass
        if created:
            proj.addMapLayer(lyr)
        return lyr

    def _update_qgis_cpa(self, cpa):
        """Mirror the route-CPA in the QGIS chart: a thick line perpendicular to
        the fairway at the meeting point (blue head-on / green overtaking);
        'routeCPA' + the 24-hour meeting time at the top of that line; and the
        two ships each labelled with their name (or MMSI) on their own side of
        the fairway centre-line, with 'DTG : x.x km' (distance to go to the CPA
        point) beneath the name. The centre-line direction comes from the fairway
        linestring (cpa['bearing']), so the two names straddle the real axis."""
        try:
            import math as _m
            from qgis.core import (
                QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry,
                QgsPointXY, QgsLineSymbol, QgsPalLayerSettings,
                QgsVectorLayerSimpleLabeling)
        except Exception:
            return
        try:
            proj = QgsProject.instance()
            # Retire the old blue-ring highlight layer from earlier versions —
            # the highlight is now a symbology rule on the live AIS layer.
            for l in proj.mapLayersByName("Route CPA vessels"):
                try:
                    proj.removeMapLayer(l.id())
                except Exception:  # nosec B110
                    pass
            overtaking = bool(cpa.get("overtaking")) if cpa else False
            accent = _cpa_color(overtaking)
            # Highlight the two CPA vessels by writing their MMSIs to project
            # variables; the live AIS layer's symbology rule draws the blue dot.
            self._set_cpa_vessel_vars(
                cpa.get("mmsi_out") if cpa else None,
                cpa.get("mmsi_in") if cpa else None)
            line_lyr = None
            for l in proj.mapLayersByName("Route CPA"):
                line_lyr = l
                break
            lbl_lyr = self._cpa_label_layer(
                proj, QgsVectorLayer, QgsPalLayerSettings,
                QgsVectorLayerSimpleLabeling, accent)
            if cpa is None:
                for ly in (line_lyr, lbl_lyr):
                    if ly is not None:
                        try:
                            ly.dataProvider().truncate()
                            ly.triggerRepaint()
                        except Exception:  # nosec B110
                            pass
                return
            if line_lyr is None:
                line_lyr = QgsVectorLayer("LineString?crs=EPSG:4326",
                                          "Route CPA", "memory")
                proj.addMapLayer(line_lyr)
            # line colour follows the CPA accent (refreshed each call)
            try:
                sym = QgsLineSymbol.createSimple(
                    {"color": "%d,%d,%d" % (accent.red(), accent.green(),
                                            accent.blue()), "width": "1.2"})
                line_lyr.renderer().setSymbol(sym)
            except Exception:  # nosec B110
                pass
            # perpendicular line at the meeting point
            lon, lat, brg = cpa["lon"], cpa["lat"], cpa["bearing"]
            perp = _m.radians(brg + 90.0)
            d = 150.0
            coslat = _m.cos(_m.radians(lat)) or 1.0
            dlat = d * _m.cos(perp) / 111320.0
            dlon = d * _m.sin(perp) / (111320.0 * coslat)
            p1 = QgsPointXY(lon - dlon, lat - dlat)
            p2 = QgsPointXY(lon + dlon, lat + dlat)
            try:
                line_lyr.dataProvider().truncate()
            except Exception:  # nosec B110
                pass
            lf = QgsFeature(line_lyr.fields())
            lf.setGeometry(QgsGeometry.fromPolylineXY([p1, p2]))
            line_lyr.dataProvider().addFeatures([lf])
            line_lyr.updateExtents()
            line_lyr.triggerRepaint()
            # 'routeCPA' + time at the top of the line; the two ship names on
            # opposite sides of the fairway CENTRE-LINE (perpendicular offset),
            # each nudged along the axis toward its own approach so it sits clear
            # of the crossing line. Names + 'DTG : x.x km' beneath.
            ts = ""
            ep = cpa.get("meet_epoch")
            if ep is not None:
                import time as _t
                ts = _t.strftime("%H:%M", _t.localtime(ep))
            a_lon = _m.sin(_m.radians(brg)) / (111320.0 * coslat)   # along-axis/m
            a_lat = _m.cos(_m.radians(brg)) / 111320.0
            p_lon = _m.cos(_m.radians(brg)) / (111320.0 * coslat)   # across-axis/m
            p_lat = -_m.sin(_m.radians(brg)) / 111320.0
            sign = cpa.get("out_sign", 1)
            off_perp = 55.0      # across the fairway: each name on its own side
            nudge = 95.0         # along the fairway: clear of the crossing line
            half = 110.0         # label-segment half length (sets text baseline)

            def _seg(clon, clat):
                """Short line centred on (clon, clat) along the fairway axis, so
                a line-placed label reads across the CPA line (closure)."""
                return [QgsPointXY(clon - half * a_lon, clat - half * a_lat),
                        QgsPointXY(clon + half * a_lon, clat + half * a_lat)]

            top = p2 if p2.y() >= p1.y() else p1
            try:
                lbl_lyr.dataProvider().truncate()
            except Exception:  # nosec B110
                pass
            feats = []

            def _lbl(clon, clat, text):
                """Queue one axis-aligned label segment at (clon, clat) carrying
                `text`; no-op for empty text (closure)."""
                if not text:
                    return
                f = QgsFeature(lbl_lyr.fields())
                f.setGeometry(QgsGeometry.fromPolylineXY(_seg(clon, clat)))
                f.setAttribute("label", text)
                feats.append(f)

            def _name_dtg(nm, dtg_km):
                """'NAME' or 'NAME\\nDTG : x.x km' (closure)."""
                if not nm:
                    return ""
                if dtg_km is None:
                    return nm
                return "%s\nDTG : %.1f km" % (nm, dtg_km)

            def _side(perp_dir, along_dir, text):
                """Place a name on perpendicular side `perp_dir` (+1/-1), nudged
                `along_dir` along the axis (closure)."""
                clon = lon + perp_dir * off_perp * p_lon + along_dir * nudge * a_lon
                clat = lat + perp_dir * off_perp * p_lat + along_dir * nudge * a_lat
                _lbl(clon, clat, text)

            _lbl(top.x(), top.y(), "routeCPA\n%s" % ts)
            # outbound: +perp side, nudged opposite its travel (behind the line);
            # inbound: -perp side, nudged the other way — diagonally opposite.
            _side(+1, -sign, _name_dtg(cpa.get("out_name", ""),
                                       cpa.get("dtg_out_km")))
            _side(-1, +sign, _name_dtg(cpa.get("in_name", ""),
                                       cpa.get("dtg_in_km")))
            if feats:
                lbl_lyr.dataProvider().addFeatures(feats)
            lbl_lyr.updateExtents()
            lbl_lyr.triggerRepaint()
        except Exception:  # nosec B110
            pass

    def _set_cpa_vessel_vars(self, mmsi_one, mmsi_two):
        """Write the two route-CPA vessel MMSIs to the project variables
        @routeCPAVesselOne / @routeCPAVesselTwo (empty string clears them). The
        live AIS layer's symbology rule reads these to draw a blue dot on the
        matching targets. Best-effort: never raises."""
        try:
            from qgis.core import QgsProject, QgsExpressionContextUtils
            proj = QgsProject.instance()
            QgsExpressionContextUtils.setProjectVariable(
                proj, "routeCPAVesselOne", str(mmsi_one) if mmsi_one else "")
            QgsExpressionContextUtils.setProjectVariable(
                proj, "routeCPAVesselTwo", str(mmsi_two) if mmsi_two else "")
            # Flip the live layer's highlight rule on only while a CPA is active,
            # so the renderer skips its per-feature filter the rest of the time.
            from .vessel_layer import set_cpa_highlight_active
            set_cpa_highlight_active(bool(mmsi_one or mmsi_two))
        except Exception:                          # noqa: BLE001  # nosec B110
            pass

    def _cpa_timestr(self):
        """Meeting time as a 24-hour local clock (HH:MM)."""
        ep = (self._cpa or {}).get("meet_epoch")
        if ep is None:
            return ""
        import time as _t
        return _t.strftime("%H:%M", _t.localtime(ep))

    def _symbol_offsets(self, vessels, A):
        """Anti-collision for the ship-symbol TRIANGLES (mirrors the name
        declutter, but for the markers and ordered by speed). Where triangles
        crowd ALONG the axis they are pushed out PERPENDICULAR — on the same side
        as their lane — so they don't overlap. Within a crowded group the order is
        by speed: the SLOWEST stays closest to the line, the FASTEST goes furthest
        out. A triangle with no crowding sits at the base lane, so vessels drop
        back to the line as soon as they no longer overlap. Outbound and inbound
        lanes pack independently. Returns {mmsi: signed perpendicular px of the
        triangle centre}."""
        out = {}
        for side, sign in (("out", -1.0), ("in", 1.0)):
            items = []
            for v in vessels:
                if not v.get("predictable", True):
                    continue                   # unpredictable craft sit on the axis
                is_out = (v.get("flow") == "outbound")
                if (side == "out") == is_out:
                    sog = v.get("sog")
                    items.append((A(v["km"]), v["mmsi"],
                                  sog if sog is not None else 0.0))
            items.sort(key=lambda it: it[0])
            i = 0
            while i < len(items):
                j = i                          # extend the cluster while crowding
                while j + 1 < len(items) and \
                        (items[j + 1][0] - items[j][0]) <= _SYM_ALONG_PX:
                    j += 1
                cluster = items[i:j + 1]
                if len(cluster) == 1:
                    out[cluster[0][1]] = sign * _LANE_PX
                else:                          # slow -> near line, fast -> far out
                    for rank, (a, mmsi, sog) in enumerate(
                            sorted(cluster, key=lambda it: it[2])):
                        out[mmsi] = sign * (_LANE_PX + rank * _SYM_STEP_PX)
                i = j + 1
        return out

    def _declutter_offsets(self, vessels, A, sym=None):
        """Anti-collision label placement: a perpendicular offset (px from the
        axis to each name's NEAR edge) so names that crowd ALONG the axis are
        PUSHED OUT past one another instead of overlapping — like physical
        objects that can't intersect. Because the names are rotated, a name is
        thin along the axis but long perpendicular, so two names that are close in
        km must be stacked by the FULL length of the one before it. Outbound/
        on-axis names go to one side, inbound to the other; each side packs
        independently. Returns (offsets{mmsi:px}, up_max, down_max) where up/down
        max are the farthest extent used per side (to size the scene)."""
        from qgis.PyQt.QtGui import QFont, QFontMetricsF
        fm = QFontMetricsF(QFont("", 8, QFont.Bold))
        base = _NAME_GAP_PX
        between = 6.0
        along_thresh = 16.0          # names within this along-gap can collide
        sym = sym or {}
        offsets = {}
        side_max = {"a": base, "b": base}
        for side in ("a", "b"):      # "a" = outbound/on-axis side, "b" = inbound
            items = []
            for v in vessels:
                a_side = (v.get("flow") != "inbound")
                if (side == "a") == a_side:
                    w = fm.horizontalAdvance(self._name_of(v["mmsi"])) + 8.0
                    # a name must clear its own (possibly pushed-out) triangle
                    floor = abs(sym.get(v["mmsi"], 0.0)) + _NAME_BEYOND_PX
                    items.append((A(v["km"]), v["mmsi"], w, max(base, floor)))
            items.sort()
            last_a = None
            free = base              # next free near-edge within the current cluster
            for a, mmsi, w, vbase in items:
                if last_a is None or (a - last_a) > along_thresh:
                    cur = vbase      # far enough along: sit just past its triangle
                else:
                    cur = max(free, vbase)   # crowds: stack beyond previous + triangle
                offsets[mmsi] = cur
                free = cur + w + between
                side_max[side] = max(side_max[side], cur + w)
                last_a = a
        return offsets, side_max["a"], side_max["b"]

    def _add_name(self, a, side, mmsi, color_hex, offset_px, portrait, pt):
        """Vessel name (or MMSI) on the SAME side as the vessel's lane: outbound/
        on-axis on the perp- side, inbound on the perp+ side. In LANDSCAPE the
        name reads upward (rotated -90, same as the bridge/lock labels); in
        PORTRAIT it reads horizontally (rotation 0) so it stays normally readable.
        Centred on the marker along the axis; `offset_px` is the anti-collision
        near-edge distance from the axis (see _declutter_offsets)."""
        t = QGraphicsTextItem()
        # escape: an AIS shipname can contain < > & and is untrusted
        t.setHtml('<div style="font-size:8pt;font-weight:bold;color:%s;">%s</div>'
                  % (color_hex, html.escape(self._name_of(mmsi))))
        t.setFlag(_IGNORE_TF, True)
        br = t.boundingRect()
        w, h = br.width(), br.height()
        gap = offset_px
        inbound = (side == "inbound")
        along_anchor = a - h / 2.0             # centre the text along the axis
        if portrait:
            t.setRotation(0)                   # horizontal, readable
            perp = gap if inbound else -(gap + w)
        else:
            t.setRotation(-90)                 # vertical, reading upward
            perp = (gap + w) if inbound else -gap
        x, y = pt(along_anchor, perp)
        t.setPos(x, y)
        self.scene.addItem(t)

    def _add_object_marker(self, a, name, color, portrait, pt):
        """A fixed object (bridge/lock): a dotted line that touches the axis and
        runs out (on the perp- / objects side, clear of the ship labels) to the
        object name, capped with a small dot then a gap then the name. The name
        reads upward in landscape and horizontally in portrait. Purple = bridge,
        orange = lock."""
        t = QGraphicsTextItem()
        t.setHtml('<div style="font-size:8pt;font-weight:bold;color:%s;">%s</div>'
                  % (color.name(), html.escape(name)))
        t.setFlag(_IGNORE_TF, True)
        br = t.boundingRect()
        w, h = br.width(), br.height()
        along_anchor = a - h / 2.0
        if portrait:
            t.setRotation(0)                   # horizontal, readable
            perp = -(_OBJ_NAME_PX + w)         # name ends at -_OBJ_NAME_PX
        else:
            t.setRotation(-90)                 # reading upward, along the line
            perp = -_OBJ_NAME_PX
        nx, ny = pt(along_anchor, perp)
        t.setPos(nx, ny)
        # dotted line from the axis out to just before the name, capped with a dot.
        dot_perp = -(_OBJ_NAME_PX - 12.0)
        pen = QPen(color); pen.setWidthF(2.0); pen.setStyle(_DOTLINE)
        self.scene.addLine(*pt(a, 0.0), *pt(a, dot_perp), pen)
        dx, dy = pt(a, dot_perp)
        r = 3.0
        self.scene.addEllipse(dx - r, dy - r, 2 * r, 2 * r,
                              QPen(color), QBrush(color))
        self.scene.addItem(t)
        return dx, dy                          # the dot — a pick point for ETA

    def _toggle_orientation(self):
        """Toggle the strip between landscape (horizontal axis) and portrait
        (vertical axis). This is a real re-layout — not a view rotation — so the
        labels stay normally readable in both orientations."""
        self._portrait = not self._portrait
        self.btn_rotate.setText("⟳ Landscape" if self._portrait else "⟳ Portrait")
        self._rebuild(fit=True)

    def closeEvent(self, ev):
        """Stop the refresh timer and drop the palette listener when the window
        closes."""
        try:
            self._timer.stop()
        except Exception:  # nosec B110
            pass
        try:
            from . import ais_palette as _p
            _p.unregister_listener(self._on_palette_changed)
        except Exception:                          # noqa: BLE001  # nosec B110
            pass
        try:
            from . import units as _u
            _u.unregister_listener(self._on_palette_changed)
        except Exception:                          # noqa: BLE001  # nosec B110
            pass
        super().closeEvent(ev)
