"""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,
)

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
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 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
# 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"
# 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()
    aisRecord = pyqtSignal(object)      # every decoded record -> Guardian engine
    streamHealthReport = pyqtSignal(int, float)   # (num_outages, mean_downtime_s)
    fullStopToggled = pyqtSignal()      # Full stop / resume button

    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
        self.reader = None
        self.scanner = None
        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._retired = []               # readers being torn down (held until finished)
        self.trails = TrailLayer()
        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.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)
        try:
            self.rate_graph.update_from(self.health)
        except Exception:
            pass
        rep = self.health.hourly_report(now)
        if rep is not None:
            self.streamHealthReport.emit(rep[0], rep[1])

    # ---- 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._src_group = QButtonGroup(self)
        self._src_group.addButton(self.rb_serial)
        self._src_group.addButton(self.rb_tcp)
        type_row.addWidget(self.rb_serial)
        type_row.addWidget(self.rb_tcp)
        type_row.addStretch(1)
        sbv.addLayout(type_row)
        self.rb_serial.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)

        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)
        self.plot_chk = QCheckBox("Plot vessels on map")
        self.plot_chk.setChecked(True)
        self.plot_chk.setToolTip(
            "Write decoded vessel positions to the live 'liveFeedAIS' map layer"
        )
        ctrl.addWidget(self.plot_chk)
        ctrl.addStretch(1)
        layout.addLayout(ctrl)

        # Settings, packed into two tight columns: the speed-vector pair
        # (vectorlength @AIS_speedVector / vectorsource @AIS_speedVectorBearing)
        # on the left, symbol amplifier (@AIS_symbolAmplify) + trails on the
        # right; the two toggles share the next row, then 'Set own position'.
        portrayal_box = QGroupBox("Settings")
        pgrid = QGridLayout(portrayal_box)
        pgrid.setHorizontalSpacing(8)
        pgrid.setVerticalSpacing(6)
        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("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.setColumnMinimumWidth(2, 18)      # small gap between the two pairs
        pgrid.setColumnStretch(5, 1)            # extra width to the far right edge

        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("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)

        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, 2, 0, 1, 3)

        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, 2, 3, 1, 3)

        # 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, 3, 0, 1, 3)

        layout.addWidget(portrayal_box)
        self._init_portrayal_from_project()

        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_chk.setChecked(False)
            self.plot_chk.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)

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

    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."""
        if 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.health = StreamHealth()        # fresh per-session baseline
        self.log.clear()

        self.reader = reader
        self.reader.lineReceived.connect(self.on_line)
        self.reader.errorOccurred.connect(self.on_error)
        self.reader.connected.connect(self.on_connected)
        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}…"))

        # 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_chk.isChecked() 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:
            pass
        self.disconnect_source()
        try:
            self._rate_timer.stop()
        except Exception:
            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:
            pass
        try:
            self.vessels.ensure_layer()
        except Exception:
            pass
        try:
            self._on_trails_changed()       # re-creates the trail layer if on
        except Exception:
            pass
        if getattr(self, "_was_live", False):
            try:
                self.connect_source()
            except Exception:
                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
        kind = classify(core)
        valid = is_valid_nmea(core)
        if valid:
            self._valid += 1
        is_ais = kind == "ais"
        if is_ais:
            self._ais += 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 _handle_ais(self, line):
        """Reassemble + decode an AIS sentence, update the preview, and plot it."""
        rec = self.assembler.push(line)  # None until a full message is ready
        if not rec:
            return

        # Stamp the fairway binding before anything downstream consumes the
        # record (Guardian engine, Vessels tab) so they all see fairway fields.
        if rec.get("has_position"):
            self._bind_fairway(rec)

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

        if rec.get("has_position"):
            self.decoded_label.setText(
                f'Decoded — MMSI {rec["mmsi"]}   type {rec["msg_type"]}   '
                f'lat {rec["lat"]:.5f}   lon {rec["lon"]:.5f}'
                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)
                )

        if self.plot_chk.isChecked():
            try:
                self.vessels.update(rec)
                if rec.get("has_position"):
                    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()
        except Exception as exc:
            self._fairway_failed = True
            try:
                self.decoded_label.setText(f"Fairway binding unavailable: {exc}")
            except Exception:
                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 ""
            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"] = ""
        except Exception:
            pass            # binding must never disrupt decoding/plotting

    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:
            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
                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
                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:
                            pass
                    self._custom_added = True
            except Exception:                   # noqa: BLE001
                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
            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
            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
            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:
            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:
            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:
            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:
            pass
        try:
            self._routes_timer.stop()
        except Exception:
            pass
        if self.scanner:
            self.scanner.stop()
            self.scanner = None
        self.disconnect_source()
