"""AIS Guardian dockable panel: a QgsDockWidget holding three tabs.

  Tab 1  AIS Guardian   — the three alarm switches, the coverage-mapping toggle,
                          the chime toggle, the green/red status lights, and the
                          Alarm-3 watched-vessel list.
  Tab 2  Event log      — the persistent guardian_events table (read from the
                          store), with CSV export.
  Tab 3  AIS stream     — the existing StreamWidget (source pick / connect /
                          live feed / portrayal).

This increment builds the shell and wires the green 'feed live' light to the
stream's connection state. Feeding decoded messages into the detection engine
(which drives the red light, the chime, the event log and the coverage map) is
the next increment; the switches expose their state for that wiring.
"""
import os

from qgis.PyQt.QtCore import pyqtSignal, QTimer, Qt
from qgis.PyQt.QtWidgets import (
    QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QCheckBox,
    QGroupBox, QPushButton, QListWidget, QTableWidget, QTableWidgetItem,
    QHeaderView, QFileDialog, QInputDialog, QAbstractItemView, QApplication,
    QDialog, QLineEdit, QRadioButton, QButtonGroup, QDialogButtonBox, QMessageBox,
    QComboBox, QMenu, QScrollArea, QFrame,
)
import re
import time

from .guardian_core import GuardianEngine, Event

try:
    from qgis.gui import QgsCollapsibleGroupBox
except Exception:                       # pragma: no cover
    QgsCollapsibleGroupBox = QGroupBox

try:
    from qgis.PyQt.QtGui import QColor
except Exception:                       # pragma: no cover
    QColor = None

try:
    from qgis.gui import QgsMapToolEmitPoint
except Exception:                       # pragma: no cover
    QgsMapToolEmitPoint = None

from . import proximity
from . import my_fleet
from . import units

# friendly labels for event-log "kind" column
_KIND_LABEL = {
    "entry": "ENTRY", "exit": "EXIT",
    "presence_in": "COVERAGE IN", "presence_out": "COVERAGE OUT",
    "nav": "NAV-STATUS", "stream_health": "STREAM", "info": "INFO",
    "lost_target": "LOST TARGET",
}


def _layer_alive(layer):
    """True if a layer's Python wrapper still backs a live C++ object.

    Deleting a layer from the project destroys the C++ object but leaves the
    Python wrapper dangling; calling any method on it (even .id()) then raises
    RuntimeError. This lets callers test a held reference safely instead of
    crashing inside a timer callback.
    """
    if layer is None:
        return False
    try:
        layer.id()
        return True
    except RuntimeError:
        return False


def _parse_ids(raw):
    """Pull every run of digits from a stored group-ids value into a list of ints."""
    return [int(x) for x in re.findall(r"\d+", str(raw or ""))]


def _apply_zone_attrs(layer, fid, values):
    """Write zone attributes into the feature. Commits only if we had to open an
    edit session ourselves (the digitizing path is already editing)."""
    started = False
    try:
        if not layer.isEditable():
            layer.startEditing(); started = True
        for key, val in values.items():
            idx = layer.fields().indexOf(key)
            if idx >= 0:
                layer.changeAttributeValue(fid, idx, val)
        if started:
            layer.commitChanges()
    except Exception:
        if started:
            try:
                layer.rollBack()
            except Exception:  # nosec B110
                pass

try:
    from qgis.gui import QgsDockWidget
except Exception:                       # pragma: no cover - non-QGIS import guard
    QgsDockWidget = QWidget             # lets the module import for py_compile


def _resize_to_stretch(header):
    """Make a QHeaderView stretch its sections (Qt5/Qt6 enum-safe)."""
    mode = getattr(getattr(QHeaderView, "ResizeMode", QHeaderView), "Stretch", None)
    if mode is not None:
        header.setSectionResizeMode(mode)


class StatusLight(QWidget):
    """A small coloured indicator dot (on/off), drawn with a stylesheet so there
    are no QPainter enum differences across Qt versions."""
    def __init__(self, on_color, label="", stretch=True, parent=None):
        """Build the indicator dot in its on-colour with an optional caption,
        starting in the off state."""
        super().__init__(parent)
        self._on_color = on_color
        lay = QHBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 0)
        self._dot = QLabel()
        self._dot.setFixedSize(14, 14)
        lay.addWidget(self._dot)
        if label:
            lay.addWidget(QLabel(label))
        if stretch:
            lay.addStretch(1)
        self.set_on(False)

    def set_on(self, on):
        """Light the dot (on-colour) or dim it (grey)."""
        color = self._on_color if on else "#444"
        self._dot.setStyleSheet(
            f"background-color:{color}; border-radius:7px; border:1px solid #222;")


class GuardGroupsEditor(QWidget):
    """Reusable named MMSI sets, backed by the store. A group can be bound to
    one or more guard zones (target_mode='groups') and its members are also the
    vessels watched for Alarm 3. A single-ship guard is just a one-member group."""
    changed = pyqtSignal()

    def __init__(self, store, parent=None):
        """Build the two-column groups/members editor backed by the store."""
        super().__init__(parent)
        self.store = store
        self._gids = []
        self._mmsis = []

        box = QGroupBox("Guard groups")
        outer = QVBoxLayout(box)
        row = QHBoxLayout()

        # groups column
        gcol = QVBoxLayout()
        gcol.addWidget(QLabel("Groups"))
        self.groups = QListWidget()
        self.groups.currentRowChanged.connect(lambda _r: self.refresh_members())
        gcol.addWidget(self.groups)
        gb = QHBoxLayout()
        b_new = QPushButton("New…"); b_del = QPushButton("Delete")
        b_new.clicked.connect(self._new_group); b_del.clicked.connect(self._del_group)
        gb.addWidget(b_new); gb.addWidget(b_del)
        gcol.addLayout(gb)
        row.addLayout(gcol)

        # members column
        mcol = QVBoxLayout()
        mcol.addWidget(QLabel("MMSI in group"))
        self.members = QListWidget()
        mcol.addWidget(self.members)
        mb = QHBoxLayout()
        b_add = QPushButton("Add MMSI…"); b_rm = QPushButton("Remove")
        b_add.clicked.connect(self._add_member); b_rm.clicked.connect(self._del_member)
        mb.addWidget(b_add); mb.addWidget(b_rm)
        mcol.addLayout(mb)
        row.addLayout(mcol)

        outer.addLayout(row)
        lay = QVBoxLayout(self)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.addWidget(box)
        self.refresh_groups()

    def _current_gid(self):
        """group_id of the selected group row, or None."""
        r = self.groups.currentRow()
        return self._gids[r] if 0 <= r < len(self._gids) else None

    def refresh_groups(self):
        """Reload the groups list from the store and refresh the member pane."""
        self.groups.clear(); self._gids = []
        if self.store is None:
            return
        try:
            for gid, name in self.store.list_groups():
                self.groups.addItem(name); self._gids.append(gid)
        except Exception:  # nosec B110
            pass
        if self._gids:
            self.groups.setCurrentRow(0)
        self.refresh_members()

    def refresh_members(self):
        """Reload the MMSI list for the currently selected group."""
        self.members.clear(); self._mmsis = []
        gid = self._current_gid()
        if gid is None or self.store is None:
            return
        try:
            for mmsi, name in self.store.list_members(gid):
                self.members.addItem(mmsi + (f"   ({name})" if name else ""))
                self._mmsis.append(mmsi)
        except Exception:  # nosec B110
            pass

    def _new_group(self):
        """Prompt for a name and create a new guard group."""
        name, ok = QInputDialog.getText(self, "New guard group", "Group name:")
        if ok and name.strip():
            try:
                self.store.create_group(name.strip())
            except Exception:  # nosec B110
                pass
            self.refresh_groups(); self.changed.emit()

    def _del_group(self):
        """Delete the selected guard group (and its members via cascade)."""
        gid = self._current_gid()
        if gid is not None:
            try:
                self.store.delete_group(gid)
            except Exception:  # nosec B110
                pass
            self.refresh_groups(); self.changed.emit()

    def _add_member(self):
        """Prompt for an MMSI (digits only) and add it to the selected group."""
        gid = self._current_gid()
        if gid is None:
            return
        text, ok = QInputDialog.getText(self, "Add MMSI", "MMSI:")
        if ok and text.strip().isdigit():
            try:
                self.store.add_member(gid, text.strip())
            except Exception:  # nosec B110
                pass
            self.refresh_members(); self.changed.emit()

    def _del_member(self):
        """Remove the selected MMSI from the current group."""
        gid = self._current_gid()
        r = self.members.currentRow()
        if gid is not None and 0 <= r < len(self._mmsis):
            try:
                self.store.remove_member(gid, self._mmsis[r])
            except Exception:  # nosec B110
                pass
            self.refresh_members(); self.changed.emit()


class Alarm3GroupList(QWidget):
    """Per-group Alarm-3 control, collapsed by default. Each group has an enable
    checkbox and a direction dropdown: 'to dynamic' (getting under way, static->
    dynamic) or 'to static' (mooring/arriving, dynamic->static). Persisted per
    group as 0=off / 1=to-dynamic / 2=to-static."""
    changed = pyqtSignal()

    def __init__(self, store, parent=None):
        """Build the collapsed per-group Alarm-3 panel backed by the store."""
        super().__init__(parent)
        self.store = store
        outer = QVBoxLayout(self)
        outer.setContentsMargins(16, 0, 0, 0)
        self.box = QgsCollapsibleGroupBox("apply Alarm 3 to groups")
        try:
            self.box.setCollapsed(True)
        except Exception:  # nosec B110
            pass
        self._lay = QVBoxLayout(self.box)
        outer.addWidget(self.box)
        self._rows = []        # [(widget, gid, checkbox, combo)]
        self.refresh()

    def refresh(self):
        """Rebuild one enable-checkbox + direction-dropdown row per guard group
        from the store's saved Alarm-3 mode."""
        for w, _g, _cb, _co in self._rows:
            w.setParent(None); w.deleteLater()
        self._rows = []
        groups = []
        if self.store is not None:
            try:
                groups = self.store.list_groups_full()
            except Exception:
                groups = []
        if not groups:
            lbl = QLabel("(no groups yet)")
            self._lay.addWidget(lbl)
            self._rows.append((lbl, None, None, None))
            return
        for gid, name, mode in groups:
            row = QWidget()
            rl = QHBoxLayout(row); rl.setContentsMargins(0, 0, 0, 0)
            cb = QCheckBox(name); cb.setChecked(mode != 0)
            combo = QComboBox(); combo.addItems(["to dynamic", "to static"])
            combo.setCurrentIndex(1 if mode == 2 else 0)
            cb.toggled.connect(lambda _on, g=gid: self._apply(g))
            combo.currentIndexChanged.connect(lambda _i, g=gid: self._apply(g))
            rl.addWidget(cb); rl.addWidget(combo); rl.addStretch(1)
            self._lay.addWidget(row)
            self._rows.append((row, gid, cb, combo))

    def _apply(self, gid):
        """Persist a group's Alarm-3 mode (0 off / 1 to-dynamic / 2 to-static)
        from its row widgets, then signal the change."""
        for _w, g, cb, combo in self._rows:
            if g == gid and cb is not None:
                mode = 0 if not cb.isChecked() else (2 if combo.currentIndex() == 1 else 1)
                if self.store is not None:
                    try:
                        self.store.set_group_alarm3(gid, mode)
                    except Exception:  # nosec B110
                        pass
                break
        self.changed.emit()


class GuardianControls(QWidget):
    configChanged = pyqtSignal()
    editZoneRequested = pyqtSignal()

    def __init__(self, store=None, parent=None):
        """Build the Guardian controls tab: feed-live light, the three alarm
        switches with their lights, and the guard-groups editor."""
        super().__init__(parent)
        self.store = store
        lay = QVBoxLayout(self)

        # --- status lights ---
        lights = QHBoxLayout()
        self.feed_light = StatusLight("#2e9e4f", "Feed live")     # green
        lights.addWidget(self.feed_light)
        lights.addStretch(1)
        self.btn_edit_zone = QPushButton("Edit selected zone…")
        self.btn_edit_zone.setToolTip("Configure the guard zone selected on the map")
        self.btn_edit_zone.clicked.connect(self.editZoneRequested)
        lights.addWidget(self.btn_edit_zone)
        lay.addLayout(lights)

        # --- alarm switches ---
        alarms = QGroupBox("Alarms")
        av = QVBoxLayout(alarms)
        self.chk_a1 = QCheckBox("Alarm 1 — Guard zone (entry / within)")
        self.chk_a2 = QCheckBox("Alarm 2 — Exit / left-zone")
        self.chk_a3 = QCheckBox("Log vessel events (coverage in/out + nav-status)")
        self.a1_light = StatusLight("#d63b3b", stretch=False)
        self.a2_light = StatusLight("#d63b3b", stretch=False)
        for chk, light in ((self.chk_a1, self.a1_light),
                           (self.chk_a2, self.a2_light)):
            chk.toggled.connect(self.configChanged)
            row = QHBoxLayout()
            row.addWidget(chk, 1)
            row.addWidget(light)
            av.addLayout(row)
        # the event-logging toggle is log-only (no alarm light)
        self.chk_a3.toggled.connect(self.configChanged)
        av.addWidget(self.chk_a3)
        lay.addWidget(alarms)

        # --- Guard groups (targeting) ---
        self.groups_editor = GuardGroupsEditor(self.store)
        self.groups_editor.changed.connect(self.configChanged)
        lay.addWidget(self.groups_editor)

        lay.addStretch(1)

    # ---- accessors for the engine ----------------------------------------
    def is_alarm1(self):
        """Whether Alarm 1 (zone entry/within) is switched on."""
        return self.chk_a1.isChecked()

    def is_alarm2(self):
        """Whether Alarm 2 (zone exit) is switched on."""
        return self.chk_a2.isChecked()

    def is_alarm3(self):
        """Whether event logging (coverage in/out + nav-status) is switched on."""
        return self.chk_a3.isChecked()

    # ---- light setters ---------------------------------------------------
    def set_feed_live(self, on):
        """Set the green feed-live indicator."""
        self.feed_light.set_on(on)

    def set_alarm_lights(self, active):
        """active: {'a1':bool,'a2':bool} from engine.active_alarms()."""
        self.a1_light.set_on(bool(active.get("a1")))
        self.a2_light.set_on(bool(active.get("a2")))


class GuardianLog(QWidget):
    COLS = ["Time (UTC)", "Kind", "Zone", "MMSI", "Lat", "Lon", "Detail"]
    showTrackRequested = pyqtSignal(str, float)   # (mmsi, event_time epoch)

    def __init__(self, parent=None):
        """Build the read-only event-log table with Refresh / Export controls."""
        super().__init__(parent)
        self.store = None
        self._rows = []                 # cached event dicts, row-aligned
        lay = QVBoxLayout(self)

        self.table = QTableWidget(0, len(self.COLS))
        self.table.setHorizontalHeaderLabels(self.COLS)
        _resize_to_stretch(self.table.horizontalHeader())
        try:
            no_edit = getattr(getattr(QAbstractItemView, "EditTrigger",
                              QAbstractItemView), "NoEditTriggers")
            self.table.setEditTriggers(no_edit)
        except Exception:  # nosec B110
            pass
        try:
            policy = Qt.ContextMenuPolicy.CustomContextMenu
        except AttributeError:                  # Qt5 unscoped enum
            policy = Qt.CustomContextMenu
        self.table.setContextMenuPolicy(policy)
        self.table.customContextMenuRequested.connect(self._context_menu)
        lay.addWidget(self.table)

        btns = QHBoxLayout()
        self.btn_refresh = QPushButton("Refresh")
        self.btn_export = QPushButton("Export CSV…")
        self.btn_refresh.clicked.connect(self.refresh)
        self.btn_export.clicked.connect(self._export)
        btns.addWidget(self.btn_refresh)
        btns.addWidget(self.btn_export)
        self.coverage_status = QLabel("Coverage: off")
        btns.addWidget(self.coverage_status)
        btns.addStretch(1)
        lay.addLayout(btns)

    def set_coverage_status(self, text):
        """Update the coverage-status caption beside the export button."""
        self.coverage_status.setText(text)

    def set_store(self, store):
        """Attach the persistent store and load its events into the table."""
        self.store = store
        self.refresh()

    def refresh(self):
        """Reload the event-log table from the store (newest first)."""
        if self.store is None:
            return
        try:
            rows = self.store.read_events()
        except Exception:
            rows = []
        self._rows = rows
        self.table.setRowCount(len(rows))
        for r, ev in enumerate(rows):
            kind = _KIND_LABEL.get(ev.get("kind"), ev.get("kind"))
            vals = [ev.get("created_utc"), kind, ev.get("zone_name"),
                    ev.get("mmsi"), units.fmt_lat(ev.get("lat")),
                    units.fmt_lon(ev.get("lon")), ev.get("detail")]
            for c, v in enumerate(vals):
                self.table.setItem(r, c, QTableWidgetItem("" if v is None else str(v)))

    def _context_menu(self, pos):
        """Right-click an event with an MMSI -> 'Show track (±5 min)': draw the
        vessel's stored track around the event time."""
        row = self.table.rowAt(pos.y())
        if row < 0 or row >= len(self._rows):
            return
        ev = self._rows[row]
        mmsi = ev.get("mmsi")
        t0 = ev.get("event_time")
        if not mmsi or t0 is None:
            return
        menu = QMenu(self)
        act = menu.addAction("Show track (±5 min)  —  MMSI %s" % mmsi)
        runner = getattr(menu, "exec", None) or getattr(menu, "exec_")
        if runner(self.table.viewport().mapToGlobal(pos)) is act:
            try:
                self.showTrackRequested.emit(str(mmsi), float(t0))
            except Exception:  # nosec B110
                pass

    def _export(self):
        """Prompt for a path and export the whole event log to CSV."""
        if self.store is None:
            return
        path, _ = QFileDialog.getSaveFileName(self, "Export event log",
                                              "ais_guardian_events.csv", "CSV (*.csv)")
        if path:
            try:
                self.store.export_events_csv(path)
            except Exception:  # nosec B110
                pass


class ZoneDialog(QDialog):
    """Our own guard-zone attribute form, with a multi-select group picker and
    save-time failsafes so a zone can't be saved in a state that silently
    suppresses its alarm."""
    def __init__(self, store, attrs, parent=None):
        """Build the guard-zone form pre-filled from `attrs`: name, armed,
        entry/exit alarms, and an All-vessels / guard-groups multi-select."""
        super().__init__(parent)
        self.store = store
        self.setWindowTitle("Guard zone")
        self._group_boxes = []          # [(checkbox, gid)]
        lay = QVBoxLayout(self)

        form = QGridLayout()
        form.addWidget(QLabel("Name:"), 0, 0)
        self.ed_name = QLineEdit(str(attrs.get("zone_name") or ""))
        form.addWidget(self.ed_name, 0, 1)
        lay.addLayout(form)

        self.cb_armed = QCheckBox("Armed")
        self.cb_armed.setChecked(bool(attrs.get("armed", 1)))
        self.cb_entry = QCheckBox("Alarm on entry (Alarm 1)")
        self.cb_entry.setChecked(bool(attrs.get("on_entry", 1)))
        self.cb_exit = QCheckBox("Alarm on exit (Alarm 2)")
        self.cb_exit.setChecked(bool(attrs.get("on_exit", 0)))
        for c in (self.cb_armed, self.cb_entry, self.cb_exit):
            lay.addWidget(c)

        box = QGroupBox("Applies to")
        bv = QVBoxLayout(box)
        self.rb_all = QRadioButton("All vessels")
        self.rb_groups = QRadioButton("Guard groups:")
        grp = QButtonGroup(self)
        grp.addButton(self.rb_all); grp.addButton(self.rb_groups)
        bv.addWidget(self.rb_all); bv.addWidget(self.rb_groups)
        bound = set(_parse_ids(attrs.get("group_ids")))
        try:
            groups = store.list_groups() if store else []
        except Exception:
            groups = []
        for gid, name in groups:
            cb = QCheckBox("    " + name)
            cb.setChecked(gid in bound)
            self._group_boxes.append((cb, gid))
            bv.addWidget(cb)
        if not groups:
            bv.addWidget(QLabel("    (no groups yet — create one below the alarms)"))
        lay.addWidget(box)

        mode = str(attrs.get("target_mode") or "all").lower()
        (self.rb_groups if mode.startswith("group") else self.rb_all).setChecked(True)
        self.rb_groups.toggled.connect(self._sync_enabled)
        self._sync_enabled()

        SB = getattr(QDialogButtonBox, "StandardButton", QDialogButtonBox)
        bb = QDialogButtonBox(SB.Ok | SB.Cancel)
        bb.accepted.connect(self._on_ok)
        bb.rejected.connect(self.reject)
        lay.addWidget(bb)

    def _sync_enabled(self):
        """Enable the group checkboxes only when 'Guard groups' is selected."""
        on = self.rb_groups.isChecked()
        for cb, _g in self._group_boxes:
            cb.setEnabled(on)

    def _on_ok(self):
        """Accept the dialog, but block save-time states that would silently make
        the zone never alarm (group mode with no group, or no entry/exit)."""
        # failsafe: a group-targeted zone with no group bound would never fire
        if self.rb_groups.isChecked() and not any(cb.isChecked() for cb, _ in self._group_boxes):
            QMessageBox.warning(self, "Guard zone",
                                "Pick at least one guard group, or choose "
                                "'All vessels'. A group zone with no group bound "
                                "would never raise an alarm.")
            return
        # failsafe: a zone with neither entry nor exit does nothing
        if not self.cb_entry.isChecked() and not self.cb_exit.isChecked():
            QMessageBox.warning(self, "Guard zone",
                                "Enable alarm on entry and/or exit, otherwise the "
                                "zone is monitored but never alarms.")
            return
        self.accept()

    def values(self):
        """Collect the form into the zone attribute dict written back to the layer."""
        gids = [gid for cb, gid in self._group_boxes if cb.isChecked()]
        return {
            "zone_name": self.ed_name.text().strip() or None,
            "armed": 1 if self.cb_armed.isChecked() else 0,
            "on_entry": 1 if self.cb_entry.isChecked() else 0,
            "on_exit": 1 if self.cb_exit.isChecked() else 0,
            "target_mode": "groups" if self.rb_groups.isChecked() else "all",
            "group_ids": ",".join(str(g) for g in gids),
        }


class NearbyTab(QWidget):
    """'Vessels' tab. A Nearby/Database toggle switches the table:

      * Nearby   — live proximity to own (static) position: MMSI, name, bearing,
                   distance, CPA, TCPA; nearest first; red name while alarming;
                   click a row to zoom the map to that vessel.
      * Database — everything in the local vessel registry (vessel_names.xml):
                   MMSI, name, callsign, Vessel ID, length, beam, type.
    """
    COLS_NEARBY = ["MMSI", "Vessel name", "Bearing", "Distance",
                   "Fairway", "Flow", "Along route"]
    COLS_DB = ["MMSI", "Vessel name", "Vessel callsign", "Vessel ID",
               "Vessel Length", "Vessel Beam", "Vessel Type"]

    setOwnPositionRequested = pyqtSignal()
    zoomToVessel = pyqtSignal(float, float)
    modeChanged = pyqtSignal()
    memberAddedToGroup = pyqtSignal(str, str)   # (mmsi, group name)
    pastVoyagesRequested = pyqtSignal(str, str)  # (mmsi, vessel name)
    fleetChanged = pyqtSignal()                  # my-fleet membership changed

    def __init__(self, store=None, parent=None):
        """Build the Vessels tab: a Nearby/Register toggle, a search box (Register
        only), and the table with row-click zoom + right-click 'copy to group'."""
        super().__init__(parent)
        self.store = store
        self._row_pos = {}              # table row -> (lon, lat) [nearby only]
        lay = QVBoxLayout(self)

        top = QHBoxLayout()
        # Nearby | Database | Set own position | Own position
        self.rb_nearby = QRadioButton("Nearby")
        self.rb_nearby.setChecked(True)
        self.rb_db = QRadioButton("Register")
        self.rb_fleet = QRadioButton("My fleet")
        self._mode_group = QButtonGroup(self)
        self._mode_group.addButton(self.rb_nearby)
        self._mode_group.addButton(self.rb_db)
        self._mode_group.addButton(self.rb_fleet)
        top.addWidget(self.rb_nearby)
        top.addWidget(self.rb_db)
        top.addWidget(self.rb_fleet)
        self.rb_nearby.toggled.connect(self._apply_mode)
        self.rb_fleet.toggled.connect(self._apply_mode)
        self.own_label = QLabel("Own position: not set")
        top.addWidget(self.own_label, 1)
        lay.addLayout(top)

        # search row (database mode only) — filters the table as you type
        self._db_sig = None
        self.search_row = QWidget()
        srow = QHBoxLayout(self.search_row)
        srow.setContentsMargins(0, 0, 0, 0)
        srow.addWidget(QLabel("Search"))
        self.search = QLineEdit()
        self.search.setPlaceholderText("MMSI, name, callsign, type…")
        self.search.setClearButtonEnabled(True)
        self.search.textChanged.connect(self._apply_filter)
        srow.addWidget(self.search, 1)
        self.search_row.setVisible(False)
        lay.addWidget(self.search_row)

        self.table = QTableWidget(0, len(self.COLS_NEARBY))
        self.table.setHorizontalHeaderLabels(self.COLS_NEARBY)
        self.table.verticalHeader().setVisible(False)
        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.table.setSelectionMode(QAbstractItemView.SingleSelection)
        hh = self.table.horizontalHeader()
        try:
            hh.setSectionResizeMode(QHeaderView.Stretch)
        except Exception:  # nosec B110
            pass
        try:
            hh.sortIndicatorChanged.connect(lambda *_: self._apply_filter())
        except Exception:  # nosec B110
            pass
        self.table.cellClicked.connect(self._row_clicked)
        try:
            policy = Qt.ContextMenuPolicy.CustomContextMenu
        except AttributeError:                  # Qt5 unscoped enum
            policy = Qt.CustomContextMenu
        self.table.setContextMenuPolicy(policy)
        self.table.customContextMenuRequested.connect(self._context_menu)
        lay.addWidget(self.table, 1)

    # ---- mode -----------------------------------------------------------
    def is_database_mode(self):
        """True when the Register (database) view is selected."""
        return self.rb_db.isChecked()

    def is_fleet_mode(self):
        """True when the My-fleet view is selected."""
        return self.rb_fleet.isChecked()

    def _apply_mode(self, *_):
        """Swap the table columns/sorting/search to match the Nearby / Register /
        My-fleet toggle and clear the current rows. Register and My-fleet share the
        same columns (and search + sort); Nearby is the live-proximity view."""
        db_like = self.rb_db.isChecked() or self.rb_fleet.isChecked()
        cols = self.COLS_DB if db_like else self.COLS_NEARBY
        self.table.setSortingEnabled(False)         # repopulated programmatically
        self.table.setColumnCount(len(cols))
        self.table.setHorizontalHeaderLabels(cols)
        self.table.setRowCount(0)
        self._row_pos = {}
        self._db_sig = None                         # force a rebuild on next fill
        self.search_row.setVisible(db_like)         # search in Register / My-fleet
        self.table.setSortingEnabled(db_like)       # column sort in Register / fleet
        if db_like:
            try:
                so = getattr(Qt, "SortOrder", Qt)
                self.table.horizontalHeader().setSortIndicator(1, so.AscendingOrder)
            except Exception:  # nosec B110
                pass
        self.modeChanged.emit()

    def _apply_filter(self, *_):
        """Hide Register / My-fleet rows that don't match the search box
        (case-insensitive substring across all columns). Re-applied after rebuilds
        and re-sorts."""
        if not (self.rb_db.isChecked() or self.rb_fleet.isChecked()):
            return
        q = self.search.text().strip().lower()
        for r in range(self.table.rowCount()):
            if not q:
                self.table.setRowHidden(r, False)
                continue
            hit = False
            for c in range(self.table.columnCount()):
                it = self.table.item(r, c)
                if it is not None and q in it.text().lower():
                    hit = True
                    break
            self.table.setRowHidden(r, not hit)

    def set_own_label(self, lat, lon):
        """Show the static receiver position (or 'not set') in the header."""
        if lat is None or lon is None:
            self.own_label.setText("Own position: not set")
        else:
            self.own_label.setText("Own position: %s  (static)"
                                   % units.fmt_position(lat, lon))

    def prompt_pick(self, on):
        """No-op: the Set-own-position button now lives on the stream's first page."""
        pass        # the Set-own-position button now lives on the first page

    def _row_clicked(self, row, _col):
        """Clicking a Nearby row zooms the map to that vessel's position."""
        pos = self._row_pos.get(row)        # only populated in nearby mode
        if pos is not None:
            self.zoomToVessel.emit(pos[0], pos[1])

    def _context_menu(self, pos):
        """Right-click a vessel row: copy to a guard group, add/remove from My
        fleet, or list past voyages. Works in all three modes (column 0 is always
        the MMSI)."""
        row = self.table.rowAt(pos.y())
        if row < 0:
            return
        mmsi_item = self.table.item(row, 0)
        if mmsi_item is None or not mmsi_item.text().strip():
            return
        mmsi = mmsi_item.text().strip()
        name_item = self.table.item(row, 1)
        name = name_item.text().strip() if name_item is not None else ""

        menu = QMenu(self)
        action_gid = {}
        if self.store is not None:
            try:
                groups = self.store.list_groups()
            except Exception:
                groups = []
            sub = menu.addMenu(f"Copy {mmsi} to guard group")
            if groups:
                for gid, gname in groups:
                    action_gid[sub.addAction(gname)] = (gid, gname)
            else:
                sub.addAction("(no guard groups yet)").setEnabled(False)
        in_fleet = my_fleet.contains(mmsi)
        fleet_act = menu.addAction(
            "Remove from my fleet" if in_fleet else "Add to my fleet")
        menu.addSeparator()
        voy_act = menu.addAction("Past voyages…")
        runner = getattr(menu, "exec", None) or getattr(menu, "exec_")
        chosen = runner(self.table.viewport().mapToGlobal(pos))
        if chosen is voy_act:
            self.pastVoyagesRequested.emit(mmsi, name or "")
        elif chosen is fleet_act:
            if in_fleet:
                my_fleet.remove(mmsi)
            else:
                my_fleet.add(mmsi, name or None)
            self.fleetChanged.emit()
        elif chosen in action_gid and self.store is not None:
            gid, gname = action_gid[chosen]
            try:
                self.store.add_member(gid, mmsi, name or None)
                self.memberAddedToGroup.emit(mmsi, gname)
            except Exception:  # nosec B110
                pass

    @staticmethod
    def _fmt_cpa(cpa, away):
        """Format a CPA distance: 'passed' if receding, else nm or em-dash."""
        if away:
            return "passed"
        return "—" if cpa is None else f"{cpa:.2f} nm"

    @staticmethod
    def _fmt_tcpa(tcpa, away):
        """Format a TCPA: 'passed' if receding, else minutes or em-dash."""
        if away:
            return "passed"
        return "—" if tcpa is None else f"{tcpa:.1f} min"

    @staticmethod
    def _fmt_num(v):
        """Format a numeric cell: integers without a trailing '.0', '' for None."""
        if v is None:
            return ""
        try:
            fv = float(v)
            return str(int(fv)) if fv.is_integer() else f"{fv:g}"
        except Exception:
            return str(v)

    # ---- nearby mode ----------------------------------------------------
    def update_rows(self, own, tracks, alarming):
        """own: (lat, lon) or None; tracks: {mmsi: {lon,lat,sog,cog,name}};
        alarming: set of MMSIs currently triggering an alarm."""
        if self.rb_db.isChecked():
            return
        if own is None:
            self.table.setRowCount(0)
            self._row_pos = {}
            return
        olat, olon = own
        rows = []
        for mmsi, t in tracks.items():
            lon, lat = t.get("lon"), t.get("lat")
            if lon is None or lat is None:
                continue
            dist = proximity.distance_nm(olat, olon, lat, lon)
            brg = proximity.bearing_deg(olat, olon, lat, lon)
            rows.append((dist, mmsi, t.get("name"), brg,
                         t.get("fairway_name"), t.get("fairway_flow"),
                         t.get("fairway_km"), t.get("nav_status"), lon, lat))
        rows.sort(key=lambda r: r[0])               # nearest first

        self.table.setRowCount(len(rows))
        self._row_pos = {}
        for r, (dist, mmsi, name, brg, fwname, flow, fwkm, ns, lon, lat) in \
                enumerate(rows):
            self._row_pos[r] = (lon, lat)
            kmtxt = "" if fwkm in (None, "") else units.fmt_route(float(fwkm))
            # moored (5) / at anchor (1) / aground (6) -> idle, not a flow
            flow_disp = "idle" if ns in (1, 5, 6) else (flow or "—")
            vals = [str(mmsi), name or "", f"{brg:.0f}°", units.fmt_distance(dist),
                    fwname or "—", flow_disp, kmtxt]
            for c, v in enumerate(vals):
                item = QTableWidgetItem(v)
                if c == 1 and QColor is not None and mmsi in alarming:
                    item.setForeground(QColor("#d63b3b"))   # alarming -> red name
                self.table.setItem(r, c, item)

    # ---- database mode --------------------------------------------------
    @staticmethod
    def _db_signature(records):
        """Hash of the registry contents, so the Register table is only rebuilt
        when the data actually changes (preserving sort/filter/selection)."""
        try:
            return hash(tuple(sorted(
                (m, r.get("name"), r.get("callsign"), r.get("imo"),
                 r.get("length"), r.get("beam"), r.get("ship_type"),
                 r.get("ship_type_desc")) for m, r in records.items())))
        except Exception:
            return None

    def update_database(self, records):
        """records: {mmsi: {name,callsign,imo,length,beam,ship_type,
        ship_type_desc}} from the vessel registry. Rebuilds only when the data
        actually changed, so the user's column sort, filter and selection
        survive the periodic refresh."""
        if not self.rb_db.isChecked():
            return
        sig = self._db_signature(records)
        if sig is not None and sig == self._db_sig:
            return                                  # unchanged -> leave as-is
        self._db_sig = sig
        self._fill_db_table(records)

    def update_fleet(self, records):
        """Fill the My-fleet view: the vessels in 'my fleet' (my_fleet.json),
        enriched from the live registry where known, else a name-only stub."""
        if not self.rb_fleet.isChecked():
            return
        fleet = my_fleet.load()                     # {mmsi: stored name}
        recs = {}
        for m, nm in fleet.items():
            rec = dict(records.get(m) or {})
            if not rec.get("name") and nm:
                rec["name"] = nm
            recs[m] = rec
        sig = ("fleet", self._db_signature(recs))
        if sig == self._db_sig:
            return
        self._db_sig = sig
        self._fill_db_table(recs)

    def _fill_db_table(self, records):
        """Fill the shared Register / My-fleet table from {mmsi: rec}."""
        rows = [(rec.get("name") or "", str(mmsi), rec)
                for mmsi, rec in records.items()]
        self.table.setSortingEnabled(False)         # bulk fill, then re-enable
        self.table.setRowCount(len(rows))
        self._row_pos = {}
        for r, (name, mmsi, rec) in enumerate(rows):
            vtype = rec.get("ship_type_desc")
            if not vtype and rec.get("ship_type") is not None:
                vtype = str(rec.get("ship_type"))
            self.table.setItem(r, 0, _NumericItem(str(mmsi), self._as_num(mmsi)))
            self.table.setItem(r, 1, QTableWidgetItem(name))
            self.table.setItem(r, 2, QTableWidgetItem(rec.get("callsign") or ""))
            self.table.setItem(r, 3, _NumericItem(self._fmt_num(rec.get("imo")),
                                                  self._as_num(rec.get("imo"))))
            self.table.setItem(r, 4, _NumericItem(units.fmt_length(rec.get("length")),
                                                  self._as_num(rec.get("length"))))
            self.table.setItem(r, 5, _NumericItem(units.fmt_length(rec.get("beam")),
                                                  self._as_num(rec.get("beam"))))
            self.table.setItem(r, 6, QTableWidgetItem(vtype or ""))
        self.table.setSortingEnabled(True)          # re-applies current sort
        self._apply_filter()

    @staticmethod
    def _as_num(v):
        """Coerce a value to float for numeric sorting; blanks sort as -inf."""
        try:
            return float(v)
        except Exception:
            return float("-inf")                    # blanks sort together


class _NumericItem(QTableWidgetItem):
    """Table cell that sorts by a numeric value while displaying formatted text,
    so columns like Length / Beam / Vessel ID sort 2 < 10 < 100 (not lexically)."""
    def __init__(self, text, value):
        """Display `text` but carry `value` for sorting."""
        super().__init__(text)
        self._v = value

    def __lt__(self, other):
        """Sort by the numeric value, falling back to text order."""
        try:
            return self._v < other._v
        except Exception:
            return super().__lt__(other)


class _OwnPositionPicker(QgsMapToolEmitPoint if QgsMapToolEmitPoint else object):
    """One-shot map tool: the next canvas click reports a point (canvas CRS)."""
    def __init__(self, canvas, on_pick):
        """Map tool whose next canvas click reports a point to `on_pick`."""
        super().__init__(canvas)
        self._on_pick = on_pick

    def canvasReleaseEvent(self, e):
        """Report the clicked map point (canvas CRS) to the callback."""
        try:
            pt = self.toMapCoordinates(e.pos())
        except Exception:
            return
        self._on_pick(pt)


class GuardianDock(QgsDockWidget):
    COVERAGE_SAVE_EVERY = 10            # ticks (~seconds) between coverage saves

    def __init__(self, iface, stream_widget, store=None, parent=None):
        """Assemble the dock: the four tabs, the guard-zone layer, all signal
        wiring (feed light, records -> engine, full-stop, config sync, Vessels
        tab), the startup map UX, the 1 Hz tick, and any persisted state."""
        super().__init__("AIS Watchkeeper", parent)
        self.setObjectName("AisNmeaSourceDock")
        self.iface = iface
        self.stream = stream_widget
        self.store = store
        self.engine = GuardianEngine()
        self._tick_count = 0
        self._first_contact_done = False
        self._cov_layer = None          # single consolidated coverage layer
        self._tracks = {}               # mmsi -> {lon,lat,sog,cog,name} (nearby tab)
        self._own_pos = None            # (lat, lon) of the static receiver
        self._own_marker_layer = None   # map marker showing own position
        self._halted = False            # Full stop state
        self._own_picker = None         # active map tool while picking
        self._prev_maptool = None
        self._own_user_set = False      # user set own position by hand this session
        self._own_gnss_done = False     # a GNSS fix has already seeded own position

        central = QWidget()
        cl = QVBoxLayout(central)
        self.tabs = QTabWidget()
        self.controls = GuardianControls(store=store)
        self.log = GuardianLog()
        self.nearby = NearbyTab(store=store)
        if store is not None:
            self.log.set_store(store)

        self.tabs.addTab(self.stream, "AIS stream")
        self.tabs.addTab(self.controls, "AIS Guardian")
        self.tabs.addTab(self.nearby, "Vessels")
        from .route_table_tab import RoutesTab
        self.routes_tab = RoutesTab(self.stream)
        self.tabs.addTab(self.routes_tab, "Routes")     # after Vessels
        self.tabs.addTab(self.log, "Event log")
        from .history_tab import HistoryTab
        self.history_tab = HistoryTab(self.stream, self.iface)
        self.tabs.addTab(self.history_tab, "History")   # browse the voyage archive
        # '?' help button in the tab-bar corner -> opens the bundled user manual
        self.help_btn = QPushButton("?")
        self.help_btn.setFixedWidth(26)
        self.help_btn.setToolTip("Open the AIS Watchkeeper user manual")
        self.help_btn.clicked.connect(self._open_manual)
        self.tabs.setCornerWidget(self.help_btn)
        cl.addWidget(self.tabs)
        # Wrap the whole panel in a scroll area so a screen (or docked height)
        # shorter than the content gets a vertical scrollbar instead of clipping
        # or squeezing it — matters on small laptops. Horizontal stays off (the
        # content resizes to the panel width); the bar only appears when needed.
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.NoFrame)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
        scroll.setWidget(central)
        self.setWidget(scroll)
        # When floated out into its own window, make that window freely resizable:
        # a bare floating QDockWidget on Windows has no maximize/minimize buttons
        # and its minimum is pinned by the table size-hints, so it feels stuck.
        self._floated_once = False
        try:
            self.topLevelChanged.connect(self._on_top_level_changed)
        except Exception:  # nosec B110
            pass

        # guardZones polygon layer (Alarms 1 & 2) — created alongside the panel,
        # ready for the user to draw into; re-read whenever its edits commit.
        self.zones_layer = None
        self._setup_zone_layer()

        # green 'feed live' light follows the stream connection state
        try:
            self.stream.feedLiveChanged.connect(self.controls.set_feed_live)
            self.stream.feedLiveChanged.connect(self._on_feed_live)
        except Exception:  # nosec B110
            pass
        # every decoded record drives the engine
        self.stream.aisRecord.connect(self._on_record)
        # Full stop / resume button halts or rebuilds the whole plugin
        self.stream.fullStopToggled.connect(self._toggle_full_stop)
        # stream-health hourly report -> event log
        try:
            self.stream.streamHealthReport.connect(self._log_stream_health)
        except Exception:  # nosec B110
            pass
        # a target lost while in coverage (removed after 20 min) -> event log
        try:
            self.stream.lostTarget.connect(self._on_lost_target)
        except Exception:  # nosec B110
            pass
        # the active source changed -> re-key the coverage map to that source
        try:
            self.stream.sourceActivated.connect(self._rekey_coverage)
        except Exception:  # nosec B110
            pass
        # control changes re-sync the engine's switches / groups / zones
        self.controls.configChanged.connect(self._sync_config)
        self.controls.editZoneRequested.connect(self._edit_selected_zone)

        # 'Vessels' tab: own-position picker + row -> zoom + mode toggle
        self.stream.setOwnPositionRequested.connect(self._begin_pick_own)
        # A GNSS fix seen on the live feed (combined receiver) seeds own position
        # once, GNSS-first — unless the user has already set it by hand.
        try:
            self.stream.ownPositionFix.connect(self._on_gnss_seed)
        except Exception:  # nosec B110
            pass
        self.nearby.zoomToVessel.connect(self._zoom_to_vessel)
        self.nearby.modeChanged.connect(self._refresh_vessels_table)
        self.nearby.fleetChanged.connect(self._refresh_vessels_table)
        self.nearby.memberAddedToGroup.connect(self._on_member_added_to_group)
        # history retrieval: event-log 'show track' + register 'past voyages'
        try:
            self.log.showTrackRequested.connect(self._on_show_track)
            self.nearby.pastVoyagesRequested.connect(self._on_past_voyages)
        except Exception:  # nosec B110
            pass
        # units change -> re-render the tables, event log and own-position label
        try:
            units.register_listener(self._on_units_changed)
        except Exception:  # nosec B110
            pass

        # startup map UX: OSM basemap at the bottom, AIS layers above it,
        # project CRS pinned to 3857 so the feed layer can't flip it to 4326
        try:
            from . import map_setup
            map_setup.ensure_osm_basemap()
            map_setup.pin_project_crs("EPSG:3857")
            map_setup.raise_layers_above_basemap(["liveFeedAIS", "guardZones"])
        except Exception:  # nosec B110
            pass

        # periodic tick: grace-expiry events + coverage save/readout
        self._timer = QTimer(self)
        self._timer.setInterval(1000)
        self._timer.timeout.connect(self._on_tick)
        self._timer.start()

        self._restore_persisted_state()
        self._sync_config()

    # ---- Full stop / resume ---------------------------------------------
    def _toggle_full_stop(self):
        """Halt every plugin process (feed, all timers, layer updates) so it can
        sit idle in the background and the user can freely delete the AIS layers;
        pressing again repopulates the layers and resumes full operation."""
        self._halted = not self._halted
        if self._halted:
            try:
                self._timer.stop()
            except Exception:  # nosec B110
                pass
            if self._own_pos is not None and self.store is not None:
                try:
                    self.store.save_coverage(self.engine.coverage)  # don't lose it
                except Exception:  # nosec B110
                    pass
            self.stream.halt_processes()
            self.stream.set_fullstop_label(True)
            try:
                self.log.set_coverage_status("Coverage: halted (Full stop)")
            except Exception:  # nosec B110
                pass
        else:
            self.stream.resume_processes()
            self._repopulate_layers()
            try:
                self._timer.start()
            except Exception:  # nosec B110
                pass
            self.stream.set_fullstop_label(False)
            self._sync_config()

    def _repopulate_layers(self):
        """Recreate the plugin's layers after a Full stop (or after the user
        deleted them while halted): basemap, guard zones, and — if own position
        is set — the coverage layer and own-position marker. The live vessel and
        trail layers are rebuilt by the stream widget."""
        try:
            from . import map_setup
            map_setup.ensure_osm_basemap()
        except Exception:  # nosec B110
            pass
        try:
            self._setup_zone_layer()
        except Exception:  # nosec B110
            pass
        if self._own_pos is not None:
            try:
                self._update_own_marker()
                self._ensure_coverage_layers()
                self._refresh_coverage_layers()
            except Exception:  # nosec B110
                pass
        try:
            from . import map_setup
            map_setup.raise_layers_above_basemap(["liveFeedAIS", "guardZones"])
        except Exception:  # nosec B110
            pass

    def _restore_persisted_state(self):
        """Reload the saved receiver position and resume coverage under it (the
        coverage map and its sourcePosition are anchored to where the receiver
        sits)."""
        if self.store is None:
            return
        try:
            op = self.store.load_own_position()
        except Exception:
            op = None
        if op is not None:
            self._own_pos = op
            self.nearby.set_own_label(op[0], op[1])
            self._update_own_marker()
            self._activate_coverage(op)
        else:
            self.log.set_coverage_status(
                "Coverage: off (set own position in Vessels)")

    # ---- own position + source-stamped coverage --------------------------
    @staticmethod
    def _src_str(own):
        """Receiver position as a 'lat,lon' source key for coverage stamping."""
        if not own:
            return None
        return f"{own[0]:.5f},{own[1]:.5f}"

    def _activate_coverage(self, own):
        """Own position set or changed: re-key coverage to the active source (its
        key includes the receiver position for a local antenna). Kept for the
        own-position callers; the actual work is source-aware."""
        self._rekey_coverage()

    def _rekey_coverage(self):
        """Point the coverage accumulator at the ACTIVE source's key, so each
        source keeps its own footprint: your own antenna on location, a fixed
        internet feed (Kystverket), and a position-scoped feed (aisstream) are
        distinct. A *changed* key saves+freezes the previous source's cells (kept
        in the store) and resumes the new source's. Coverage stays off when the
        key is None — a position-anchored source with no own position set yet."""
        try:
            key = self.stream.coverage_source_key(self._own_pos)
        except Exception:
            key = None
        cov = self.engine.coverage
        if key is None:
            self.log.set_coverage_status(
                "Coverage: off (set own position in Vessels)")
            return
        if getattr(cov, "source", None) != key:
            if self.store is not None and (cov.cells["A"] or cov.cells["B"]):
                try:
                    self.store.save_coverage(cov)        # finalize old source
                except Exception:  # nosec B110
                    pass
            cov.reset()
            cov.source = key
            if self.store is not None:
                try:
                    self.store.load_coverage_into(cov)   # resume this source
                except Exception:  # nosec B110
                    pass
        self.engine.set_master(mapping=True)
        self._ensure_coverage_layers()
        self._refresh_coverage_layers()

    # ---- guard-zones layer ----------------------------------------------
    def _setup_zone_layer(self):
        """Create/load the guardZones layer, install our custom form + native-
        form suppression, and re-sync the engine whenever its edits commit."""
        if self.store is None:
            return
        try:
            from . import guardian_zones as gz
            self.zones_layer = gz.ensure_zone_layer(self.store.zone_layer_path)
            if self.zones_layer is not None:
                gz.configure_form(self.zones_layer, self.store)
                gz.suppress_native_form(self.zones_layer)   # we use our own form
                try:
                    self.zones_layer.featureAdded.connect(self._on_zone_added)
                except Exception:  # nosec B110
                    pass
                for sig in ("afterCommitChanges", "editingStopped"):
                    try:
                        getattr(self.zones_layer, sig).connect(self._sync_config)
                    except Exception:  # nosec B110
                        pass
            try:
                self.controls.groups_editor.changed.connect(self._reconfigure_zone_form)
            except Exception:  # nosec B110
                pass
        except Exception:
            self.zones_layer = None

    def _on_zone_added(self, fid):
        """A polygon was just drawn — give it valid defaults, then open our form."""
        if self.zones_layer is None:
            return
        defaults = {"zone_name": None, "armed": 1, "on_entry": 1, "on_exit": 0,
                    "target_mode": "all", "group_ids": ""}
        _apply_zone_attrs(self.zones_layer, fid, defaults)   # never left inert
        try:
            dlg = ZoneDialog(self.store, defaults, self.iface.mainWindow())
            if (dlg.exec_ if hasattr(dlg, "exec_") else dlg.exec)():
                _apply_zone_attrs(self.zones_layer, fid, dlg.values())
        except Exception:  # nosec B110
            pass
        self._sync_config()

    def _edit_selected_zone(self):
        """Open the ZoneDialog on the zone selected on the map and write back any
        edits, then re-sync the engine."""
        if self.zones_layer is None:
            return
        feats = list(self.zones_layer.selectedFeatures())
        if not feats:
            QMessageBox.information(self.iface.mainWindow(), "Guard zone",
                                    "Select a zone on the map first.")
            return
        f = feats[0]
        keys = ("zone_name", "armed", "on_entry", "on_exit", "target_mode", "group_ids")
        attrs = {}
        for k in keys:
            if self.zones_layer.fields().indexOf(k) >= 0:
                try:
                    attrs[k] = f[k]
                except Exception:  # nosec B110
                    pass
        dlg = ZoneDialog(self.store, attrs, self.iface.mainWindow())
        if (dlg.exec_ if hasattr(dlg, "exec_") else dlg.exec)():
            _apply_zone_attrs(self.zones_layer, f.id(), dlg.values())
            self._sync_config()

    def _reconfigure_zone_form(self):
        """Rebuild the zone form's group picker after the guard groups change."""
        if self.zones_layer is None or self.store is None:
            return
        try:
            from . import guardian_zones as gz
            gz.configure_form(self.zones_layer, self.store)
        except Exception:  # nosec B110
            pass

    def _on_feed_live(self, live):
        """On (dis)connect: keep the project on 3857 with AIS layers on top, and
        re-arm the pan-to-first-contact for the next session when the feed drops."""
        if live:
            # the live layer (4326) was just (re)created; keep the project on 3857
            try:
                from . import map_setup
                map_setup.pin_project_crs("EPSG:3857")
                map_setup.raise_layers_above_basemap(["liveFeedAIS", "guardZones"])
            except Exception:  # nosec B110
                pass
        else:
            self._first_contact_done = False    # re-arm pan for the next session

    def _on_lost_target(self, payload):
        """Log a 'lost target' — a vessel removed from the viewer after 20 min of
        silence while the feed was still live (i.e. in coverage, so a genuine
        loss rather than an expected out-of-coverage departure)."""
        if self.store is None:
            return
        try:
            mmsi, lon, lat = payload
        except Exception:
            return
        try:
            self.store.log_event(Event("lost_target", None, None, str(mmsi),
                                       lon, lat, time.time(), None,
                                       "no position for 20 min (in coverage)"))
            self.log.refresh()
        except Exception:  # nosec B110
            pass

    def _log_stream_health(self, n_outages, mean_downtime):
        """Write the hourly stream-health summary to the event log."""
        if self.store is None:
            return
        detail = f"{n_outages} stream outage(s), mean downtime {mean_downtime:.0f}s"
        try:
            self.store.log_event(Event("stream_health", None, None, None,
                                       None, None, time.time(), None, detail))
            self.log.refresh()
        except Exception:  # nosec B110
            pass

    # ---- engine configuration -------------------------------------------
    def _sync_config(self):
        """Push the current UI/store config into the engine: master switches,
        guard-group membership and the drawn zones; coverage mapping follows
        whether an own position is set."""
        self.engine.set_master(
            a1=self.controls.is_alarm1(), a2=self.controls.is_alarm2(),
            a3=self.controls.is_alarm3(), mapping=(self._own_pos is not None))
        members = {}
        if self.store is not None:
            try:
                members = self.store.group_members_map()
            except Exception:
                members = {}
        self.engine.set_group_members(members)
        self.engine.set_zones(self._load_zones())
        if self._own_pos is not None:
            self._ensure_coverage_layers()

    def _ensure_coverage_layers(self):
        """Add the single consolidated coverage layer (default unchecked) when
        the user opts to build the coverage map. One layer holds both classes,
        distinguished by the transponderClass attribute (A or B)."""
        try:
            from qgis.core import QgsVectorLayer, QgsProject
            from .qgis_compat import make_field
        except Exception:
            return
        proj = QgsProject.instance()
        label = "AIS coverage"
        lyr = self._cov_layer
        if _layer_alive(lyr) and proj.mapLayer(lyr.id()) is not None:
            return
        self._cov_layer = None                  # drop any dangling wrapper
        existing = proj.mapLayersByName(label)
        if existing:
            self._cov_layer = existing[0]
            return
        v = QgsVectorLayer("Point?crs=EPSG:4326", label, "memory")
        v.dataProvider().addAttributes([make_field("transponderClass", "str"),
                                        make_field("hits", "int"),
                                        make_field("sourcePosition", "str")])
        v.updateFields()
        self._apply_coverage_style(v)
        proj.addMapLayer(v, False)
        try:
            node = proj.layerTreeRoot().insertLayer(0, v)
            node.setItemVisibilityChecked(False)   # added but not portrayed
        except Exception:  # nosec B110
            pass
        self._cov_layer = v

    def _apply_coverage_style(self, layer):
        """Ready-made categorized style: Class A vs Class B in distinct colours,
        with marker size growing by reception density (the 'hits' count)."""
        try:
            from qgis.core import (QgsCategorizedSymbolRenderer,
                                   QgsRendererCategory, QgsMarkerSymbol)
        except Exception:
            return
        cats = []
        spec = (("A", "47,111,224,160", "Class A (12.5 W)"),
                ("B", "255,122,26,160", "Class B (2 W)"))
        for cls, color, legend in spec:
            sym = QgsMarkerSymbol.createSimple({
                "name": "circle", "size": "2.4",
                "color": color, "outline_style": "no",
            })
            try:                                    # density -> size, best-effort
                from qgis.core import QgsProperty, QgsSymbolLayer
                prop = getattr(QgsSymbolLayer, "PropertySize", None)
                if prop is None and hasattr(QgsSymbolLayer, "Property"):
                    prop = getattr(QgsSymbolLayer.Property, "PropertySize", None)
                if prop is not None:
                    sym.symbolLayer(0).setDataDefinedProperty(
                        prop, QgsProperty.fromExpression(
                            'scale_linear(sqrt(coalesce("hits",1)),1,30,1.6,6)'))
            except Exception:  # nosec B110
                pass
            cats.append(QgsRendererCategory(cls, sym, legend))
        try:
            layer.setRenderer(
                QgsCategorizedSymbolRenderer("transponderClass", cats))
            layer.triggerRepaint()
        except Exception:  # nosec B110
            pass

    def _refresh_coverage_layers(self):
        """Rewrite the coverage point layer from all stored cells (class, count,
        source position) so the map reflects current reception density."""
        try:
            from qgis.core import QgsFeature, QgsGeometry, QgsPointXY
        except Exception:
            return
        lyr = self._cov_layer
        if not _layer_alive(lyr) or self.store is None:
            return
        try:
            rows = self.store.load_all_coverage()
        except Exception:
            rows = []
        inv = 1.0 / self.engine.coverage.cell_deg
        try:
            dp = lyr.dataProvider()
            try:
                dp.truncate()
            except Exception:
                dp.deleteFeatures([f.id() for f in lyr.getFeatures()])
            feats = []
            for cls, cx, cy, n, src in rows:
                f = QgsFeature(lyr.fields())
                f.setAttribute("transponderClass", cls)
                f.setAttribute("hits", int(n))
                f.setAttribute("sourcePosition", src or "")
                f.setGeometry(QgsGeometry.fromPointXY(
                    QgsPointXY(cx / inv, cy / inv)))
                feats.append(f)
            if feats:
                dp.addFeatures(feats)
            lyr.updateExtents(); lyr.triggerRepaint()
        except Exception:  # nosec B110
            pass

    def _load_zones(self):
        """Read the drawn guard zones into GuardZone objects for Alarms 1 & 2."""
        if self.zones_layer is None:
            return []
        try:
            from . import guardian_zones as gz
            return gz.read_zones(self.zones_layer)
        except Exception:
            return []

    # ---- live feed -------------------------------------------------------
    def _on_record(self, rec):
        """Per decoded record: pan to the first contact once, feed the engine
        (handling any events), update the Nearby track and the alarm lights.
        Wrapped so a decode quirk never kills the feed."""
        if self._halted:
            return
        lon, lat = rec.get("lon"), rec.get("lat")
        # pan/zoom to the first contact of the session, once
        if not self._first_contact_done and lon is not None and lat is not None:
            try:
                from . import map_setup
                if map_setup.pan_to(self.iface, lon, lat, 50000):
                    self._first_contact_done = True
            except Exception:
                self._first_contact_done = True
        try:
            events = self.engine.update(
                rec.get("mmsi"), lon, lat,
                msg_type=rec.get("msg_type"), ship_type=rec.get("ship_type"),
                nav_status=rec.get("nav_status"), now=time.time())
        except Exception:
            return                       # a decode quirk must never kill the feed
        if events:
            self._handle_events(events)
        self._update_track(rec)
        self.controls.set_alarm_lights(self.engine.active_alarms(time.time()))

    def _on_tick(self):
        """1 Hz tick: process exit-grace expiries, refresh the Vessels table and
        alarm lights, and periodically save + redraw the coverage map."""
        if self._halted:
            return
        try:
            events = self.engine.tick(time.time())
        except Exception:
            events = []
        if events:
            self._handle_events(events)
        self.controls.set_alarm_lights(self.engine.active_alarms(time.time()))

        self._tick_count += 1
        self._refresh_vessels_table()
        if self._own_pos is not None and self.store is not None:
            cov = self.engine.coverage
            self.log.set_coverage_status(
                f"Coverage: A={cov.count('A')} cells ({cov.total('A')} hits), "
                f"B={cov.count('B')} cells ({cov.total('B')} hits)")
            if self._tick_count % self.COVERAGE_SAVE_EVERY == 0:
                try:
                    self.store.save_coverage(cov)
                except Exception:  # nosec B110
                    pass
                self._ensure_coverage_layers()
                self._refresh_coverage_layers()
        else:
            self.log.set_coverage_status(
                "Coverage: off (set own position in Vessels)")

    # ---- 'Vessels nearby' tab --------------------------------------------
    def _update_track(self, rec):
        """Fold a record into the per-MMSI track cache that backs the Nearby
        table (position, sog/cog, fairway fields, nav-status, name)."""
        mmsi = rec.get("mmsi")
        if not mmsi:
            return
        t = self._tracks.setdefault(mmsi, {})
        if rec.get("has_position"):
            t["lon"], t["lat"] = rec.get("lon"), rec.get("lat")
            if rec.get("sog") is not None:
                t["sog"] = rec.get("sog")
            if rec.get("cog") is not None:
                t["cog"] = rec.get("cog")
            for k in ("fairway_name", "fairway_flow", "fairway_km"):
                if rec.get(k) is not None:
                    t[k] = rec.get(k)
            if rec.get("nav_status") is not None:
                t["nav_status"] = rec.get("nav_status")
        if rec.get("name"):
            t["name"] = rec.get("name")

    def _on_top_level_changed(self, floating):
        """Dock floated/redocked. When it floats into its own OS window, turn that
        window into a normal, freely-resizable one: a plain floating QDockWidget
        has no maximize/minimize buttons on Windows and its minimum is pinned by
        the content's size-hints, so users can't grow, shrink or maximize it.
        Re-applying the window flags with those hints (geometry preserved across
        the change) fixes it; docked again we drop the float-only minimum so the
        docked panel keeps its natural (narrower) size."""
        if not floating:
            try:
                self.setMinimumSize(0, 0)          # don't pin the docked width
            except Exception:  # nosec B110
                pass
            return
        try:
            from qgis.PyQt.QtCore import Qt
            self.setMinimumSize(360, 260)          # allow shrinking the float
            geo = self.geometry()
            self.setWindowFlags(self.windowFlags()
                                | Qt.WindowMinimizeButtonHint
                                | Qt.WindowMaximizeButtonHint)
            if not self._floated_once:             # comfortable size on first float
                self._floated_once = True
                geo.setWidth(max(geo.width(), 760))
                geo.setHeight(max(geo.height(), 900))
            self.setGeometry(geo)
            self.show()                            # required after changing flags
        except Exception:  # nosec B110
            pass

    def _begin_pick_own(self):
        """Set own position, seeded once (scrutiny tool, not a plotter). Prefer the
        GPS device QGIS already manages: the user connects it once in QGIS's own GPS
        Information panel (which auto-detects the port), and we read the current fix
        from it — no COM/baud picking here. Otherwise pick a point on the map."""
        box = QMessageBox(self)
        box.setWindowTitle("Set own position")
        box.setText("How do you want to set your receiver position?")
        box.setInformativeText(
            "Use the GPS device connected in QGIS (View > Panels > GPS Information), "
            "or pick a point on the map. Either way it is set once, not tracked.")
        gps_btn = box.addButton("From QGIS GPS", QMessageBox.AcceptRole)
        box.addButton("Pick on map…", QMessageBox.ActionRole)
        box.addButton("Cancel", QMessageBox.RejectRole)
        (box.exec_() if hasattr(box, "exec_") else box.exec())
        clicked = box.clickedButton()
        if clicked is gps_btn:
            self._own_from_qgis_gps()
        elif clicked is not None and box.buttonRole(clicked) == QMessageBox.ActionRole:
            self._begin_pick_own_map()
        # RejectRole / closed -> cancel

    def _begin_pick_own_map(self):
        """Start picking the receiver position by map click; fall back to typing
        coordinates when there's no canvas/tool."""
        try:
            canvas = self.iface.mapCanvas()
        except Exception:
            canvas = None
        if canvas is None or QgsMapToolEmitPoint is None:
            self._pick_own_manual()      # no canvas/tool -> type coordinates
            return
        self._prev_maptool = canvas.mapTool()
        self._own_picker = _OwnPositionPicker(canvas, self._set_own_from_canvas)
        canvas.setMapTool(self._own_picker)
        self.stream.prompt_pick(True)

    # ---- QGIS native GPS -------------------------------------------------
    def _own_from_qgis_gps(self):
        """Seed own position from the GPS device QGIS is connected to. If nothing
        is connected yet (or there's no fix), guide the user to QGIS's GPS panel
        and offer the map pick instead."""
        fix = self._qgis_gps_fix()
        if fix is None:
            ask = QMessageBox(self)
            ask.setWindowTitle("QGIS GPS")
            ask.setIcon(QMessageBox.Information)
            ask.setText("No GPS position is available from QGIS yet.")
            ask.setInformativeText(
                "Connect your GNSS device in QGIS's GPS Information panel "
                "(View > Panels > GPS Information, or the GPS toolbar) and wait for "
                "a fix, then try again.\n\nPick your position on the map instead?")
            ask.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
            ask.setDefaultButton(QMessageBox.Yes)
            if (ask.exec_() if hasattr(ask, "exec_") else ask.exec()) == QMessageBox.Yes:
                self._begin_pick_own_map()
            return
        lat, lon = fix
        self._seed_own_from_gnss(lat, lon, user=True)
        try:
            self.iface.messageBar().pushMessage(
                "AIS Watchkeeper",
                "Own position set from QGIS GPS: %.5f, %.5f" % (lat, lon), level=0)
        except Exception:  # nosec B110
            pass

    def _qgis_gps_fix(self):
        """(lat, lon) from a live QGIS GPS connection that has a valid fix, else
        None. Reads QGIS's own connection registry — no serial handling here."""
        try:
            from qgis.core import QgsApplication
            reg = QgsApplication.gpsConnectionRegistry()
            conns = reg.connectionList() if reg is not None else []
        except Exception:                            # noqa: BLE001
            return None
        for conn in conns or []:
            try:
                info = conn.currentGPSInformation()
            except Exception:                        # noqa: BLE001  # nosec B112
                continue
            if info is None:
                continue
            # NB: a default/empty QgsGpsInformation reports isValid()=True at 0,0,
            # so gate on a REAL fix instead: a 2D/3D fixType (or a positive GGA
            # quality) with non-zero, in-range coordinates.
            try:
                fix_t = int(info.fixType)
            except Exception:                        # noqa: BLE001
                fix_t = 0
            try:
                qual = int(info.quality)
            except Exception:                        # noqa: BLE001
                qual = -1
            if fix_t < 2 and qual < 1:
                continue
            try:
                lat = float(info.latitude)
                lon = float(info.longitude)
            except Exception:                        # noqa: BLE001  # nosec B112
                continue
            if lat == 0.0 and lon == 0.0:
                continue
            if -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0:
                return (lat, lon)
        return None

    def _on_gnss_seed(self, lat, lon):
        """Opportunistic seed from a GNSS sentence on the live AIS feed: set own
        position once, GNSS-first, unless the user already set it by hand."""
        if self._own_gnss_done or self._own_user_set:
            return
        self._seed_own_from_gnss(lat, lon, user=False)
        try:
            self.iface.messageBar().pushMessage(
                "AIS Watchkeeper",
                "Own position seeded from the receiver's GNSS: %.5f, %.5f"
                % (lat, lon), level=0)
        except Exception:  # nosec B110
            pass

    def _seed_own_from_gnss(self, lat, lon, user):
        """Set own position from a GNSS fix (shared by the one-shot reader and the
        feed-seed path): store it, drop the marker, activate coverage. 'user' marks
        an explicit choice so a later opportunistic feed-seed won't override it."""
        self._own_pos = (lat, lon)
        self._own_gnss_done = True
        if user:
            self._own_user_set = True
        try:
            self.nearby.set_own_label(lat, lon)
        except Exception:  # nosec B110
            pass
        self._persist_own_position(lat, lon)
        self._update_own_marker()
        self._activate_coverage((lat, lon))
        try:
            self._refresh_vessels_table()
        except Exception:  # nosec B110
            pass

    def _set_own_from_canvas(self, point):
        """Map-click handler: reproject the picked point to 4326, store it as own
        position, persist it, drop the marker, activate coverage, and restore the
        previous map tool."""
        lon, lat = point.x(), point.y()
        try:
            from qgis.core import (QgsProject, QgsCoordinateReferenceSystem,
                                   QgsCoordinateTransform)
            src = QgsProject.instance().crs()
            dst = QgsCoordinateReferenceSystem("EPSG:4326")
            if src.isValid() and src != dst:
                tr = QgsCoordinateTransform(src, dst, QgsProject.instance())
                p = tr.transform(point)
                lon, lat = p.x(), p.y()
        except Exception:  # nosec B110
            pass
        self._own_pos = (lat, lon)
        self._own_user_set = True
        self.nearby.set_own_label(lat, lon)
        self.stream.prompt_pick(False)
        self._persist_own_position(lat, lon)
        self._update_own_marker()
        self._activate_coverage((lat, lon))
        try:
            canvas = self.iface.mapCanvas()
            if self._prev_maptool is not None:
                canvas.setMapTool(self._prev_maptool)
            else:
                canvas.unsetMapTool(self._own_picker)
        except Exception:  # nosec B110
            pass
        self._own_picker = None
        try:
            self._refresh_vessels_table()
        except Exception:  # nosec B110
            pass

    def _pick_own_manual(self):
        """Fallback own-position entry: type 'lat, lon' in EPSG:4326."""
        try:
            txt, ok = QInputDialog.getText(
                self.iface.mainWindow(), "Set own position",
                "Enter lat, lon (EPSG:4326):")
            if not ok or not txt:
                return
            parts = txt.replace(";", ",").split(",")
            lat, lon = float(parts[0]), float(parts[1])
            self._own_pos = (lat, lon)
            self._own_user_set = True
            self.nearby.set_own_label(lat, lon)
            self._persist_own_position(lat, lon)
            self._update_own_marker()
            self._activate_coverage((lat, lon))
        except Exception:  # nosec B110
            pass

    def _persist_own_position(self, lat, lon):
        """Save the receiver position to the store (best-effort)."""
        if self.store is not None:
            try:
                self.store.save_own_position(lat, lon)
            except Exception:  # nosec B110
                pass

    def _on_member_added_to_group(self, mmsi, group_name):
        """A vessel was copied into a guard group from the Vessels table:
        refresh the groups editor / Alarm-3 list and re-sync the engine so the
        new member counts immediately, then confirm in the message bar."""
        try:
            self.controls.groups_editor.refresh_groups()
        except Exception:  # nosec B110
            pass
        self._sync_config()
        try:
            self.iface.messageBar().pushMessage(
                "AIS Guardian",
                f"MMSI {mmsi} added to group “{group_name}”.", 0, 3)
        except Exception:  # nosec B110
            pass

    def _registry_records(self):
        """All vessels from the stream's registry (for the Register view)."""
        try:
            return self.stream.registry.records()
        except Exception:
            return {}

    def _on_units_changed(self):
        """Unit system changed: re-render the Vessels table, the event log and the
        own-position label in the new units."""
        try:
            self._refresh_vessels_table()
        except Exception:  # nosec B110
            pass
        try:
            self.log.refresh()
        except Exception:  # nosec B110
            pass
        try:
            if self._own_pos is not None:
                self.nearby.set_own_label(self._own_pos[0], self._own_pos[1])
        except Exception:  # nosec B110
            pass

    def _refresh_vessels_table(self):
        """Refresh the Vessels tab in whichever mode is active (Register / My-fleet
        from the registry, or Nearby from the live tracks + alarming set)."""
        try:
            if self.nearby.is_fleet_mode():
                self.nearby.update_fleet(self._registry_records())
            elif self.nearby.is_database_mode():
                self.nearby.update_database(self._registry_records())
            else:
                self.nearby.update_rows(self._own_pos, self._tracks,
                                        self.engine.alarming_mmsis())
        except Exception:  # nosec B110
            pass

    def _update_own_marker(self):
        """Show the receiver position on the map as a single marker."""
        if self._own_pos is None:
            return
        try:
            from qgis.core import (QgsVectorLayer, QgsProject, QgsFeature,
                                   QgsGeometry, QgsPointXY)
        except Exception:
            return
        lat, lon = self._own_pos
        proj = QgsProject.instance()
        lyr = self._own_marker_layer
        if not _layer_alive(lyr) or proj.mapLayer(lyr.id()) is None:
            self._own_marker_layer = None       # drop any dangling wrapper
            existing = proj.mapLayersByName("Own position")
            if existing:
                lyr = existing[0]
            else:
                lyr = QgsVectorLayer("Point?crs=EPSG:4326", "Own position", "memory")
                self._style_own_marker(lyr)
                proj.addMapLayer(lyr)
            self._own_marker_layer = lyr
        try:
            dp = lyr.dataProvider()
            try:
                dp.truncate()
            except Exception:
                dp.deleteFeatures([f.id() for f in lyr.getFeatures()])
            f = QgsFeature(lyr.fields())
            f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(lon, lat)))
            dp.addFeatures([f])
            lyr.updateExtents()
            lyr.triggerRepaint()
        except Exception:  # nosec B110
            pass

    def _style_own_marker(self, layer):
        """Prefer a QGIS-native house/home SVG; fall back to a bold star."""
        try:
            from qgis.core import (QgsMarkerSymbol, QgsSingleSymbolRenderer)
            sym = None
            try:
                from qgis.core import (QgsSvgMarkerSymbolLayer, QgsApplication)
                import glob
                svg = None
                for base in QgsApplication.svgPaths():
                    for pat in ("*house*.svg", "*home*.svg"):
                        hits = glob.glob(os.path.join(base, "**", pat),
                                         recursive=True)
                        if hits:
                            svg = hits[0]
                            break
                    if svg:
                        break
                if svg:
                    sl = QgsSvgMarkerSymbolLayer(svg)
                    try:
                        sl.setSize(7)
                    except Exception:  # nosec B110
                        pass
                    sym = QgsMarkerSymbol()
                    sym.changeSymbolLayer(0, sl)
            except Exception:
                sym = None
            if sym is None:
                sym = QgsMarkerSymbol.createSimple({
                    "name": "star", "size": "6", "color": "30,30,30,255",
                    "outline_color": "255,255,255,255", "outline_width": "0.4"})
            layer.setRenderer(QgsSingleSymbolRenderer(sym))
        except Exception:  # nosec B110
            pass

    def _open_manual(self):
        """Open the bundled user manual in the default browser (self-contained
        HTML). Falls back to the online repo if the file is missing."""
        from qgis.PyQt.QtGui import QDesktopServices
        from qgis.PyQt.QtCore import QUrl
        path = os.path.join(os.path.dirname(__file__), "help",
                            "AIS_Watchkeeper_Manual.html")
        if os.path.exists(path):
            QDesktopServices.openUrl(QUrl.fromLocalFile(path))
        else:
            QDesktopServices.openUrl(
                QUrl("https://gitlab.com/foss4maritime/aiswatchkeeper"))

    def _zoom_to_vessel(self, lon, lat):
        """Pan/zoom the canvas to a vessel clicked in the Nearby table."""
        try:
            from . import map_setup
            map_setup.pan_to(self.iface, lon, lat, 2500)
        except Exception:  # nosec B110
            pass

    # ---- history retrieval ----------------------------------------------
    def _history_reader(self):
        """Build a HistoryReader from the stream's history config, or None."""
        try:
            cfg = getattr(self.stream, "_history_cfg", None)
            if cfg is None:
                from .history_config import HistoryConfig
                cfg = HistoryConfig.load()
            from .history_reader import HistoryReader
            return HistoryReader(cfg)
        except Exception:
            return None

    def _on_show_track(self, mmsi, t0):
        """Event-log 'Show track (±5 min)': draw the vessel's stored track from
        5 min before to 5 min after the event."""
        from qgis.PyQt.QtWidgets import QMessageBox
        rd = self._history_reader()
        if rd is None:
            return
        ok, why = rd.available()
        if not ok:
            QMessageBox.information(self, "Show track",
                                    "No stored history: %s." % why)
            return
        try:
            coords = rd.window_track(mmsi, t0, 300.0, 300.0)
        except Exception as exc:                        # noqa: BLE001
            QMessageBox.warning(self, "Show track",
                                "Could not read history:\n%s" % exc)
            return
        if not coords or len(coords) < 2:
            QMessageBox.information(self, "Show track",
                "No stored track for MMSI %s around that time." % mmsi)
            return
        label = "Track %s @ %s" % (mmsi, time.strftime("%H:%M", time.gmtime(t0)))
        self._add_track_and_zoom(label, coords)

    def _on_past_voyages(self, mmsi, name):
        """Register 'Past voyages…': list a vessel's voyages, then draw the one
        the user picks."""
        from qgis.PyQt.QtWidgets import QMessageBox
        rd = self._history_reader()
        if rd is None:
            return
        ok, why = rd.available()
        if not ok:
            QMessageBox.information(self, "Past voyages",
                                    "No stored history: %s." % why)
            return
        try:
            voyages = rd.list_voyages(mmsi)
        except Exception as exc:                        # noqa: BLE001
            QMessageBox.warning(self, "Past voyages",
                                "Could not read history:\n%s" % exc)
            return
        if not voyages:
            QMessageBox.information(self, "Past voyages",
                                    "No stored voyages for MMSI %s." % mmsi)
            return
        vid = self._pick_voyage(mmsi, name, voyages)
        if not vid:
            return
        try:
            coords = rd.voyage_track(vid)
        except Exception as exc:                        # noqa: BLE001
            QMessageBox.warning(self, "Past voyages",
                                "Could not read track:\n%s" % exc)
            return
        if not coords or len(coords) < 2:
            QMessageBox.information(self, "Past voyages",
                                    "That voyage has no stored track.")
            return
        tail = vid.split(":")[-1]
        self._add_track_and_zoom("Voyage %s (%s)" % (name or mmsi, tail), coords)

    def _pick_voyage(self, mmsi, name, voyages):
        """Modal list of a vessel's voyages; return the chosen voyage_id or None."""
        from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout, QListWidget,
                                         QDialogButtonBox, QLabel)

        def _fmt(ts):
            return (time.strftime("%Y-%m-%d %H:%M", time.gmtime(ts))
                    if ts else "…")

        dlg = QDialog(self)
        dlg.setWindowTitle("Past voyages — %s" % (name or mmsi))
        lay = QVBoxLayout(dlg)
        lay.addWidget(QLabel("Select a voyage to show its track:"))
        lst = QListWidget()
        for v in voyages:
            txt = "%s  →  %s   %s" % (_fmt(v.get("start_t")),
                                      _fmt(v.get("end_t")),
                                      v.get("end_reason") or "")
            if v.get("destination"):
                txt += "   → %s" % v.get("destination")
            lst.addItem(txt)
        lst.setCurrentRow(0)
        lst.itemDoubleClicked.connect(lambda *_: dlg.accept())
        lay.addWidget(lst)
        bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
        bb.accepted.connect(dlg.accept)
        bb.rejected.connect(dlg.reject)
        lay.addWidget(bb)
        runner = getattr(dlg, "exec", None) or getattr(dlg, "exec_")
        if runner():
            r = lst.currentRow()
            if 0 <= r < len(voyages):
                return voyages[r].get("voyage_id")
        return None

    def _add_track_and_zoom(self, label, coords):
        """Add the track layer and zoom the canvas to fit it (best-effort)."""
        from .history_reader import add_track_layer
        lyr = add_track_layer(label, coords)
        if lyr is None:
            return
        try:
            from qgis.core import (QgsCoordinateTransform, QgsProject)
            canvas = self.iface.mapCanvas()
            tr = QgsCoordinateTransform(lyr.crs(), QgsProject.instance().crs(),
                                        QgsProject.instance())
            ext = tr.transformBoundingBox(lyr.extent())
            ext.scale(1.2)                       # a little margin around the track
            canvas.setExtent(ext)
            canvas.refresh()
        except Exception:  # nosec B110
            pass

    # ---- alarm output: log + chime + message bar -------------------------
    def _handle_events(self, events):
        """Persist each engine Event to the log and notify the user, then refresh
        the event-log table."""
        for ev in events:
            if self.store is not None:
                try:
                    self.store.log_event(ev)
                except Exception:  # nosec B110
                    pass
            self._notify(ev)
        if self.store is not None:
            self.log.refresh()

    def _notify(self, ev):
        """User-facing alert for an alarm event: optional chime + a message-bar
        line. Log-only kinds (presence/nav/health) are silent."""
        # log-only kinds (presence in/out, nav-status, health) are recorded in
        # the event log but never chime or raise a message bar.
        if ev.kind in ("presence_in", "presence_out", "nav", "stream_health",
                       "info", "lost_target"):
            return
        if self.stream.is_chime():
            try:
                QApplication.beep()
            except Exception:  # nosec B110
                pass
        zone = f" [{ev.zone_name}]" if ev.zone_name else ""
        text = f"{ev.kind.upper()}  MMSI {ev.mmsi}{zone} — {ev.detail}"
        try:
            from qgis.core import Qgis
            level = getattr(getattr(Qgis, "MessageLevel", Qgis), "Warning")
            self.iface.messageBar().pushMessage("AIS Guardian", text,
                                                level=level, duration=6)
        except Exception:  # nosec B110
            pass

    def shutdown(self):
        """Stop the tick timer, flush coverage to the store, and shut the stream
        down. Called by the plugin on unload."""
        try:
            units.unregister_listener(self._on_units_changed)
        except Exception:  # nosec B110
            pass
        try:
            self._timer.stop()
        except Exception:  # nosec B110
            pass
        if self.store is not None and self._own_pos is not None:
            try:
                self.store.save_coverage(self.engine.coverage)
            except Exception:  # nosec B110
                pass
        try:
            self.routes_tab.shutdown()
        except Exception:  # nosec B110
            pass
        try:
            self.stream.shutdown()
        except Exception:  # nosec B110
            pass
