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

# 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",
}


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:
                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:
            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:
            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:
                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:
                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:
                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:
                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:
            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:
                        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"]

    def __init__(self, parent=None):
        """Build the read-only event-log table with Refresh / Export controls."""
        super().__init__(parent)
        self.store = None
        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:
            pass
        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.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"), ev.get("lat"), 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 _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:
                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", "Route km"]
    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)

    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._mode_group = QButtonGroup(self)
        self._mode_group.addButton(self.rb_nearby)
        self._mode_group.addButton(self.rb_db)
        top.addWidget(self.rb_nearby)
        top.addWidget(self.rb_db)
        self.rb_nearby.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:
            pass
        try:
            hh.sortIndicatorChanged.connect(lambda *_: self._apply_filter())
        except Exception:
            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 _apply_mode(self, *_):
        """Swap the table columns/sorting/search to match the Nearby/Register
        toggle and clear the current rows."""
        db = self.rb_db.isChecked()
        cols = self.COLS_DB if db 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)              # search only makes sense in DB
        self.table.setSortingEnabled(db)            # column sort only in DB mode
        if db:
            try:
                so = getattr(Qt, "SortOrder", Qt)
                self.table.horizontalHeader().setSortIndicator(1, so.AscendingOrder)
            except Exception:
                pass
        self.modeChanged.emit()

    def _apply_filter(self, *_):
        """Hide database 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():
            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(f"Own position: {lat:.5f}, {lon:.5f}  "
                                   "(static)")

    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 group -> [guard groups]. The
        MMSI is added to the chosen group. Works in both Nearby and Database
        modes (column 0 is always the MMSI)."""
        if self.store is None:
            return
        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 ""

        try:
            groups = self.store.list_groups()
        except Exception:
            groups = []
        menu = QMenu(self)
        sub = menu.addMenu(f"Copy {mmsi} to group")
        action_gid = {}
        if groups:
            for gid, gname in groups:
                action_gid[sub.addAction(gname)] = (gid, gname)
        else:
            none_act = sub.addAction("(no guard groups yet)")
            none_act.setEnabled(False)
        runner = getattr(menu, "exec", None) or getattr(menu, "exec_")
        chosen = runner(self.table.viewport().mapToGlobal(pos))
        if chosen in action_gid:
            gid, gname = action_gid[chosen]
            try:
                self.store.add_member(gid, mmsi, name or None)
                self.memberAddedToGroup.emit(mmsi, gname)
            except Exception:
                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 f"{float(fwkm):.1f}"
            # 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}°", f"{dist:.2f} nm",
                    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

        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(self._fmt_num(rec.get("length")),
                                                  self._as_num(rec.get("length"))))
            self.table.setItem(r, 5, _NumericItem(self._fmt_num(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

        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")
        cl.addWidget(self.tabs)
        self.setWidget(central)

        # 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:
            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:
            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)
        self.nearby.zoomToVessel.connect(self._zoom_to_vessel)
        self.nearby.modeChanged.connect(self._refresh_vessels_table)
        self.nearby.memberAddedToGroup.connect(self._on_member_added_to_group)

        # 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:
            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:
                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:
                    pass
            self.stream.halt_processes()
            self.stream.set_fullstop_label(True)
            try:
                self.log.set_coverage_status("Coverage: halted (Full stop)")
            except Exception:
                pass
        else:
            self.stream.resume_processes()
            self._repopulate_layers()
            try:
                self._timer.start()
            except Exception:
                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:
            pass
        try:
            self._setup_zone_layer()
        except Exception:
            pass
        if self._own_pos is not None:
            try:
                self._update_own_marker()
                self._ensure_coverage_layers()
                self._refresh_coverage_layers()
            except Exception:
                pass
        try:
            from . import map_setup
            map_setup.raise_layers_above_basemap(["liveFeedAIS", "guardZones"])
        except Exception:
            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: coverage now records cells stamped with
        this receiver position. A *changed* position saves and freezes the
        previous source's cells (kept in the store) and starts a fresh set."""
        src = self._src_str(own)
        if src is None:
            return
        cov = self.engine.coverage
        if getattr(cov, "source", None) != src:
            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:
                    pass
            cov.reset()
            cov.source = src
            if self.store is not None:
                try:
                    self.store.load_coverage_into(cov)   # resume this source
                except Exception:
                    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:
                    pass
                for sig in ("afterCommitChanges", "editingStopped"):
                    try:
                        getattr(self.zones_layer, sig).connect(self._sync_config)
                    except Exception:
                        pass
            try:
                self.controls.groups_editor.changed.connect(self._reconfigure_zone_form)
            except Exception:
                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:
            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:
                    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:
            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:
                pass
        else:
            self._first_contact_done = False    # re-arm pan for the next session

    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:
            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:
            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:
                pass
            cats.append(QgsRendererCategory(cls, sym, legend))
        try:
            layer.setRenderer(
                QgsCategorizedSymbolRenderer("transponderClass", cats))
            layer.triggerRepaint()
        except Exception:
            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:
            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:
                    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 _begin_pick_own(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)

    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:
            pass
        self._own_pos = (lat, lon)
        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:
            pass
        self._own_picker = None
        try:
            self._refresh_vessels_table()
        except Exception:
            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.nearby.set_own_label(lat, lon)
            self._persist_own_position(lat, lon)
            self._update_own_marker()
            self._activate_coverage((lat, lon))
        except Exception:
            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:
                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:
            pass
        self._sync_config()
        try:
            self.iface.messageBar().pushMessage(
                "AIS Guardian",
                f"MMSI {mmsi} added to group “{group_name}”.", 0, 3)
        except Exception:
            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 _refresh_vessels_table(self):
        """Refresh the Vessels tab in whichever mode is active (Register from the
        registry, or Nearby from the live tracks + alarming set)."""
        try:
            if 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:
            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:
            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:
                        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:
            pass

    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:
            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:
                    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"):
            return
        if self.stream.is_chime():
            try:
                QApplication.beep()
            except Exception:
                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:
            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:
            self._timer.stop()
        except Exception:
            pass
        if self.store is not None and self._own_pos is not None:
            try:
                self.store.save_coverage(self.engine.coverage)
            except Exception:
                pass
        try:
            self.routes_tab.shutdown()
        except Exception:
            pass
        try:
            self.stream.shutdown()
        except Exception:
            pass
