"""Native (non-docked) dialog for discovering and connecting to AIS/NMEA sources.

This is a modeless QDialog: it floats as its own top-level OS window rather than
docking into a QGIS panel area, which is the "native, not docked" behaviour
requested. The plugin keeps a reference to it so Python doesn't garbage-collect
the window while it's open.
"""
from qgis.PyQt.QtGui import QFont
from qgis.PyQt.QtCore import pyqtSignal, QTimer
import time
import os
from qgis.PyQt.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QComboBox,
    QPushButton, QPlainTextEdit, QCheckBox, QGroupBox,
    QRadioButton, QButtonGroup, QLineEdit, QSpinBox,
)

from .stream_health import StreamHealth
from .stream_graph import StreamRateGraph

from qgis.core import QgsProject, QgsExpressionContextUtils

from .serial_reader import SerialReader, list_serial_ports, PYSERIAL_AVAILABLE
from .tcp_reader import TcpReader
from .nmea_utils import (is_valid_nmea, classify, strip_tag_block,
                         is_gps_sentence, parse_gps_fix)
from .port_scanner import PortScanner
from .ais_decoder import AISAssembler, PYAIS_AVAILABLE
from .vessel_layer import VesselLayer
from .vessel_registry import VesselRegistry
from .trail_layer import TrailLayer
from . import ais_filters
from . import internet_sources
from . import my_fleet
from . import vendor

vendor.setup()

BAUD_RATES = ["4800", "9600", "38400", "115200"]  # 38400 is the AIS default
MAX_LOG_BLOCKS = 500  # cap the live feed so memory stays bounded
# Binding-engine (map-matcher) logic version, stamped on every history session so
# historic bindings stay attributable across upgrades / multi-node aggregation.
HISTORY_LOGIC_VERSION = "match-1"
# Drop a target (and its route binding) after this long with no position report.
STALE_TARGET_S = 1200.0        # 20 minutes
# The feed counts as "in coverage" if any sentence arrived this recently; a stale
# target removed while in coverage is a genuine 'lost target' (logged), whereas
# one removed during a feed outage is simply out of coverage (silent).
FEED_LIVE_WINDOW_S = 120.0
# speedvector dropdown: label -> minutes written to @AIS_speedVector
SPEEDVECTOR_OPTIONS = [("none", 0), ("1 min", 1), ("3 min", 3), ("6 min", 6)]
SPEEDVECTOR_VAR = "AIS_speedVector"
# plot dropdown: which vessels to draw on the map
PLOT_OPTIONS = [("all vessels", "all"), ("my fleet", "fleet"), ("off", "off")]
CHARTLIGHT_OPTIONS = [("Day", "day"), ("Dusk", "dusk"), ("Night", "night")]
UNIT_OPTIONS = [("Nautical", "nautical"), ("Metric", "metric"),
                ("Imperial", "imperial")]
# bearing dropdown: label -> field name written to @AIS_speedVectorBearing
SPEEDVECTOR_BEARING_OPTIONS = [("COG", "vessel_courseOverGround"),
                               ("heading", "vessel_heading")]
SPEEDVECTOR_BEARING_VAR = "AIS_speedVectorBearing"
# symbol amplifier: label -> factor written to @AIS_symbolAmplify (SVG mode)
SYMBOL_AMPLIFY_OPTIONS = [("x1", 1), ("x2", 2), ("x5", 5), ("x10", 10)]
SYMBOL_AMPLIFY_VAR = "AIS_symbolAmplify"
# trails: label -> number of last fixes drawn as a red dotted line (0 = off)
TRAIL_OPTIONS = [("off", 0), ("5", 5), ("10", 10), ("20", 20)]
TRAIL_VAR = "AIS_trail"
# default perpendicular bind tolerance (m) from a route centreline; per-route
# overrides (e.g. wide tidal fairways) are set in the Routes table and persisted.
DEFAULT_BIND_TOL_M = 150.0


class StreamWidget(QWidget):
    """The AIS/NMEA stream UI: source pick, connect, live feed, portrayal.

    Lives as tab 3 of the Guardian dock. Emits feedLiveChanged(bool) so the
    Guardian tab's green 'feed live' light can follow the connection state.
    """
    feedLiveChanged = pyqtSignal(bool)
    setOwnPositionRequested = pyqtSignal()
    ownPositionFix = pyqtSignal(float, float)  # first GNSS fix on the feed -> seed
    aisRecord = pyqtSignal(object)      # every decoded record -> Guardian engine
    streamHealthReport = pyqtSignal(int, float)   # (num_outages, mean_downtime_s)
    fullStopToggled = pyqtSignal()      # Full stop / resume button
    lostTarget = pyqtSignal(object)     # (mmsi, lon, lat) removed while in coverage
    sourceActivated = pyqtSignal()      # active source changed (connect/disconnect)

    def __init__(self, iface, parent=None):
        """Build the stream UI and its helpers (decoder, registry, vessel/trail
        layers, lazy fairway matcher) and start the 1 Hz rate sampler."""
        super().__init__(parent)
        self.iface = iface
        # Pick a performance profile from a one-time render benchmark of this box
        # (persisted, so it only benchmarks on first open). Sets the render-tuning
        # defaults read below (repaint interval, symbol/label scale, batch rates,
        # area radius). Best-effort — falls back to the medium profile.
        try:
            from . import perf_profile
            perf_profile.detect()
        except Exception:                # noqa: BLE001  # nosec B110
            pass
        self.reader = None
        self.scanner = None
        self._gnss_seed_emitted = False  # emit only the first GNSS fix per feed
        self.assembler = AISAssembler()
        self.registry = VesselRegistry()
        self.vessels = VesselLayer(registry=self.registry)
        self.matcher = None              # fairway map-matcher (lazy-built)
        self._fairway_failed = False
        self._vaarwegen_added = False    # chart display layer added once
        self._markers_added = False      # user reference-marker layer added once
        self._custom_added = False       # custom-routes layer added once
        self._custom_dirty = True        # custom routes need (re)injecting
        self._route_tol = self._load_route_tol()   # {routeid: bind tolerance m}
        self._route_map_win = None
        self._history = None             # optional voyage-history recorder (lazy)
        self._history_cfg = None
        self._last_label_t = 0.0         # throttle the cosmetic decoded-preview line
        self._render_timer = None        # live map-render watch (lazy: needs canvas)
        self._render_downgraded = False  # one-shot safety profile drop already done
        self._hist_lastgood = {}         # mmsi -> (t,lon,lat): teleport-gate ref
        self._hist_filtered = 0          # fixes rejected by the ingest filters
        self._fleet = my_fleet.mmsis()   # cached 'my fleet' MMSIs (plot filter)
        self._retired = []               # readers being torn down (held until finished)
        self.trails = TrailLayer()
        self._last_rx = 0.0              # wall-clock of the last received sentence
        self._ports = []
        self._total = self._valid = self._ais = 0

        self.health = StreamHealth()
        self._last_total_sample = 0

        self._build_ui()
        self.refresh_ports()

        # 1 Hz stream-rate sampler -> health classifier + sparkline
        self._rate_timer = QTimer(self)
        self._rate_timer.setInterval(1000)
        self._rate_timer.timeout.connect(self._sample_rate)
        self._rate_timer.start()

        # ~10 s auto-refresh of the on-canvas in-coverage routes layer (+ arm the
        # click-to-open filter). No-ops until the fairway matcher exists, so it
        # never forces a build / never hangs the GUI at startup.
        self._routes_timer = QTimer(self)
        self._routes_timer.setInterval(10000)
        self._routes_timer.timeout.connect(self._refresh_routes)
        self._routes_timer.timeout.connect(self._sweep_stale_targets)
        self._routes_timer.start()

    def _sample_rate(self):
        """1 Hz tick: feed the per-second message delta to the health classifier,
        update the sparkline, and emit any hourly stream-health report."""
        now = time.time()
        delta = self._total - self._last_total_sample
        self._last_total_sample = self._total
        if delta < 0:
            delta = 0
        self.health.sample(delta, now)
        # Rate-gate the map writes: a high-rate feed coalesces per-vessel writes
        # and flushes them on the repaint tick (invisible — the map only repaints
        # every ~0.7 s anyway); a normal feed writes immediately ("full whack").
        # Dead band (60 on / 40 off msg/s) keeps it from flapping at the boundary.
        try:
            from . import perf_profile
            if delta > (perf_profile.get("batch_on") or 60):
                self.vessels.set_batch_mode(True)
            elif delta < (perf_profile.get("batch_off") or 40):
                self.vessels.set_batch_mode(False)
        except Exception:               # noqa: BLE001  # nosec B110
            pass
        try:
            self.rate_graph.update_from(self.health)
        except Exception:  # nosec B110
            pass
        rep = self.health.hourly_report(now)
        if rep is not None:
            self.streamHealthReport.emit(rep[0], rep[1])
        if self._history is not None:
            self._update_history_status()
        if self._plot_mode() == "fleet":
            self._reload_fleet()        # reflect fleet edits in the plot filter

    # ---- live render watch ----------------------------------------------
    def _ensure_render_timer(self):
        """Arm the live map-render watch once (needs the canvas). Measures the real
        render pass and, if this box is clearly struggling under real load, drops
        the performance profile one step — once, one-way (never a live governor)."""
        if self._render_timer is not None:
            return
        try:
            canvas = self.iface.mapCanvas()
        except Exception:               # noqa: BLE001
            canvas = None
        if canvas is None:
            return
        try:
            from .render_timer import RenderTimer
            self._render_timer = RenderTimer(canvas, on_slow=self._on_render_slow)
        except Exception:               # noqa: BLE001
            self._render_timer = None

    def _on_render_slow(self, median_ms):
        """The real canvas render is sustained-slow for the current profile: take
        one safety step down (high -> medium -> low) so the repaint throttle eases
        and dots/labels kick in earlier. Session-only; done at most once."""
        if self._render_downgraded:
            return
        try:
            from . import perf_profile
            order = ["high", "medium", "low"]
            cur = perf_profile.name()
            if cur not in order or cur == "low":
                self._render_downgraded = True   # already at the floor
                return
            nxt = order[order.index(cur) + 1]
            perf_profile.set_profile(nxt, persist=False)   # this session only
            self._render_downgraded = True
            try:                                 # re-apply the lighter symbology now
                self.vessels.refresh_scale_symbology()
            except Exception:                    # noqa: BLE001  # nosec B110
                pass
            self.status_label.setText(
                "⚙ Heavy render (%d ms) — eased to the '%s' performance profile."
                % (int(median_ms), nxt))
        except Exception:                        # noqa: BLE001  # nosec B110
            pass

    # ---- UI construction -------------------------------------------------
    def _build_ui(self):
        """Lay out the whole tab: source picker (serial/TCP), connect controls,
        the Settings box (portrayal + route map + own position), status line,
        rate sparkline and the live NMEA log."""
        layout = QVBoxLayout(self)

        src_box = QGroupBox("AIS source")
        sbv = QVBoxLayout(src_box)

        # source-type toggle: USB/Serial vs Network (TCP)
        type_row = QHBoxLayout()
        self.rb_serial = QRadioButton("USB / Serial")
        self.rb_serial.setChecked(True)
        self.rb_tcp = QRadioButton("Network (TCP)")
        self.rb_internet = QRadioButton("Internet")
        self._src_group = QButtonGroup(self)
        self._src_group.addButton(self.rb_serial)
        self._src_group.addButton(self.rb_tcp)
        self._src_group.addButton(self.rb_internet)
        type_row.addWidget(self.rb_serial)
        type_row.addWidget(self.rb_tcp)
        type_row.addWidget(self.rb_internet)
        type_row.addStretch(1)
        sbv.addLayout(type_row)
        self.rb_serial.toggled.connect(self._on_source_type)
        self.rb_internet.toggled.connect(self._on_source_type)

        # --- serial panel ---
        self.serial_panel = QWidget()
        grid = QGridLayout(self.serial_panel)
        grid.setContentsMargins(0, 0, 0, 0)
        grid.addWidget(QLabel("Port:"), 0, 0)
        self.port_combo = QComboBox()
        self.port_combo.currentIndexChanged.connect(self._update_details)
        grid.addWidget(self.port_combo, 0, 1)
        self.refresh_btn = QPushButton("Refresh")
        self.refresh_btn.clicked.connect(self.refresh_ports)
        grid.addWidget(self.refresh_btn, 0, 2)

        grid.addWidget(QLabel("Baud:"), 1, 0)
        self.baud_combo = QComboBox()
        self.baud_combo.addItems(BAUD_RATES)
        self.baud_combo.setCurrentText("38400")
        grid.addWidget(self.baud_combo, 1, 1)

        self.details_label = QLabel("")
        self.details_label.setWordWrap(True)
        grid.addWidget(self.details_label, 2, 0, 1, 3)
        sbv.addWidget(self.serial_panel)

        # --- network (TCP) panel ---
        self.tcp_panel = QWidget()
        tgrid = QGridLayout(self.tcp_panel)
        tgrid.setContentsMargins(0, 0, 0, 0)
        tgrid.addWidget(QLabel("Host:"), 0, 0)
        self.host_edit = QLineEdit()
        self.host_edit.setPlaceholderText("e.g. 10.0.0.212  (host or IP)")
        tgrid.addWidget(self.host_edit, 0, 1)
        tgrid.addWidget(QLabel("Port:"), 1, 0)
        self.tcpport_edit = QLineEdit()
        self.tcpport_edit.setPlaceholderText("e.g. 9996")
        tgrid.addWidget(self.tcpport_edit, 1, 1)
        self.tcp_hint = QLabel("Connects directly to a TCP NMEA stream "
                               "(serial-device server / multiplexer).")
        self.tcp_hint.setWordWrap(True)
        tgrid.addWidget(self.tcp_hint, 2, 0, 1, 2)
        self.tcp_panel.setVisible(False)
        sbv.addWidget(self.tcp_panel)

        # --- internet-sources panel ---
        self.internet_panel = QWidget()
        igrid = QGridLayout(self.internet_panel)
        igrid.setContentsMargins(0, 0, 0, 0)
        igrid.addWidget(QLabel("Service:"), 0, 0)
        self.service_combo = QComboBox()
        for s in internet_sources.SERVICES:
            self.service_combo.addItem(s["name"], s["id"])
        self.service_combo.currentIndexChanged.connect(
            self._on_internet_service_changed)
        igrid.addWidget(self.service_combo, 0, 1, 1, 2)

        self.apikey_label = QLabel("API key:")
        self.apikey_edit = QLineEdit()
        self.apikey_edit.setPlaceholderText("free key from the service")
        self.apikey_edit.setEchoMode(QLineEdit.PasswordEchoOnEdit)
        self.getkey_btn = QPushButton("Get key")
        self.getkey_btn.setToolTip("Open the service's website to sign up for a "
                                   "free API key")
        self.getkey_btn.clicked.connect(self._open_service_signup)
        igrid.addWidget(self.apikey_label, 1, 0)
        igrid.addWidget(self.apikey_edit, 1, 1)
        igrid.addWidget(self.getkey_btn, 1, 2)

        self.radius_label = QLabel("Area radius (nm):")
        self.radius_spin = QSpinBox()
        self.radius_spin.setRange(5, 500)
        try:                                    # profile-driven default (laptop=20)
            from . import perf_profile
            self.radius_spin.setValue(int(perf_profile.get("default_radius_nm") or 20))
        except Exception:                       # noqa: BLE001
            self.radius_spin.setValue(20)
        self.radius_spin.setToolTip(
            "Half-size of the box requested around your own position. 20 nm keeps "
            "the vessel count (and CPU) sane on a laptop; widen it for a bigger area.")
        igrid.addWidget(self.radius_label, 2, 0)
        igrid.addWidget(self.radius_spin, 2, 1)

        self.internet_hint = QLabel("")
        self.internet_hint.setWordWrap(True)
        igrid.addWidget(self.internet_hint, 3, 0, 1, 3)
        self.internet_panel.setVisible(False)
        sbv.addWidget(self.internet_panel)

        layout.addWidget(src_box)

        ctrl = QHBoxLayout()
        self.connect_btn = QPushButton("Connect")
        self.connect_btn.clicked.connect(self.toggle_connection)
        ctrl.addWidget(self.connect_btn)
        self.fullstop_btn = QPushButton("Full stop")
        self.fullstop_btn.setToolTip(
            "Halt ALL plugin processing — feed, timers and layer updates — so the "
            "plugin sits idle in the background (you can then delete the AIS "
            "layers if you like). Press again to repopulate the layers and resume."
        )
        self.fullstop_btn.clicked.connect(self.fullStopToggled)
        ctrl.addWidget(self.fullstop_btn)
        self.detect_btn = QPushButton("Auto-detect")
        self.detect_btn.setToolTip(
            "Scan all ports and baud rates and connect to the AIS feed it finds"
        )
        self.detect_btn.clicked.connect(self.auto_detect)
        ctrl.addWidget(self.detect_btn)
        self.ais_only_chk = QCheckBox("Show AIS only")
        ctrl.addWidget(self.ais_only_chk)
        ctrl.addStretch(1)
        layout.addLayout(ctrl)
        # (Plot control moved into the Settings box below, as a dropdown.)

        # Settings, laid out in three bands with no ragged dead space: the value
        # pickers on top (two balanced columns whose fields stretch to the edges),
        # the full-width 'Set own position' button in the middle, and the on/off
        # toggles along the bottom. Columns: 0/3 = labels, 1/4 = fields (stretch),
        # 2 = a small gutter between the two pairs.
        portrayal_box = QGroupBox("Settings")
        pgrid = QGridLayout(portrayal_box)
        pgrid.setHorizontalSpacing(8)
        pgrid.setVerticalSpacing(6)
        pgrid.setColumnMinimumWidth(2, 16)      # gutter between the two picker pairs
        pgrid.setColumnStretch(1, 1)            # left field fills its half
        pgrid.setColumnStretch(4, 1)            # right field fills its half

        # --- pickers (top band) ---------------------------------------------
        pgrid.addWidget(QLabel("vectorlength"), 0, 0)
        self.speedvector_combo = QComboBox()
        for label, _ in SPEEDVECTOR_OPTIONS:
            self.speedvector_combo.addItem(label)
        self.speedvector_combo.setToolTip(
            "Minutes of look-ahead written to the project variable "
            "@AIS_speedVector (0 = none)"
        )
        self.speedvector_combo.currentIndexChanged.connect(
            self._on_speedvector_changed
        )
        pgrid.addWidget(self.speedvector_combo, 0, 1)

        pgrid.addWidget(QLabel("symbol amplifier"), 0, 3)
        self.amplify_combo = QComboBox()
        for label, _ in SYMBOL_AMPLIFY_OPTIONS:
            self.amplify_combo.addItem(label)
        self.amplify_combo.setToolTip(
            "Multiplies the SVG ship symbol's length and width "
            "(@AIS_symbolAmplify); applies in SVG portrayal mode"
        )
        self.amplify_combo.currentIndexChanged.connect(self._on_amplify_changed)
        pgrid.addWidget(self.amplify_combo, 0, 4)

        pgrid.addWidget(QLabel("vectorsource"), 1, 0)
        self.bearing_combo = QComboBox()
        for label, _ in SPEEDVECTOR_BEARING_OPTIONS:
            self.bearing_combo.addItem(label)
        self.bearing_combo.setToolTip(
            "Field used for the speed-vector bearing, written as a field name "
            "to the project variable @AIS_speedVectorBearing"
        )
        self.bearing_combo.currentIndexChanged.connect(self._on_bearing_changed)
        pgrid.addWidget(self.bearing_combo, 1, 1)

        pgrid.addWidget(QLabel("trails"), 1, 3)
        self.trails_combo = QComboBox()
        for label, _ in TRAIL_OPTIONS:
            self.trails_combo.addItem(label)
        self.trails_combo.setToolTip(
            "Draw a red dotted trail through each vessel's last N transponder "
            "fixes (off = no trail)"
        )
        self.trails_combo.currentIndexChanged.connect(self._on_trails_changed)
        pgrid.addWidget(self.trails_combo, 1, 4)

        pgrid.addWidget(QLabel("plot vessels"), 2, 0)
        self.plot_combo = QComboBox()
        for label, _ in PLOT_OPTIONS:
            self.plot_combo.addItem(label)
        self.plot_combo.setToolTip(
            "Which vessels to draw on the 'liveFeedAIS' map layer: all, only your "
            "fleet (Vessels tab → My fleet), or none")
        self.plot_combo.currentIndexChanged.connect(self._on_plot_mode_changed)
        pgrid.addWidget(self.plot_combo, 2, 1)

        pgrid.addWidget(QLabel("chart light"), 2, 3)
        self.chartlight_combo = QComboBox()
        self._chartlight_codes = [c for _, c in CHARTLIGHT_OPTIONS]
        for label, _ in CHARTLIGHT_OPTIONS:
            self.chartlight_combo.addItem(label)
        self.chartlight_combo.setToolTip(
            "Day / Dusk / Night display: dims the map canvas and basemap, switches "
            "the QGIS colour theme, and re-tints the labels and the route-leg strip")
        try:
            from . import ais_palette
            self.chartlight_combo.setCurrentIndex(
                self._chartlight_codes.index(ais_palette.current_mode()))
        except Exception:                          # noqa: BLE001  # nosec B110
            pass
        self.chartlight_combo.currentIndexChanged.connect(self._on_chartlight_changed)
        pgrid.addWidget(self.chartlight_combo, 2, 4)
        # restore the persisted mode once the panel/project are up (canvas + basemap
        # + labels + strip; the QGIS UI theme is left to explicit user choice).
        QTimer.singleShot(0, self.apply_display_mode)

        pgrid.addWidget(QLabel("units"), 3, 0)
        self.units_combo = QComboBox()
        self._unit_codes = [c for _, c in UNIT_OPTIONS]
        for label, _ in UNIT_OPTIONS:
            self.units_combo.addItem(label)
        self.units_combo.setToolTip(
            "Unit system for all readouts — Nautical (kn, nm, metres, DMM "
            "position), Metric (km/h, km, m) or Imperial (mph, miles, feet)")
        try:
            from . import units as _u
            self.units_combo.setCurrentIndex(self._unit_codes.index(_u.system()))
        except Exception:                          # noqa: BLE001  # nosec B110
            pass
        self.units_combo.currentIndexChanged.connect(self._on_units_changed)
        pgrid.addWidget(self.units_combo, 3, 1, 1, 4)

        # --- own-position button (middle band, full width) ------------------
        # The in-coverage routes are drawn on the QGIS map automatically (every
        # ~10 s) and a click on a route opens its leg — no button needed; see
        # _refresh_routes (timer below).
        self.own_btn = QPushButton("Set own position…")
        self.own_btn.setToolTip("Click, then pick your receiver position on the map")
        self.own_btn.clicked.connect(self.setOwnPositionRequested)
        pgrid.addWidget(self.own_btn, 4, 0, 1, 5)

        # --- toggles (bottom band) ------------------------------------------
        self.fairway_chk = QCheckBox("Bind vessels to fairways")
        self.fairway_chk.setChecked(True)
        self.fairway_chk.setToolTip(
            "Match each vessel to the Dutch fairway network and write its "
            "fairway name + route-km into the live target's data model "
            "(updates as the vessel moves onto another fairway)"
        )
        pgrid.addWidget(self.fairway_chk, 5, 0, 1, 2)

        self.chime_chk = QCheckBox("Audible chime on alarm")
        self.chime_chk.setChecked(True)
        self.chime_chk.setToolTip("Beep when an AIS Guardian alarm triggers")
        pgrid.addWidget(self.chime_chk, 5, 3, 1, 2)

        self.shiplabel_chk = QCheckBox("Ship labels (name · hdg · SOG)")
        self.shiplabel_chk.setChecked(True)
        self.shiplabel_chk.setToolTip(
            "Label each vessel with its name (max 10 chars) and, on a second "
            "line, heading and speed (e.g. 351° - 20 kn), offset to 135°.")
        self.shiplabel_chk.toggled.connect(self._on_shiplabels_toggled)
        pgrid.addWidget(self.shiplabel_chk, 6, 0, 1, 5)

        layout.addWidget(portrayal_box)
        self._init_portrayal_from_project()

        layout.addWidget(self._build_history_box())

        self.status_label = QLabel(self._status_text("Idle"))
        layout.addWidget(self.status_label)

        # stream-rate sparkline, directly above the live NMEA stream
        self.rate_graph = StreamRateGraph()
        layout.addWidget(self.rate_graph)

        self.log = QPlainTextEdit()
        self.log.setReadOnly(True)
        self.log.setMaximumBlockCount(MAX_LOG_BLOCKS)
        self.log.setFont(QFont("Monospace", 9))
        layout.addWidget(self.log)

        self.decoded_label = QLabel(
            "Decoding/plotting: pyais not found — bundle it into libs/ (see README)"
            if not PYAIS_AVAILABLE else
            "Decoded preview: waiting for an AIS position fix…"
        )
        self.decoded_label.setWordWrap(True)
        layout.addWidget(self.decoded_label)

        if not PYAIS_AVAILABLE:
            self.plot_combo.setCurrentIndex(
                [c for _, c in PLOT_OPTIONS].index("off"))
            self.plot_combo.setEnabled(False)

        if not PYSERIAL_AVAILABLE:
            self.status_label.setText(
                "pyserial not found — bundle it into libs/ (see README)"
            )
            self.connect_btn.setEnabled(False)
            self.detect_btn.setEnabled(False)
            self.refresh_btn.setEnabled(False)

    # ---- Voyage history --------------------------------------------------
    def _build_history_box(self):
        """Optional voyage-history recorder controls (default OFF). Settings
        persist to the profile; when enabled, a background recorder segments
        vessels into voyages and writes to GeoPackage or PostGIS off the GUI
        thread (never blocking the live feed)."""
        from .history_config import HistoryConfig, STAGE_LABELS, BACKEND_LABELS
        self._history_cfg = HistoryConfig.load()
        self._stage_codes = [s for _, s in STAGE_LABELS]
        self._backend_codes = [b for _, b in BACKEND_LABELS]

        box = QGroupBox("Voyage history")
        g = QGridLayout(box)
        g.setHorizontalSpacing(8)
        g.setVerticalSpacing(6)

        self.history_chk = QCheckBox("Record voyage history")
        self.history_chk.setChecked(self._history_cfg.enabled)
        self._history_chk_tip = (
            "Segment each vessel into voyages and store them for later replay / "
            "analysis. Runs in the background; off by default. Serial / TCP only.")
        self.history_chk.setToolTip(self._history_chk_tip)
        self.history_chk.toggled.connect(self._on_history_toggle)
        g.addWidget(self.history_chk, 0, 0, 1, 4)
        # (Delete history lives on the History tab, which owns the archive UI.)

        g.addWidget(QLabel("detail"), 1, 0)
        self.history_stage_combo = QComboBox()
        for label, _ in STAGE_LABELS:
            self.history_stage_combo.addItem(label)
        if self._history_cfg.stage in self._stage_codes:
            self.history_stage_combo.setCurrentIndex(
                self._stage_codes.index(self._history_cfg.stage))
        self.history_stage_combo.setToolTip(
            "1 textual (vessel info + events) · 2 voyages (a LineString per "
            "voyage) · 3 full (all transponder positions)")
        self.history_stage_combo.currentIndexChanged.connect(self._on_history_stage)
        g.addWidget(self.history_stage_combo, 1, 1, 1, 3)

        g.addWidget(QLabel("storage"), 2, 0)
        self.history_backend_combo = QComboBox()
        for label, _ in BACKEND_LABELS:
            self.history_backend_combo.addItem(label)
        self.history_backend_combo.setCurrentIndex(
            0 if self._history_cfg.backend == "gpkg" else 1)
        self.history_backend_combo.currentIndexChanged.connect(
            self._on_history_backend)
        g.addWidget(self.history_backend_combo, 2, 1, 1, 2)

        self.history_cfg_btn = QPushButton("Configure database…")
        self.history_cfg_btn.setToolTip(
            "PostgreSQL/PostGIS connection (credentials via the QGIS auth vault)")
        self.history_cfg_btn.clicked.connect(self._configure_history_db)
        g.addWidget(self.history_cfg_btn, 2, 3)

        self.history_status = QLabel("")
        self.history_status.setWordWrap(True)
        g.addWidget(self.history_status, 3, 0, 1, 4)

        self._sync_history_controls()
        if self._history_cfg.enabled:
            # honour a persisted "on" state at startup (best-effort)
            QTimer.singleShot(0, self._ensure_history)
        return box

    def _current_stage(self):
        return self._stage_codes[self.history_stage_combo.currentIndex()]

    def _current_backend(self):
        return self._backend_codes[self.history_backend_combo.currentIndex()]

    def _set_history_status(self, text):
        try:
            self.history_status.setText(text)
        except Exception:  # nosec B110
            pass

    def _gpkg_disk_suffix(self):
        """' · N MB on disk' for the GeoPackage backend, else '' (best-effort)."""
        cfg = self._history_cfg
        if cfg is None or cfg.backend != "gpkg":
            return ""
        try:
            from .history_config import gpkg_disk_bytes, human_size
            n = gpkg_disk_bytes(cfg.gpkg_path)
            if n <= 0:
                return ""
            return " · %s on disk" % human_size(n)
        except Exception:
            return ""

    def _sync_history_controls(self):
        cfg = self._history_cfg
        try:
            self.history_cfg_btn.setEnabled(cfg.backend == "postgres")
        except Exception:  # nosec B110
            pass
        if self._history is None:
            if cfg.enabled:
                self._set_history_status(cfg.backend_label()
                                         + self._gpkg_disk_suffix())
            else:
                self._set_history_status("Off")

    def _plugin_version(self):
        try:
            import configparser
            cp = configparser.ConfigParser()
            cp.read(os.path.join(os.path.dirname(__file__), "metadata.txt"),
                    encoding="utf-8")
            return cp.get("general", "version", fallback="")
        except Exception:
            return ""

    def _on_history_toggle(self, on):
        self._history_cfg.enabled = bool(on)
        self._history_cfg.save()
        if on:
            self._ensure_history()
        else:
            self._stop_history()
        self._sync_history_controls()

    def _on_history_stage(self, *_):
        self._history_cfg.stage = self._current_stage()
        self._history_cfg.save()
        self._restart_history_if_running()

    def _on_history_backend(self, *_):
        self._history_cfg.backend = self._current_backend()
        self._history_cfg.save()
        self._sync_history_controls()
        self._restart_history_if_running()

    def _configure_history_db(self):
        from .history_config import edit_postgres_config
        if edit_postgres_config(self._history_cfg, parent=self):
            self._history_cfg.save()
            self._sync_history_controls()
            self._restart_history_if_running()

    def _restart_history_if_running(self):
        if self._history is not None:
            self._stop_history()
            self._ensure_history()
        self._sync_history_controls()

    def _ensure_history(self):
        """Start the recorder if history is enabled (idempotent). PostgreSQL
        backends are offered one-click provisioning when the schema is absent.
        Never records from an internet source (serial / TCP only)."""
        if self._history is not None:
            return self._history
        if self.rb_internet.isChecked():        # internet feeds are never recorded
            return None
        cfg = self._history_cfg
        if not cfg.enabled:
            return None
        try:
            from .history_config import build_store_factory
            from .history_recorder import HistoryRecorder
            factory = build_store_factory(cfg)
            if cfg.backend == "postgres" and not self._provision_postgres(cfg,
                                                                          factory):
                self._force_history_off()
                return None
            rec = HistoryRecorder(
                store_factory=factory, stage=cfg.stage, node_id=cfg.node_id,
                plugin_version=self._plugin_version(),
                logic_version=HISTORY_LOGIC_VERSION)
            rec.start()
            self._history = rec
            self._set_history_status("Recording · %s" % cfg.backend_label())
        except Exception as exc:                        # noqa: BLE001
            self._history = None
            self._set_history_status("History disabled: %s" % exc)
            self._force_history_off()
        return self._history

    def _force_history_off(self):
        """Untick the box + persist OFF without recursing through the toggle."""
        self._history_cfg.enabled = False
        self._history_cfg.save()
        try:
            self.history_chk.blockSignals(True)
            self.history_chk.setChecked(False)
            self.history_chk.blockSignals(False)
        except Exception:  # nosec B110
            pass

    def _provision_postgres(self, cfg, factory):
        """On first use, offer to create the schema + tables + indexes if absent.
        Returns True if the database is ready to record into."""
        from qgis.PyQt.QtWidgets import QMessageBox
        try:
            store = factory()
        except Exception as exc:                        # noqa: BLE001
            QMessageBox.warning(self, "History database",
                                "Could not connect:\n%s" % exc)
            return False
        try:
            if store.needs_provisioning():
                r = QMessageBox.question(
                    self, "Set up history database",
                    "Schema '%s' and its tables are not present in this "
                    "database.\nCreate them now?" % cfg.pg_schema,
                    QMessageBox.Yes | QMessageBox.No)
                if r != QMessageBox.Yes:
                    return False
                store.ensure_schema()
                store.ensure_indexes(cfg.stage)
                store.register_node(cfg.node_id, self._plugin_version(),
                                    HISTORY_LOGIC_VERSION)
            return True
        except Exception as exc:                        # noqa: BLE001
            QMessageBox.warning(self, "History database", "Setup failed:\n%s" % exc)
            return False
        finally:
            try:
                store.close()
            except Exception:  # nosec B110
                pass

    def _stop_history(self):
        rec = self._history
        self._history = None
        if rec is not None:
            try:
                rec.stop(timeout=15)
            except Exception:  # nosec B110
                pass

    def _delete_history(self):
        """Confirm, then permanently clear all recorded history from the active
        backend (TRUNCATE the PostgreSQL tables, or delete the GeoPackage file).
        Resumes recording into a fresh empty store if it was running."""
        from qgis.PyQt.QtWidgets import QMessageBox
        cfg = self._history_cfg
        if cfg.backend == "postgres":
            target = ("PostgreSQL schema '%s'\n(connection: %s)"
                      % (cfg.pg_schema, cfg.pg_conn_name or "not set"))
        else:
            target = "GeoPackage file:\n%s" % cfg.gpkg_path
        if QMessageBox.question(
                self, "Delete history",
                "Permanently delete ALL recorded voyage history from the\n%s?\n\n"
                "This cannot be undone." % target,
                QMessageBox.Yes | QMessageBox.No, QMessageBox.No) != QMessageBox.Yes:
            return
        was_recording = self._history is not None
        self._stop_history()                    # release the store before deleting
        ok, msg = self._clear_history_store(cfg)
        self._hist_lastgood.clear()
        self._hist_filtered = 0
        if ok and was_recording and cfg.enabled:
            self._ensure_history()              # resume into a fresh, empty store
        self._sync_history_controls()
        (QMessageBox.information if ok else QMessageBox.warning)(
            self, "Delete history", msg)

    def _clear_history_store(self, cfg):
        """Empty the configured backend. Returns (ok, message)."""
        try:
            if cfg.backend == "postgres":
                if not cfg.pg_conn_name:
                    return False, "No PostgreSQL connection is configured."
                import re
                import psycopg2
                from .history_config import pg_params_from_qgis
                if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", cfg.pg_schema):
                    return False, "Invalid PostgreSQL schema name."
                con = psycopg2.connect(**pg_params_from_qgis(cfg.pg_conn_name))
                con.autocommit = True
                cur = con.cursor()
                cur.execute("SELECT to_regclass(%s)", ("%s.fixes" % cfg.pg_schema,))
                if cur.fetchone()[0] is None:
                    con.close()
                    return True, "No PostgreSQL history to delete (already empty)."
                cur.execute(                                         # nosec B608
                    "TRUNCATE %s.fixes, %s.voyages, %s.events, %s.vessels, "
                    "%s.history_nodes RESTART IDENTITY" % ((cfg.pg_schema,) * 5))
                con.close()
                return True, "PostgreSQL voyage history cleared."
            # GeoPackage: delete the file + its WAL/SHM sidecars
            import os
            removed = 0
            for suffix in ("", "-wal", "-shm"):
                p = cfg.gpkg_path + suffix
                if os.path.exists(p):
                    os.remove(p)
                    removed += 1
            return True, ("History GeoPackage deleted." if removed
                          else "No history file found (already empty).")
        except Exception as exc:                        # noqa: BLE001
            return False, "Delete failed: %s" % exc

    def _record_history(self, rec):
        """Feed one position fix to the recorder from the (already fairway-stamped)
        record. Best-effort, non-blocking; independent of the fairway toggle and
        never disturbs the live feed."""
        h = self._history
        if h is None:
            return
        try:
            mmsi = rec.get("mmsi")
            lon, lat = rec.get("lon"), rec.get("lat")
            # Ingest filters: only real ships, only plausible positions, and drop
            # single-message decode glitches (impossible instantaneous jumps).
            if (not ais_filters.is_ship_mmsi(mmsi)
                    or not ais_filters.plausible_position(lon, lat)):
                self._hist_filtered += 1
                return
            now_t = time.time()
            if ais_filters.is_teleport(self._hist_lastgood.get(mmsi), now_t,
                                       lon, lat):
                self._hist_filtered += 1
                return
            self._hist_lastgood[mmsi] = (now_t, lon, lat)
            static = None
            if self.registry is not None:
                try:
                    static = self.registry.get_record(mmsi)
                except Exception:
                    static = None
            on = rec.get("fairway_on") == "yes"
            h.record(
                mmsi, now_t, lon, lat,
                sog=rec.get("sog"), cog=rec.get("cog"),
                heading=rec.get("heading"), nav_status=rec.get("nav_status"),
                static=static or None,
                routeid=(rec.get("fairway_routeid") if on else None),
                route_km=(rec.get("fairway_km") if on else None),
                flow=((rec.get("fairway_flow") or None) if on else None))
        except Exception:  # nosec B110
            pass

    def _update_history_status(self):
        rec = self._history
        if rec is None:
            return
        if rec.reconnecting:
            msg = str(rec.error or "").splitlines()
            msg = msg[0] if msg else "connection lost"
            self._set_history_status(
                "Reconnecting to database (retry %d)… — %s" % (rec.n_reconnects,
                                                               msg))
            return
        if rec.error is not None:
            self._set_history_status("History error: %s" % rec.error)
            return
        extra = (" · %d dropped" % rec.n_dropped) if rec.n_dropped else ""
        extra += (" · %d filtered" % self._hist_filtered) if self._hist_filtered else ""
        extra += (" · %d reconnects" % rec.n_reconnects) if rec.n_reconnects else ""
        extra += self._gpkg_disk_suffix()
        self._set_history_status(
            "Recording · %d fixes in · %d rows written%s"
            % (rec.n_in, rec.n_written, extra))

    # ---- Port handling ---------------------------------------------------
    def _on_source_type(self, *_):
        """Show the serial / TCP / internet panel to match the source-type radio
        and gate the connect/auto-detect buttons accordingly."""
        serial = self.rb_serial.isChecked()
        internet = self.rb_internet.isChecked()
        tcp = not serial and not internet
        self.serial_panel.setVisible(serial)
        self.tcp_panel.setVisible(tcp)
        self.internet_panel.setVisible(internet)
        self.detect_btn.setEnabled(serial)      # auto-detect is serial-only
        if serial:
            self.connect_btn.setEnabled(bool(self._ports))
        else:
            self.connect_btn.setEnabled(True)   # TCP/internet don't need ports
        self._apply_history_source_gate(internet)
        if internet:
            self._on_internet_service_changed()

    def _apply_history_source_gate(self, internet):
        """Voyage history records your OWN receiver's traffic (serial / TCP), not a
        third-party internet firehose — so disable the recorder for internet
        sources (stopping it if it was running) and re-enable it otherwise."""
        chk = getattr(self, "history_chk", None)
        if chk is None:
            return
        if internet:
            if chk.isChecked():
                chk.blockSignals(True)
                chk.setChecked(False)
                chk.blockSignals(False)
                self._stop_history()
            chk.setEnabled(False)
            chk.setToolTip("Voyage history records your own receiver's traffic "
                           "(serial / TCP) — not a third-party internet feed.")
        else:
            chk.setEnabled(True)
            chk.setToolTip(getattr(self, "_history_chk_tip", ""))

    # ---- Internet sources ------------------------------------------------
    def _selected_service(self):
        sid = self.service_combo.currentData()
        return internet_sources.get_service(sid)

    # ---- coverage-source identity ---------------------------------------
    def coverage_source_key(self, own_pos):
        """Stable identity of the ACTIVE source, used to segregate the coverage
        map so each source keeps its own footprint: your own antenna on location,
        a fixed-footprint internet feed (e.g. Kystverket), and a position-scoped
        feed (aisstream/VesselAPI) are all distinct.

        Returns a short key string, or None when coverage can't be keyed yet — a
        position-anchored source (local hardware, or a location-based internet
        feed) with no own position set. own_pos is (lat, lon) or None.
        """
        def _pos():
            if not own_pos:
                return None
            return "%.5f,%.5f" % (own_pos[0], own_pos[1])

        if self.rb_internet.isChecked():
            svc = self._selected_service()
            sid = svc["id"] if svc else "internet"
            if svc is not None and not svc.get("needs_location"):
                return "net:%s" % sid              # fixed footprint (national/global)
            p = _pos()                             # box around own position
            return ("net:%s@%s" % (sid, p)) if p else None
        # local hardware (serial / TCP): footprint = your antenna, anchored to where
        # it physically sits, so it is keyed by the own position.
        p = _pos()
        return ("local@%s" % p) if p else None

    def _on_internet_service_changed(self, *_):
        """Update the internet panel to the selected service: show the API-key row
        only when needed, the area radius + a position note only for location-based
        feeds, and a coverage/licence hint line."""
        svc = self._selected_service()
        if svc is None:
            return
        need_key = svc.get("needs_key")
        need_loc = svc.get("needs_location")
        for w in (self.apikey_label, self.apikey_edit, self.getkey_btn):
            w.setVisible(bool(need_key))
        self.getkey_btn.setEnabled(bool(svc.get("signup")))
        for w in (self.radius_label, self.radius_spin):
            w.setVisible(bool(need_loc))
        # restore any saved key for this service
        if need_key:
            self.apikey_edit.setText(self._load_apikey(svc["id"]))
        parts = ["Coverage: %s." % svc.get("coverage", "?")]
        if svc.get("notice"):
            parts.append(svc["notice"])
        else:
            parts.append("Licence: %s." % svc.get("license", "?"))
        if need_loc:
            op = self._own_position()
            parts.append("Uses your own position to pick a data area — "
                         + ("currently %.3f, %.3f." % (op[0], op[1]) if op
                            else "NOT set yet; set it on the map first."))
        self.internet_hint.setText("  ".join(parts))
        self._save_setting("internet/service", svc["id"])

    def _open_service_signup(self):
        """Open the selected internet service's website so the user can sign up
        for a free API key."""
        svc = self._selected_service()
        url = svc.get("signup") if svc else None
        if not url:
            return
        try:
            from qgis.PyQt.QtGui import QDesktopServices
            from qgis.PyQt.QtCore import QUrl
            QDesktopServices.openUrl(QUrl(url))
        except Exception:  # nosec B110
            pass

    def _own_position(self):
        """(lat, lon) of the stored receiver position, or None (read-only)."""
        try:
            from .guardian_store import GuardianStore
            return GuardianStore().load_own_position()
        except Exception:
            return None

    def _apikey_settings_key(self, sid):
        return "ais_nmea_source/internet/apikey_%s" % sid

    def _load_apikey(self, sid):
        try:
            from qgis.core import QgsSettings
            return QgsSettings().value(self._apikey_settings_key(sid), "") or ""
        except Exception:
            return ""

    def _save_apikey(self, sid, key):
        try:
            from qgis.core import QgsSettings
            QgsSettings().setValue(self._apikey_settings_key(sid), key)
        except Exception:  # nosec B110
            pass

    def _save_setting(self, key, value):
        try:
            from qgis.core import QgsSettings
            QgsSettings().setValue("ais_nmea_source/" + key, value)
        except Exception:  # nosec B110
            pass

    def _build_internet_reader(self):
        """Build the reader for the selected internet service. Returns
        (reader, label, is_record_source) or None (with a status message /
        prompt) when it can't proceed."""
        svc = self._selected_service()
        if svc is None:
            return None
        if svc["transport"] == "tcp":       # raw NMEA over TCP -> the TCP reader
            return TcpReader(svc["host"], svc["port"]), svc["name"], False
        # key-based, decoded-record sources (websocket stream / REST poll)
        key = self.apikey_edit.text().strip()
        if svc.get("needs_key") and not key:
            self.status_label.setText(self._status_text(
                "Enter your free API key (see %s)" % svc.get("signup", "the service")))
            return None
        bbox = None
        if svc.get("needs_location"):
            op = self._own_position()
            if op is None:
                from qgis.PyQt.QtWidgets import QMessageBox
                QMessageBox.information(
                    self, "Own position needed",
                    "This feed loads AIS around your position.\n\nSet your "
                    "position on the map (Set own position…), then press Connect "
                    "again.")
                self.setOwnPositionRequested.emit()     # trigger the map picker
                return None
            bbox = internet_sources.bbox_around(op[0], op[1],
                                                self.radius_spin.value())
        if svc.get("needs_key"):
            self._save_apikey(svc["id"], key)
        bbox = bbox or internet_sources.bbox_around(0.0, 0.0, 1)
        if svc["transport"] == "rest":
            from .vesselapi_reader import VesselApiReader
            reader = VesselApiReader(svc["url"], key, bbox, svc.get("poll_s", 30))
        else:                               # websocket (aisstream.io)
            from .aisstream_reader import AisStreamReader
            reader = AisStreamReader(svc["url"], key, bbox)
        return reader, svc["name"], True

    def refresh_ports(self):
        """Re-enumerate serial ports and repopulate the port dropdown, labelling
        each USB/serial; keeps Connect enabled for TCP even with no ports."""
        self.port_combo.clear()
        self._ports = list_serial_ports()
        tcp = getattr(self, "rb_tcp", None) is not None and self.rb_tcp.isChecked()
        if not self._ports:
            self.port_combo.addItem("No serial ports found")
            self.port_combo.setEnabled(False)
            self.connect_btn.setEnabled(tcp)   # TCP can still connect
            self.details_label.setText("")
            return
        self.port_combo.setEnabled(True)
        self.connect_btn.setEnabled(True)
        for p in self._ports:
            tag = "USB" if p["is_usb"] else "serial"
            self.port_combo.addItem(f'{p["device"]} — {p["description"]} [{tag}]')
        self._update_details()

    def _update_details(self):
        """Show VID:PID / vendor / manufacturer for the selected serial port."""
        idx = self.port_combo.currentIndex()
        if not self._ports or idx < 0 or idx >= len(self._ports):
            self.details_label.setText("")
            return
        p = self._ports[idx]
        bits = []
        if p["vid"] is not None and p["pid"] is not None:
            bits.append(f'VID:PID {p["vid"]:04X}:{p["pid"]:04X}')
        if p["vendor_label"]:
            bits.append(p["vendor_label"])
        if p["manufacturer"]:
            bits.append(p["manufacturer"])
        self.details_label.setText("    ".join(bits) if bits else p["hwid"])

    # ---- Connection ------------------------------------------------------
    def toggle_connection(self):
        """Connect or disconnect, depending on whether a reader is running."""
        if self.reader and self.reader.isRunning():
            self.disconnect_source()
        else:
            self.connect_source()

    def connect_source(self):
        """Start a serial or TCP reader for the chosen source, wire its signals,
        reset the per-session counters/health, and ensure the live layer exists.
        TCP host/port are validated before connecting."""
        is_record_source = False
        if self.rb_internet.isChecked():
            built = self._build_internet_reader()
            if built is None:
                return
            reader, label, is_record_source = built
        elif self.rb_tcp.isChecked():
            host = self.host_edit.text().strip()
            try:
                port = int(self.tcpport_edit.text().strip())
            except ValueError:
                port = 0
            if not host or not (0 < port < 65536):
                self.status_label.setText(
                    self._status_text("Enter a valid host and port (1–65535)"))
                return
            # Scan the endpoint first: if it's TLS or looks like it wants auth,
            # pop the security dialog and connect with those settings. Best-effort
            # — any probe trouble just falls back to a plain connect.
            reader_kwargs = {}
            try:
                from .endpoint_probe import probe as _probe
                from .security_dialog import ConnectSecurityDialog
                from qgis.PyQt.QtWidgets import QApplication
                from qgis.PyQt.QtCore import Qt as _Qt
                QApplication.setOverrideCursor(_Qt.WaitCursor)
                try:
                    pr = _probe(host, port, timeout=3.0)
                finally:
                    QApplication.restoreOverrideCursor()
                if pr.reachable and pr.secured:
                    dlg = ConnectSecurityDialog(pr, self)
                    if dlg.exec_() != dlg.Accepted:
                        self.status_label.setText(
                            self._status_text("Connect cancelled"))
                        return
                    reader_kwargs = dlg.result_config()
                    reader_kwargs.pop("authcfg", None)   # id is for persistence, not the reader
            except Exception:
                reader_kwargs = {}
            reader = TcpReader(host, port, **reader_kwargs)
            label = f"{host}:{port}"
        else:
            idx = self.port_combo.currentIndex()
            if idx < 0 or idx >= len(self._ports):
                return
            device = self._ports[idx]["device"]
            baud = int(self.baud_combo.currentText())
            reader = SerialReader(device, baud)
            label = device

        self._total = self._valid = self._ais = 0
        self._last_total_sample = 0
        self._gnss_seed_emitted = False     # re-arm GNSS seed for this connection
        self._ensure_render_timer()         # start watching the real render pass
        self.health = StreamHealth()        # fresh per-session baseline
        self.log.clear()

        self.reader = reader
        if is_record_source:                # internet WebSocket: decoded records
            self.reader.recordReceived.connect(self.on_record)
            self.reader.connected.connect(lambda lbl=label: self.on_connected(lbl))
        else:                               # serial / TCP: raw NMEA lines
            self.reader.lineReceived.connect(self.on_line)
            self.reader.connected.connect(self.on_connected)
        self.reader.errorOccurred.connect(self.on_error)
        self.reader.disconnected.connect(self.on_disconnected)
        self.reader.start()
        self.connect_btn.setText("Disconnect")
        self.status_label.setText(self._status_text(f"Connecting to {label}…"))
        self.sourceActivated.emit()     # let coverage re-key to this source

        # Make sure the live layer is present. If the user removed it from the
        # layer panel, this recreates it (styled, ready to refill); if it's
        # still there, it's reused and keeps updating from the new stream.
        if self._plot_mode() != "off" and PYAIS_AVAILABLE:
            try:
                self.vessels.ensure_layer()
            except Exception as exc:
                self.decoded_label.setText(f"Layer setup error: {exc}")

    def disconnect_source(self):
        """Stop the active reader and signal that the feed is no longer live.

        Keep a reference to the retiring reader until its thread has actually
        finished, so it can never be garbage-collected (and finalise/close its
        serial port) while a read is still in flight — a Windows crash class."""
        if self.reader:
            r = self.reader
            self.reader = None
            self._retired.append(r)
            r.finished.connect(lambda r=r: self._retired.remove(r)
                               if r in self._retired else None)
            r.stop()
        self.connect_btn.setText("Connect")
        self.feedLiveChanged.emit(False)

    # ---- Full stop / resume ---------------------------------------------
    def is_feed_live(self):
        """True while a reader thread is connected and running."""
        return bool(self.reader and self.reader.isRunning())

    def halt_processes(self):
        """Stop the feed, the auto-detect scan and the 1 Hz rate sampler so the
        widget does no background work. Remembers whether the feed was live."""
        self._was_live = self.is_feed_live()
        try:
            if self.scanner:
                self.scanner.stop()
                self.scanner = None
        except Exception:  # nosec B110
            pass
        self.disconnect_source()
        try:
            self._rate_timer.stop()
        except Exception:  # nosec B110
            pass

    def resume_processes(self):
        """Restart the rate sampler, re-create the stream-owned layers (live
        vessels + trails) and reconnect the feed if it was live before halting."""
        try:
            self._rate_timer.start()
        except Exception:  # nosec B110
            pass
        try:
            self.vessels.ensure_layer()
        except Exception:  # nosec B110
            pass
        try:
            self._on_trails_changed()       # re-creates the trail layer if on
        except Exception:  # nosec B110
            pass
        if getattr(self, "_was_live", False):
            try:
                self.connect_source()
            except Exception:  # nosec B110
                pass

    def set_fullstop_label(self, halted):
        """Flip the Full-stop button text to reflect the halted/running state."""
        self.fullstop_btn.setText("Resume" if halted else "Full stop")

    # ---- Auto-detect -----------------------------------------------------
    def auto_detect(self):
        """Launch the background port scanner to find the AIS feed and, if one is
        found, auto-select + connect to it. No-op without pyserial or mid-scan."""
        if not PYSERIAL_AVAILABLE or self.scanner is not None:
            return
        self.disconnect_source()      # free any open port before scanning
        self.refresh_ports()
        devices = [p["device"] for p in self._ports]
        if not devices:
            self.status_label.setText(self._status_text("No ports to scan"))
            return
        self.log.clear()
        self.decoded_label.setText("Auto-detecting source…")
        self._set_scanning_ui(True)

        self.scanner = PortScanner(devices)
        self.scanner.progress.connect(self._on_scan_progress)
        self.scanner.portResult.connect(self._on_scan_result)
        self.scanner.scanFinished.connect(self._on_scan_finished)
        self.scanner.start()

    def _set_scanning_ui(self, scanning):
        """Enable/disable the source controls while a scan is in progress and
        update the Auto-detect button label."""
        for w in (self.connect_btn, self.refresh_btn,
                  self.port_combo, self.baud_combo):
            w.setEnabled(not scanning)
        self.detect_btn.setEnabled(not scanning)
        self.detect_btn.setText("Scanning…" if scanning else "Auto-detect")

    def _on_scan_progress(self, message):
        """Show a scanner progress line in the status label."""
        self.status_label.setText(message)

    def _on_scan_result(self, res):
        """Append one per-port scan finding (valid/AIS/GPS counts) to the log."""
        self.log.appendPlainText(
            f'{res["device"]} @ {res["baud"]}  ->  {res["kind"].upper()}  '
            f'(valid {res["valid"]}, ais {res["ais"]}, gps {res["gps"]})'
        )

    def _on_scan_finished(self, best):
        """Scan done: select the best port/baud and auto-connect if it's an AIS
        source; otherwise report what was found and wait for the user."""
        self._set_scanning_ui(False)
        scanner, self.scanner = self.scanner, None
        if scanner is not None:
            scanner.wait(200)

        if not best:
            self.status_label.setText(
                self._status_text("No NMEA/AIS source found — check power/wiring")
            )
            self.decoded_label.setText("Auto-detect: nothing found.")
            return

        self._select_device(best["device"])
        self.baud_combo.setCurrentText(str(best["baud"]))
        kind = best["kind"].upper()
        if best["kind"] == "ais":
            self.status_label.setText(
                f'Detected AIS on {best["device"]} @ {best["baud"]} — connecting…'
            )
            self.connect_source()  # auto-connect to the AIS feed
        else:
            self.status_label.setText(
                f'Detected {kind} on {best["device"]} @ {best["baud"]} '
                f'(no AIS seen) — press Connect to view'
            )

    def _select_device(self, device):
        """Select the port dropdown entry matching a device string."""
        for i, p in enumerate(self._ports):
            if p["device"] == device:
                self.port_combo.setCurrentIndex(i)
                return

    # ---- Reader signal handlers -----------------------------------------
    def on_connected(self, device=""):
        """Reader signal: source opened — update status and the feed-live light."""
        self.status_label.setText(self._status_text(f"Connected: {device}"))
        self.feedLiveChanged.emit(True)

    def on_disconnected(self):
        """Reader signal: source closed — reset the button/status and the light."""
        self.connect_btn.setText("Connect")
        self.status_label.setText(self._status_text("Disconnected"))
        self.feedLiveChanged.emit(False)

    def on_error(self, message):
        """Reader signal: surface a connect/read error and drop the feed-live light."""
        self.status_label.setText(f"⚠ {message}")
        self.connect_btn.setText("Connect")
        self.feedLiveChanged.emit(False)

    def on_line(self, line):
        """Handle one raw sentence: count it, update the live log (respecting the
        AIS-only filter), and hand valid AIS sentences on to be decoded.

        A leading NMEA tag block (\\...\\, added by many aggregators/multiplexers)
        is stripped for classify/validate/decode while the raw line is logged."""
        core = strip_tag_block(line)
        self._total += 1
        self._last_rx = time.time()      # feed is live -> in coverage
        kind = classify(core)
        valid = is_valid_nmea(core)
        if valid:
            self._valid += 1
        is_ais = kind == "ais"
        if is_ais:
            self._ais += 1
        elif not self._gnss_seed_emitted and is_gps_sentence(core):
            # A combined receiver may interleave its own GNSS position on the AIS
            # feed. Seed own position from the first valid fix (once), GNSS-first;
            # the Guardian dock ignores it if the user set a position by hand.
            fix = parse_gps_fix(core)
            if fix is not None:
                self._gnss_seed_emitted = True
                self.ownPositionFix.emit(fix[0], fix[1])

        if not self.ais_only_chk.isChecked() or is_ais:
            self.log.appendPlainText(line)
        self.status_label.setText(self._status_text("Connected"))

        if is_ais and valid and PYAIS_AVAILABLE:
            self._handle_ais(core)

    def on_record(self, rec):
        """Internet (WebSocket) feed handler: an already-decoded record. Count it,
        echo it to the live log, and dispatch it like a decoded NMEA record."""
        if not rec:
            return
        self._total += 1
        self._valid += 1
        self._ais += 1
        self._last_rx = time.time()
        try:
            m = rec.get("mmsi")
            if rec.get("has_position"):
                self.log.appendPlainText("[net] %s  %.5f, %.5f"
                                         % (m, rec.get("lat"), rec.get("lon")))
            elif rec.get("name"):
                self.log.appendPlainText("[net] %s  static: %s" % (m, rec["name"]))
        except Exception:  # nosec B110
            pass
        self.status_label.setText(self._status_text("Connected"))
        self._dispatch_record(rec)

    def _handle_ais(self, line):
        """Reassemble + decode an AIS sentence, then dispatch the record."""
        rec = self.assembler.push(line)  # None until a full message is ready
        if rec:
            self._dispatch_record(rec)

    def _dispatch_record(self, rec):
        """Route one decoded record (from the NMEA decoder OR an internet feed)
        through binding, history, the Guardian engine, the preview and plotting.
        Shared by the serial/TCP and internet-source paths."""
        has_pos = rec.get("has_position")
        # Stamp the fairway binding before anything downstream consumes the
        # record (Guardian engine, Vessels tab) so they all see fairway fields.
        if has_pos:
            self._bind_fairway(rec)
            if self._history is not None:
                self._record_history(rec)

        # Hand every decoded record to the Guardian engine (detection + coverage
        # run independently of whether vessels are being plotted).
        self.aisRecord.emit(rec)

        if has_pos:
            t = time.perf_counter()
            if t - self._last_label_t >= 0.3:       # cosmetic: at most ~3/s
                self._last_label_t = t
                from . import units
                self.decoded_label.setText(
                    f'Decoded — MMSI {rec["mmsi"]}   type {rec["msg_type"]}   '
                    f'{units.fmt_position(rec["lat"], rec["lon"])}'
                    f'   ({self.vessels.vessel_count()} vessels)'
                )
        else:
            bits = []
            if rec.get("name"):
                bits.append(rec["name"])
            if rec.get("callsign"):
                bits.append(f'[{rec["callsign"]}]')
            if rec.get("ship_type_desc"):
                bits.append(rec["ship_type_desc"])
            if rec.get("destination"):
                bits.append(f'→ {rec["destination"]}')
            if bits:
                self.decoded_label.setText(
                    f'Static — MMSI {rec["mmsi"]}   ' + "   ".join(bits)
                )

        mode = self._plot_mode()
        if mode != "off" and (mode == "all" or rec.get("mmsi") in self._fleet):
            try:
                self.vessels.update(rec)
                if has_pos:
                    self.trails.update(rec.get("mmsi"), rec.get("lon"),
                                       rec.get("lat"))
            except Exception as exc:
                # A layer/styling hiccup must never kill the live stream.
                self.decoded_label.setText(f"Plot error (stream still live): {exc}")

    # ---- fairway binding -------------------------------------------------- #
    def _ensure_matcher(self):
        """Lazily build the fairway map-matcher from the bundled network.

        First-ever build is ~2 s (then cached in the profile dir, instant after).
        Any failure (missing data, no networkx) disables binding silently so the
        live feed is never affected.
        """
        if self.matcher is not None or self._fairway_failed:
            return self.matcher
        try:
            from .fairway_network import build_or_load
            from .map_matcher import MapMatcher
            # Binding + route manager run on the RWS 'route' network (full-stretch
            # named routes + self-derived junctions), baked into data/routes.gpkg.
            # Built fresh each session (~1-2 s); no on-disk cache.
            gpkg = os.path.join(os.path.dirname(__file__), "data", "routes.gpkg")
            if not os.path.exists(gpkg):
                self._fairway_failed = True
                return None
            self.matcher = MapMatcher(build_or_load(
                gpkg, lines_table="route", junc_table="junctions",
                snap_m=3.0), route_tol=self._route_tol)   # shared dict: live edits
            # ensure the user layers exist and inject any saved custom routes now,
            # so they bind from the first fix (not only after the routes timer).
            self._custom_dirty = True
            self._refresh_routes()
            self._ensure_bind_corridor(gpkg)
        except Exception as exc:
            self._fairway_failed = True
            try:
                self.decoded_label.setText(f"Fairway binding unavailable: {exc}")
            except Exception:  # nosec B110
                pass
        return self.matcher

    def _bind_fairway(self, rec):
        """Run a positional record through the matcher and stamp the binding
        (fairway name / route-km / on-off / direction) onto the record so it
        lands in the live target's data model."""
        if not self.fairway_chk.isChecked():
            return
        m = self._ensure_matcher()
        if m is None:
            return
        try:
            stype = rec.get("ship_type")
            if stype is None and self.registry is not None:
                try:
                    stype = self.registry.get_record(rec.get("mmsi")).get("ship_type")
                except Exception:
                    stype = None
            b = m.update(rec.get("mmsi"), time.time(), rec.get("lon"),
                         rec.get("lat"), cog=rec.get("cog"), sog=rec.get("sog"),
                         ship_type=stype)
            arrow = {1: "▲ km", -1: "▼ km", 0: ""}.get(b.direction, "")
            if b.on_fairway:
                rec["fairway_name"] = b.route_name or "(unnamed)"
                rec["fairway_km"] = round(b.km, 3) if b.km is not None else None
                rec["fairway_on"] = "yes"
                rec["fairway_dir"] = arrow
                rec["fairway_flow"] = b.flow or ""
                rec["fairway_side"] = b.side or ""
                rec["fairway_routeid"] = getattr(b, "routeid", None)
            else:
                rec["fairway_name"] = (b.route_name or "") if b.routeid else "—"
                rec["fairway_on"] = "no"
                rec["fairway_dir"] = ""
                rec["fairway_flow"] = ""
                rec["fairway_side"] = ""
                rec["fairway_routeid"] = None
        except Exception:  # nosec B110
            pass            # binding must never disrupt decoding/plotting

    def _on_shiplabels_toggled(self, on):
        """Switch the detailed ship labels on/off on the live layer."""
        try:
            self.vessels.set_detailed_labels(bool(on))
        except Exception:  # nosec B110
            pass

    # ---- plot mode (all / my fleet / off) --------------------------------
    def _plot_mode(self):
        try:
            return PLOT_OPTIONS[self.plot_combo.currentIndex()][1]
        except Exception:
            return "all"

    def _reload_fleet(self):
        self._fleet = my_fleet.mmsis()

    def _on_plot_mode_changed(self, *_):
        self._reload_fleet()
        self._apply_plot_mode()

    # ---- day / dusk / night display mode --------------------------------- #
    def _on_chartlight_changed(self, *_):
        """User picked a display mode: push it across the canvas, basemap, QGIS UI
        theme, labels and the route-leg strip."""
        try:
            mode = self._chartlight_codes[self.chartlight_combo.currentIndex()]
        except Exception:                          # noqa: BLE001
            return
        from . import ais_palette
        ais_palette.apply(self.iface, mode)        # includes the QGIS UI theme
        try:
            self.vessels.refresh_labels()
        except Exception:                          # noqa: BLE001  # nosec B110
            pass
        try:
            self.rate_graph.update()               # repaint the rate sparkline
        except Exception:                          # noqa: BLE001  # nosec B110
            pass

    def _on_units_changed(self, *_):
        """User picked a unit system: persist it, rebuild the ship labels (speed
        unit) and notify every other readout surface (tables, event log, strip)."""
        try:
            mode = self._unit_codes[self.units_combo.currentIndex()]
        except Exception:                          # noqa: BLE001
            return
        from . import units
        units.set_system(mode)
        try:
            self.vessels.refresh_labels()
        except Exception:                          # noqa: BLE001  # nosec B110
            pass
        units.notify()

    def apply_display_mode(self):
        """Restore the persisted display mode on start-up — canvas, basemap, labels
        and the strip. The QGIS UI theme is NOT forced here (only on an explicit
        pick), so we don't fight the user's own QGIS theme setting on every load."""
        try:
            from . import ais_palette
            ais_palette.apply(self.iface, ais_palette.current_mode(),
                              set_qgis_theme=False)
            self.vessels.refresh_labels()
            self.rate_graph.update()
        except Exception:                          # noqa: BLE001  # nosec B110
            pass

    def _apply_plot_mode(self):
        """Prune the live layer to the current plot mode: 'off' clears it, 'my
        fleet' drops vessels not in the fleet; 'all' leaves everything."""
        mode = self._plot_mode()
        if mode == "all":
            return
        try:
            for m in list(self.vessels._fid_by_mmsi.keys()):
                if mode == "off" or m not in self._fleet:
                    self.vessels.remove(m)
                    try:
                        self.trails.remove(m)
                    except Exception:  # nosec B110
                        pass
        except Exception:  # nosec B110
            pass

    def _sweep_stale_targets(self):
        """Drop targets with no position report for STALE_TARGET_S from the live
        viewer, their trail and their route binding. If the feed is live at the
        time (in coverage), the removal is a genuine 'lost target' and is logged
        via the Guardian event log; during a feed outage it is silent."""
        try:
            now = time.time()
            stale = self.vessels.stale_mmsis(STALE_TARGET_S, now=now)
            if not stale:
                return
            feed_live = (self.reader is not None
                         and getattr(self.reader, "isRunning", lambda: False)()
                         and (now - self._last_rx) < FEED_LIVE_WINDOW_S)
            for mmsi in stale:
                lon, lat = self.vessels.last_position(mmsi)
                self.vessels.remove(mmsi)
                try:
                    self.trails.remove(mmsi)
                except Exception:  # nosec B110
                    pass
                if self.matcher is not None:
                    try:
                        self.matcher.forget(mmsi)
                    except Exception:  # nosec B110
                        pass
                if feed_live:
                    self.lostTarget.emit((mmsi, lon, lat))
        except Exception:  # nosec B110
            pass            # a sweep hiccup must never disturb the live feed

    def prompt_pick(self, on):
        """Toggle the Set-own-position button label during map picking."""
        try:
            self.own_btn.setText("Click your position on the map…" if on
                                 else "Set own position…")
        except Exception:  # nosec B110
            pass

    def _ensure_route_win(self, m):
        """Create (once) the route-leg window over matcher ``m`` and return it."""
        if self._route_map_win is None:
            import time
            from .route_map_window import RouteMapWindow

            def _name_of(mmsi):
                """Vessel name from the registry for the strip labels (closure)."""
                try:
                    return (self.registry.get_record(mmsi) or {}).get("name")
                except Exception:
                    return None
            self._route_map_win = RouteMapWindow(
                m,
                points_provider=lambda: m.footprint_points(now=time.time()),
                bindings_provider=lambda: m.active_bindings(now=time.time()),
                name_provider=_name_of,
                parent=self)
        return self._route_map_win

    def _refresh_routes(self):
        """Ensure the vaarwegen fairway passages are shown on the chart as a
        quiet, detail-level display layer (individual stretches + names). Static,
        so it is added once and is a no-op thereafter; best-effort so it can't
        disrupt the live feed. Binding and the route manager run on the separate
        RWS 'route' network, not on this display layer."""
        if not self._vaarwegen_added:
            try:
                from .route_map_window import add_vaarwegen_layer
                gpkg = os.path.join(os.path.dirname(__file__), "data",
                                    "fairways.gpkg")
                if os.path.exists(gpkg) and add_vaarwegen_layer(gpkg) is not None:
                    self._vaarwegen_added = True
            except Exception:                   # noqa: BLE001  # nosec B110
                pass
        if not self._markers_added:
            # editable point layer for the user's own real-world references; the
            # route strip reads it and the route-CPA can target the markers.
            try:
                from .route_map_window import ensure_marker_layer
                if ensure_marker_layer() is not None:
                    self._markers_added = True
            except Exception:                   # noqa: BLE001  # nosec B110
                pass
        if not self._custom_added:
            # editable LineString layer for user-drawn routes (mainly for users
            # outside the bundled network). Edits mark the set dirty so it is
            # re-injected into the matcher on the next tick.
            try:
                from .custom_routes import ensure_custom_routes_layer
                lyr = ensure_custom_routes_layer()
                if lyr is not None:
                    for sig in ("editingStopped", "afterCommitChanges",
                                "featureAdded", "featuresDeleted",
                                "geometryChanged", "attributeValueChanged"):
                        try:
                            getattr(lyr, sig).connect(self._mark_custom_dirty)
                        except Exception:  # nosec B110
                            pass
                    self._custom_added = True
            except Exception:                   # noqa: BLE001  # nosec B110
                pass
        # (re)inject custom routes into the live network when they've changed
        if self.matcher is not None and self._custom_dirty:
            self._rebuild_custom_routes()

    def _mark_custom_dirty(self, *args):
        """Flag the custom-routes set as changed (wired to the layer's edit
        signals); the next _refresh_routes tick re-injects them."""
        self._custom_dirty = True

    def _rebuild_custom_routes(self):
        """Read the Custom routes layer and inject it into the matcher's network
        (negative routeids, binding priority). Invalidates the route model's
        cached segments so the leg view picks up the change. Best-effort."""
        self._custom_dirty = False
        try:
            from qgis.core import QgsProject
            from .custom_routes import (CUSTOM_LAYER_NAME, read_custom_routes,
                                        set_custom_routes)
            lyr = None
            for l in QgsProject.instance().mapLayersByName(CUSTOM_LAYER_NAME):
                lyr = l
                break
            customs = read_custom_routes(lyr)
            set_custom_routes(self.matcher.net, customs)
            if self._route_map_win is not None:
                self._route_map_win.model._route_segs = None
        except Exception:                       # noqa: BLE001  # nosec B110
            pass

    def open_route_leg(self, routeid):
        """Open the route-leg window for a route (used by the Routes tab)."""
        if self.matcher is None:
            return
        try:
            self._ensure_route_win(self.matcher).open_route(routeid)
        except Exception:                       # noqa: BLE001  # nosec B110
            pass

    def route_rows(self):
        """Current per-route vessel table rows (for the Routes tab), or []."""
        if self.matcher is None:
            return []
        try:
            import time
            win = self._ensure_route_win(self.matcher)
            return win.model.route_table(
                self.matcher.active_bindings(now=time.time()))
        except Exception:                       # noqa: BLE001
            return []

    # ---- per-route bind tolerance (editable in the Routes table) --------- #
    def _route_tol_path(self):
        """JSON file holding the per-route bind tolerances, in the profile dir."""
        from .guardian_store import default_base_dir
        return os.path.join(default_base_dir(), "route_tol.json")

    def _load_route_tol(self):
        """Load the persisted {routeid: tolerance_m} map (empty if none/error)."""
        import json
        try:
            with open(self._route_tol_path(), encoding="utf-8") as fh:
                return {int(k): float(v) for k, v in json.load(fh).items()}
        except Exception:                       # noqa: BLE001
            return {}

    def route_tolerance(self, routeid):
        """Bind tolerance (m) for a route — its override or the default."""
        return self._route_tol.get(routeid, DEFAULT_BIND_TOL_M)

    def set_route_tolerance(self, routeid, meters):
        """Set a route's bind tolerance (m), mutating the shared dict in place so
        the matcher picks it up live, and persist to the profile dir. A value at
        the default is dropped (keeps the file to only the explicit overrides)."""
        import json
        try:
            meters = max(20.0, min(2000.0, float(meters)))
        except (TypeError, ValueError):
            return
        if abs(meters - DEFAULT_BIND_TOL_M) < 0.5:
            self._route_tol.pop(routeid, None)
        else:
            self._route_tol[routeid] = meters
        try:
            os.makedirs(os.path.dirname(self._route_tol_path()), exist_ok=True)
            with open(self._route_tol_path(), "w", encoding="utf-8") as fh:
                json.dump({str(k): v for k, v in self._route_tol.items()}, fh)
        except Exception:                       # noqa: BLE001  # nosec B110
            pass
        # keep the on-map bind-width corridor in step with the edit
        try:
            from . import bind_corridor
            bind_corridor.update_route(getattr(self, "_bind_corridor_layer", None),
                                       routeid, meters)
        except Exception:                       # noqa: BLE001  # nosec B110
            pass

    def _ensure_bind_corridor(self, gpkg):
        """Build + add the 'Route bind-width' corridor layer once, switched off (the
        user toggles it in the Layers panel). Best-effort — never blocks binding."""
        if getattr(self, "_bind_corridor_layer", None) is not None:
            return
        try:
            from . import bind_corridor
            lyr = bind_corridor.build_layer(gpkg, self._route_tol,
                                            DEFAULT_BIND_TOL_M)
            if lyr is None:
                return
            QgsProject.instance().addMapLayer(lyr, True)
            root = QgsProject.instance().layerTreeRoot()
            node = root.findLayer(lyr.id())
            if node is not None:
                node.setItemVisibilityChecked(False)   # off by default
            self._bind_corridor_layer = lyr
        except Exception:                       # noqa: BLE001  # nosec B110
            pass

    # ---- Portrayal control ----------------------------------------------
    def _init_portrayal_from_project(self):
        """Match dropdowns to saved project variables, then ensure both are set."""
        try:
            cv = QgsProject.instance().customVariables()
        except Exception:
            cv = {}

        try:
            ival = int(cv.get(SPEEDVECTOR_VAR))
        except Exception:
            ival = None
        svals = [v for _, v in SPEEDVECTOR_OPTIONS]
        if ival in svals:
            self.speedvector_combo.setCurrentIndex(svals.index(ival))
        else:
            self.speedvector_combo.setCurrentIndex(svals.index(1))  # default 1 min

        bval = cv.get(SPEEDVECTOR_BEARING_VAR)
        bvals = [v for _, v in SPEEDVECTOR_BEARING_OPTIONS]
        if bval in bvals:
            self.bearing_combo.setCurrentIndex(bvals.index(bval))

        try:
            aval = int(cv.get(SYMBOL_AMPLIFY_VAR))
        except Exception:
            aval = None
        avals = [v for _, v in SYMBOL_AMPLIFY_OPTIONS]
        if aval in avals:
            self.amplify_combo.setCurrentIndex(avals.index(aval))
        else:
            self.amplify_combo.setCurrentIndex(0)   # default x1

        try:
            tval = int(cv.get(TRAIL_VAR))
        except Exception:
            tval = None
        tvals = [v for _, v in TRAIL_OPTIONS]
        if tval in tvals:
            self.trails_combo.setCurrentIndex(tvals.index(tval))
        else:
            self.trails_combo.setCurrentIndex(0)    # default off

        # Guarantee variables exist and match the boxes.
        self._on_speedvector_changed()
        self._on_bearing_changed()
        self._on_amplify_changed()
        self._on_trails_changed()

    def _on_speedvector_changed(self):
        """Write the chosen speed-vector look-ahead minutes to @AIS_speedVector."""
        idx = self.speedvector_combo.currentIndex()
        value = SPEEDVECTOR_OPTIONS[idx][1] if 0 <= idx < len(SPEEDVECTOR_OPTIONS) else 0
        self._set_project_var(SPEEDVECTOR_VAR, value)

    def _on_bearing_changed(self):
        """Write the speed-vector bearing field name to @AIS_speedVectorBearing."""
        idx = self.bearing_combo.currentIndex()
        value = (SPEEDVECTOR_BEARING_OPTIONS[idx][1]
                 if 0 <= idx < len(SPEEDVECTOR_BEARING_OPTIONS) else "vessel_courseOverGround")
        self._set_project_var(SPEEDVECTOR_BEARING_VAR, value)

    def _on_amplify_changed(self):
        """Write the SVG symbol amplifier factor to @AIS_symbolAmplify and
        repaint the vessels."""
        idx = self.amplify_combo.currentIndex()
        value = (SYMBOL_AMPLIFY_OPTIONS[idx][1]
                 if 0 <= idx < len(SYMBOL_AMPLIFY_OPTIONS) else 1)
        self._set_project_var(SYMBOL_AMPLIFY_VAR, value)
        self._repaint_vessels()

    def _on_trails_changed(self):
        """Write the trail length to @AIS_trail and switch the trail layer to it."""
        idx = self.trails_combo.currentIndex()
        n = TRAIL_OPTIONS[idx][1] if 0 <= idx < len(TRAIL_OPTIONS) else 0
        self._set_project_var(TRAIL_VAR, n)
        try:
            self.trails.set_length(n)
        except Exception:  # nosec B110
            pass

    def is_chime(self):
        """Whether the audible chime is enabled (read by the Guardian dock)."""
        try:
            return self.chime_chk.isChecked()
        except Exception:
            return False

    def _repaint_vessels(self):
        """Trigger a repaint of the live vessel layer (best-effort)."""
        try:
            if self.vessels is not None and self.vessels.layer is not None:
                self.vessels.layer.triggerRepaint()
        except Exception:  # nosec B110
            pass

    def _set_project_var(self, name, value):
        """Set a QGIS project custom variable (best-effort)."""
        try:
            QgsExpressionContextUtils.setProjectVariable(
                QgsProject.instance(), name, value
            )
        except Exception:  # nosec B110
            pass

    # ---- Helpers ---------------------------------------------------------
    def _status_text(self, state):
        """Compose the status line: a state word plus running line/valid/AIS counts."""
        return (f"{state}    |    lines: {self._total}    "
                f"valid NMEA: {self._valid}    AIS: {self._ais}")

    def shutdown(self):
        """Stop the reader/scanner. Called by the dock/plugin on unload."""
        try:
            self._rate_timer.stop()
        except Exception:  # nosec B110
            pass
        try:
            self._routes_timer.stop()
        except Exception:  # nosec B110
            pass
        if self.scanner:
            self.scanner.stop()
            self.scanner = None
        if self._render_timer is not None:
            try:
                self._render_timer.disconnect_canvas()
            except Exception:  # nosec B110
                pass
            self._render_timer = None
        self.disconnect_source()
        self._stop_history()
