"""Reusable connection panel for one QTFM sensor."""

from __future__ import annotations

import re
import time
from collections.abc import Callable

from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtWidgets import (
    QComboBox,
    QFrame,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QMessageBox,
    QPushButton,
    QSizePolicy,
    QVBoxLayout,
    QWidget,
)

from sensors import sensor_name
from serial_connection import SerialConnection
from theme import (
    GOLDEN_ROD,
    LED_CONNECTED,
    LED_DISCONNECTED,
    LED_PENDING,
    MUTED_TEXT,
)


class SensorPanel(QFrame):
    """Port selection and connection state for one sensor."""

    connection_changed = pyqtSignal()
    measurement_received = pyqtSignal(object)
    timestamp_state_changed = pyqtSignal(str)
    sync_role_changed = pyqtSignal(str)
    sync_event = pyqtSignal(str)
    data_rate_changed = pyqtSignal(object)
    raw_lines_received = pyqtSignal(list)

    BAUDRATES = ("19200", "115200", "230400", "921600")
    DATA_RATES_MS = (1, 2, 4, 8, 16, 32, 64, 128, 1000, 2000)
    SLAVE_SCALAR_TIMEOUT_S = 5.0

    def __init__(
        self,
        sensor_key: str,
        port_available: Callable[[str, "SensorPanel"], bool],
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self.sensor_key = sensor_key
        self.sensor_name = sensor_name(sensor_key)
        # Every sensor uses the QuSpin accent; plot colours belong to the plot
        # slot, not to the sensor letter.
        self.accent_color = GOLDEN_ROD
        self._port_available = port_available
        self._latest_value_nt: float | None = None
        self._worker: SerialConnection | None = None
        self._last_error = ""
        self._connected_port: str | None = None
        self._stream_health = "checking"
        self._byte_count = 0
        self._line_count = 0
        self._valid_frame_count = 0
        self._timestamp_state = "unknown"
        self._timestamp_missing_streak = 0
        self._sync_role = "unknown"
        self._pending_role: str | None = None
        self._pending_role_confirmed = False
        self._reported_role_waiting_for_stream: str | None = None
        self._last_scalar_received_at: float | None = None
        self._slave_watch_started_at: float | None = None
        self._protective_master_recovery = False
        self._serial_number: str | None = None
        self._data_rate_ms: int | None = None
        self._target_data_rate_ms: int | None = None
        self._data_rate_attempts = 0
        self._reboot_frame_count = 0

        self.setObjectName("sensorCard")
        self._build_ui()
        # The field readout is repainted on a timer; packets arrive far faster
        # than a label needs to change.
        self._live_value_timer = QTimer(self)
        self._live_value_timer.setInterval(150)
        self._live_value_timer.timeout.connect(self._render_live_value)
        self._live_value_timer.start()
        self._data_rate_timeout = QTimer(self)
        self._data_rate_timeout.setSingleShot(True)
        self._data_rate_timeout.setInterval(1200)
        self._data_rate_timeout.timeout.connect(self._abort_data_rate_change)
        self._role_command_timeout = QTimer(self)
        self._role_command_timeout.setSingleShot(True)
        self._role_command_timeout.setInterval(2000)
        self._role_command_timeout.timeout.connect(
            self._on_role_command_timeout
        )
        self._slave_data_watch_timer = QTimer(self)
        self._slave_data_watch_timer.setInterval(250)
        self._slave_data_watch_timer.timeout.connect(
            self._check_slave_data_continuity
        )
        self._slave_data_watch_timer.start()
        self._reboot_recovery_timer = QTimer(self)
        self._reboot_recovery_timer.setSingleShot(True)
        self._reboot_recovery_timer.setInterval(3000)
        self._reboot_recovery_timer.timeout.connect(
            self._finish_reboot_recovery
        )
        self._reboot_data_check_timer = QTimer(self)
        self._reboot_data_check_timer.setSingleShot(True)
        self._reboot_data_check_timer.setInterval(10_000)
        self._reboot_data_check_timer.timeout.connect(
            self._check_reboot_data_recovery
        )
        self._set_state("disconnected", "Not connected")

    @property
    def connected_port(self) -> str | None:
        return self._connected_port

    @property
    def active_port(self) -> str | None:
        """Return the connected or currently-opening port."""
        if self._worker is not None and self._worker.isRunning():
            return self._worker.port
        return self._connected_port

    @property
    def sync_role(self) -> str:
        return self._sync_role

    @property
    def data_rate_ms(self) -> int | None:
        return self._data_rate_ms

    @property
    def timestamp_state(self) -> str:
        return self._timestamp_state

    @property
    def serial_number(self) -> str | None:
        return self._serial_number

    def _build_ui(self) -> None:
        layout = QVBoxLayout(self)
        layout.setContentsMargins(12, 8, 12, 9)
        layout.setSpacing(4)

        heading_row = QHBoxLayout()
        heading_row.setSpacing(7)

        # A colour chip that matches this sensor's plot trace.
        self.accent_chip = QLabel()
        self.accent_chip.setObjectName("sensorAccent")
        self.accent_chip.setFixedSize(5, 18)
        self.accent_chip.setStyleSheet(
            f"background-color: {self.accent_color}; border-radius: 2px;"
        )
        heading_row.addWidget(self.accent_chip)

        title = QLabel(self.sensor_name.upper())
        title.setObjectName("sensorTitle")
        title.setStyleSheet(f"color: {self.accent_color};")
        heading_row.addWidget(title)

        self.serial_number_label = QLabel()
        self.serial_number_label.setObjectName("serialNumber")
        self.serial_number_label.setToolTip("QTFM sensor serial number")
        self.serial_number_label.hide()
        heading_row.addWidget(self.serial_number_label)

        self.live_value_label = QLabel("— nT")
        self.live_value_label.setObjectName("cardValue")
        self.live_value_label.setToolTip("Latest scalar field from this sensor")
        heading_row.addWidget(self.live_value_label)
        heading_row.addStretch()

        self.role_badge = QLabel("MODE: ?")
        self.role_badge.setObjectName("roleBadgeUnknown")
        self.role_badge.setAlignment(Qt.AlignCenter)
        self.role_badge.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed)
        heading_row.addWidget(self.role_badge, 0, Qt.AlignVCenter)

        self.status_dot = QLabel()
        self.status_dot.setFixedSize(12, 12)
        heading_row.addWidget(self.status_dot, 0, Qt.AlignVCenter)

        self.state_label = QLabel()
        self.state_label.setObjectName("stateLabel")
        heading_row.addWidget(self.state_label, 0, Qt.AlignVCenter)
        layout.addLayout(heading_row)

        fields = QGridLayout()
        fields.setHorizontalSpacing(7)
        fields.setVerticalSpacing(5)

        port_label = QLabel("PORT")
        port_label.setObjectName("fieldLabel")
        fields.addWidget(port_label, 0, 0)

        self.port_combo = QComboBox()
        self.port_combo.setMinimumWidth(205)
        self.port_combo.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon)
        fields.addWidget(self.port_combo, 0, 1)
        fields.setColumnStretch(1, 1)

        baud_label = QLabel("BAUD")
        baud_label.setObjectName("fieldLabel")
        fields.addWidget(baud_label, 0, 2)

        self.baud_combo = QComboBox()
        self.baud_combo.addItems(self.BAUDRATES)
        self.baud_combo.setCurrentText("115200")
        self.baud_combo.setMinimumWidth(100)
        fields.addWidget(self.baud_combo, 0, 3)

        self.connect_button = QPushButton("CONNECT")
        self.connect_button.setObjectName("connectButton")
        self.connect_button.setMinimumHeight(30)
        self.connect_button.clicked.connect(self.toggle_connection)
        fields.addWidget(self.connect_button, 0, 4)
        layout.addLayout(fields)

        self.detail_label = QLabel()
        self.detail_label.setObjectName("detailLabel")
        self.detail_label.setWordWrap(True)
        self.detail_label.setMinimumHeight(18)
        layout.addWidget(self.detail_label)

        state_controls = QGridLayout()
        state_controls.setHorizontalSpacing(7)
        state_controls.setVerticalSpacing(5)

        self.timestamp_label = QLabel("MS timestamp: not checked")
        self.timestamp_label.setObjectName("timestampUnknown")
        state_controls.addWidget(self.timestamp_label, 0, 0, 1, 2)

        self.enable_timestamp_button = QPushButton("ENABLE MS TIMESTAMP")
        self.enable_timestamp_button.setObjectName("timestampButton")
        self.enable_timestamp_button.clicked.connect(self.enable_ms_timestamp)
        self.enable_timestamp_button.hide()
        state_controls.addWidget(self.enable_timestamp_button, 0, 2)

        self.role_label = QLabel("Sync role: unknown")
        self.role_label.setObjectName("roleUnknown")
        state_controls.addWidget(self.role_label, 1, 0)

        self.master_button = QPushButton("SYNC OUT → MASTER")
        self.master_button.setObjectName("roleButton")
        self.master_button.setEnabled(False)
        self.master_button.setToolTip(
            "Use only for the sensor whose SCB sync cable is connected "
            "to Sync Out."
        )
        self.master_button.clicked.connect(self.set_master)
        state_controls.addWidget(self.master_button, 1, 1)

        self.slave_button = QPushButton("SYNC IN → SLAVE")
        self.slave_button.setObjectName("roleButton")
        self.slave_button.setEnabled(False)
        self.slave_button.setToolTip(
            "Use only for the sensor whose SCB sync cable is connected "
            "to Sync In."
        )
        self.slave_button.clicked.connect(self.set_slave)
        state_controls.addWidget(self.slave_button, 1, 2)
        state_controls.setColumnStretch(0, 1)

        layout.addLayout(state_controls)

        device_controls = QGridLayout()
        device_controls.setHorizontalSpacing(7)
        device_controls.setVerticalSpacing(4)

        rate_label = QLabel("DATA RATE")
        rate_label.setObjectName("fieldLabel")
        device_controls.addWidget(rate_label, 0, 0)

        self.data_rate_combo = QComboBox()
        for rate_ms in self.DATA_RATES_MS:
            rate_hz = 1000.0 / rate_ms
            hz_text = f"{rate_hz:,.0f}" if rate_hz >= 10 else f"{rate_hz:g}"
            self.data_rate_combo.addItem(
                f"{rate_ms} ms  ({hz_text} Hz)",
                rate_ms,
            )
        self.data_rate_combo.setCurrentIndex(
            self.data_rate_combo.findData(4)
        )
        self.data_rate_combo.setEnabled(False)
        self.data_rate_combo.setMinimumWidth(135)
        device_controls.addWidget(self.data_rate_combo, 0, 1)

        self.set_rate_button = QPushButton("SET RATE")
        self.set_rate_button.setObjectName("deviceButton")
        self.set_rate_button.setEnabled(False)
        self.set_rate_button.clicked.connect(self.set_selected_data_rate)
        device_controls.addWidget(self.set_rate_button, 0, 2)

        self.current_rate_label = QLabel("Current: unknown")
        self.current_rate_label.setObjectName("controlHint")
        device_controls.addWidget(self.current_rate_label, 0, 3)

        self.reboot_button = QPushButton("REBOOT SENSOR")
        self.reboot_button.setObjectName("dangerButton")
        self.reboot_button.setEnabled(False)
        self.reboot_button.clicked.connect(self.reboot_sensor)
        device_controls.addWidget(self.reboot_button, 0, 4)
        device_controls.setColumnStretch(3, 1)
        layout.addLayout(device_controls)

        self.app_status_label = QLabel("APP  •  Ready")
        self.app_status_label.setObjectName("controlHint")
        self.app_status_label.setWordWrap(True)
        layout.addWidget(self.app_status_label)

        self.sensor_status_label = QLabel("No sensor messages")
        self.sensor_status_label.setObjectName("sensorStatusBar")
        self.sensor_status_label.setMinimumHeight(26)
        self.sensor_status_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
        self.sensor_status_label.setToolTip(
            "Latest original ASCII status line received from this sensor"
        )
        layout.addWidget(self.sensor_status_label)

    def set_ports(self, ports: list[tuple[str, str]]) -> None:
        """Replace the choices while preserving the current selection."""
        if self._connected_port is not None:
            return

        previous = self.port_combo.currentData()
        self.port_combo.clear()

        for device, description in ports:
            # pyserial repeats the device inside the description
            # ("USB Serial Port (COM21)"); drop it so the entry stays readable
            # in a narrow combo.
            description = re.sub(
                rf"\s*\({re.escape(device)}\)\s*$",
                "",
                description,
            )
            text = f"{device}  —  {description}" if description else device
            self.port_combo.addItem(text, device)
            self.port_combo.setItemData(
                self.port_combo.count() - 1,
                text,
                Qt.ToolTipRole,
            )

        if previous:
            index = self.port_combo.findData(previous)
            if index >= 0:
                self.port_combo.setCurrentIndex(index)

        has_ports = bool(ports)
        self.port_combo.setEnabled(has_ports)
        self.connect_button.setEnabled(has_ports)
        if not has_ports:
            self.port_combo.addItem("No serial ports found", None)
            self._set_state("disconnected", "Connect a QTFM sensor and refresh ports.")

    def select_port(self, port: str) -> None:
        index = self.port_combo.findData(port)
        if index >= 0:
            self.port_combo.setCurrentIndex(index)

    def toggle_connection(self) -> None:
        if self._worker is not None and self._worker.isRunning():
            self.disconnect()
        else:
            self.connect()

    def connect(self) -> None:
        port = self.port_combo.currentData()
        if not port:
            QMessageBox.information(self, "No serial port", "Select a serial port first.")
            return

        if not self._port_available(port, self):
            QMessageBox.warning(
                self,
                "Port already selected",
                f"{port} is already open as the other sensor.",
            )
            return

        baudrate = int(self.baud_combo.currentText())
        self._last_error = ""
        self._stream_health = "checking"
        self._byte_count = 0
        self._line_count = 0
        self._valid_frame_count = 0
        self._set_timestamp_state("unknown")
        self._set_controls_enabled(False)
        self.connect_button.setEnabled(False)
        self._set_state("connecting", f"Opening {port} at {baudrate:,} baud…")

        worker = SerialConnection(port, baudrate, self)
        worker.opened.connect(self._on_opened)
        worker.activity.connect(self._on_activity)
        worker.stream_health.connect(self._on_stream_health)
        worker.measurement_received.connect(self._on_measurement)
        worker.status_line_received.connect(self._on_status_line)
        worker.raw_lines.connect(self.raw_lines_received)
        worker.command_written.connect(self._on_command_written)
        worker.command_write_failed.connect(self._on_command_write_failed)
        worker.connection_error.connect(self._on_error)
        worker.closed.connect(self._on_closed)
        self._worker = worker
        worker.start()

    def disconnect(self) -> None:
        worker = self._worker
        if worker is None:
            return

        self.connect_button.setEnabled(False)
        self._set_state("connecting", "Closing serial port…")
        worker.stop()

    def shutdown(self) -> None:
        """Close the port during application shutdown."""
        if self._worker is not None:
            self._worker.stop()

    def _on_opened(self, port: str) -> None:
        self._connected_port = port
        self._set_state(
            "checking",
            f"{port} is open. Checking for valid QTFM data…",
        )
        self.connect_button.setText("DISCONNECT")
        self.connect_button.setEnabled(True)
        self.connect_button.setProperty("connected", True)
        self.connect_button.style().unpolish(self.connect_button)
        self.connect_button.style().polish(self.connect_button)
        self.master_button.setEnabled(True)
        self.slave_button.setEnabled(True)
        self.data_rate_combo.setEnabled(True)
        self.set_rate_button.setEnabled(True)
        self.reboot_button.setEnabled(True)
        self._serial_number = None
        self.serial_number_label.setText("S/N: querying…")
        self.serial_number_label.show()
        self._set_sync_role("unknown")
        QTimer.singleShot(200, self.query_sync_role)
        QTimer.singleShot(350, self.query_sensor_settings)
        self.connection_changed.emit()

    def _on_activity(
        self,
        byte_count: int,
        line_count: int,
        valid_frame_count: int,
    ) -> None:
        if self._connected_port is None:
            return
        self._byte_count = byte_count
        self._line_count = line_count
        self._valid_frame_count = valid_frame_count
        self._render_stream_health()

    def _on_stream_health(self, health: str) -> None:
        if self._connected_port is None:
            return
        self._stream_health = health
        self._render_stream_health()

    def _render_live_value(self) -> None:
        if self._connected_port is None or self._latest_value_nt is None:
            text = "— nT"
        else:
            text = f"{self._latest_value_nt:,.3f} nT"
        if self.live_value_label.text() != text:
            self.live_value_label.setText(text)

    def _on_measurement(self, measurement: object) -> None:
        self._last_scalar_received_at = time.monotonic()
        if getattr(measurement, "valid", False):
            self._latest_value_nt = measurement.value_nt
        if self._pending_role is not None and self._pending_role_confirmed:
            confirmed_role = self._pending_role
            self._pending_role = None
            self._pending_role_confirmed = False
            self._reported_role_waiting_for_stream = None
            self._role_command_timeout.stop()
            self._set_sync_role(confirmed_role)
            self._restore_device_controls()
            if confirmed_role == "master" and self._protective_master_recovery:
                self._protective_master_recovery = False
                self.show_app_message(
                    "Protective recovery succeeded: Master mode confirmed "
                    "and scalar data resumed."
                )
            else:
                self.show_app_message(
                    f"{confirmed_role.title()} mode confirmed; scalar data "
                    "continued after the mode response."
                )
        elif self._reported_role_waiting_for_stream is not None:
            reported_role = self._reported_role_waiting_for_stream
            self._reported_role_waiting_for_stream = None
            self._set_sync_role(reported_role)
            self.show_app_message(
                f"{reported_role.title()} mode confirmed with live scalar data."
            )

        timestamp_ms = getattr(measurement, "timestamp_ms", None)
        if timestamp_ms is not None:
            self._timestamp_missing_streak = 0
            if self._timestamp_state != "available":
                self._set_timestamp_state("available")
        else:
            self._timestamp_missing_streak += 1
            if (
                self._timestamp_missing_streak >= 5
                and self._timestamp_state != "missing"
            ):
                self._set_timestamp_state("missing")
        self.measurement_received.emit(measurement)

    def enable_ms_timestamp(self) -> None:
        """Send the documented `m` toggle only when timestamps are absent."""
        worker = self._worker
        if (
            worker is None
            or self._timestamp_state != "missing"
            or not worker.send_command(b"m")
        ):
            return

        self._timestamp_missing_streak = 0
        self._set_timestamp_state("enabling")
        QTimer.singleShot(2000, self._timestamp_enable_timeout)

    def _timestamp_enable_timeout(self) -> None:
        if self._connected_port is not None and self._timestamp_state == "enabling":
            self._set_timestamp_state("missing")

    def set_master(self) -> None:
        """Select the documented wired Master mode (ASCII space)."""
        self._request_sync_role("master", b" ")

    def set_slave(self) -> None:
        """Select the documented wired Slave mode (ASCII exclamation)."""
        self._request_sync_role("slave", b"!")

    def _request_sync_role(self, role: str, command: bytes) -> None:
        if self._pending_role is not None:
            self.show_app_message(
                "A mode change is already waiting for confirmation."
            )
            return
        if not self._send_sensor_command(command):
            self.show_app_message("Mode command could not be queued.")
            return

        self._pending_role = role
        self._pending_role_confirmed = False
        self._reported_role_waiting_for_stream = None
        self._protective_master_recovery = False
        if role == "master":
            self._disarm_slave_data_watch()
        self.master_button.setEnabled(False)
        self.slave_button.setEnabled(False)
        self._set_sync_role(f"setting_{role}")
        self.show_app_message(
            f"{role.title()} command queued; waiting for the sensor's mode "
            "response and continued scalar data…"
        )
        rate_ms = self._data_rate_ms or 4
        self._role_command_timeout.setInterval(max(3000, rate_ms * 2 + 1500))
        self._role_command_timeout.start()

    def _on_role_command_timeout(self) -> None:
        requested = self._pending_role
        if requested is None:
            return
        if requested == "slave":
            self._pending_role = None
            self._pending_role_confirmed = False
            self._role_command_timeout.stop()
            if self._slave_watch_started_at is not None:
                self._set_sync_role("verifying_slave")
            else:
                self._set_sync_role("unknown")
            self._restore_device_controls()
            self.show_app_message(
                "Slave mode has not yet been verified. Protective Master is "
                "triggered only by ISR.Er1 or a five-second loss of scalar "
                "data while the sensor is identified as Slave."
            )
            return

        self._pending_role = None
        self._pending_role_confirmed = False
        self._reported_role_waiting_for_stream = None
        protective_recovery = self._protective_master_recovery
        self._protective_master_recovery = False
        self._set_sync_role(
            "recovery_failed" if protective_recovery else "unknown"
        )
        self._restore_device_controls()
        if protective_recovery:
            self.show_app_message(
                "Protective Master command was sent, but Master mode and "
                "continued scalar data were not confirmed. Reboot or "
                "power-cycle this sensor."
            )
        else:
            self.show_app_message(
                f"{requested.title()} mode was not confirmed by both a "
                "matching mode response and continued scalar data. "
                "Controls restored."
            )

    def query_sync_role(self) -> None:
        """Ask the sensor to report its current Master/Slave mode."""
        self._send_sensor_command(b'"')

    def send_sync_trigger(self) -> bool:
        """Send wired sync (`T`) only from a confirmed Master sensor."""
        return self._sync_role == "master" and self._send_sensor_command(b"T")

    def send_command(self, command: bytes, description: str = "ASCII command") -> bool:
        """Send a command and show the local action separately from sensor output."""
        sent = self._send_sensor_command(command)
        if sent:
            self.show_app_message(f"Queued: {description}")
        return sent

    def show_app_message(self, message: str) -> None:
        """Display local workflow information without altering sensor output."""
        display = message.strip() or "Ready"
        self.app_status_label.setText(f"APP  •  {display}")
        self.app_status_label.setToolTip(display)

    def show_sensor_status_line(self, line: str) -> None:
        """Display the sensor's original message without rewriting it."""
        display = line.strip() or "No sensor messages"
        self.sensor_status_label.setText(display)
        self.sensor_status_label.setToolTip(display)

    def query_sensor_settings(self) -> None:
        """Request startup settings, including the current data rate."""
        self._send_sensor_command(b"s")

    def reboot_sensor(self) -> None:
        """Reboot only this sensor (documented ASCII `e`)."""
        if self.send_command(b"e", "reboot requested"):
            self._role_command_timeout.stop()
            self._pending_role = None
            self._pending_role_confirmed = False
            self._reported_role_waiting_for_stream = None
            self._protective_master_recovery = False
            self._disarm_slave_data_watch()
            self._data_rate_ms = None
            self._target_data_rate_ms = None
            self.current_rate_label.setText("Current: rebooting…")
            self._set_sync_role("unknown")
            self.master_button.setEnabled(False)
            self.slave_button.setEnabled(False)
            self.data_rate_combo.setEnabled(False)
            self.set_rate_button.setEnabled(False)
            self.reboot_button.setEnabled(False)
            self._reboot_frame_count = self._valid_frame_count
            self._reboot_recovery_timer.start()
            self._reboot_data_check_timer.start()
            self.data_rate_changed.emit(None)

    def _finish_reboot_recovery(self) -> None:
        if self._connected_port is None:
            return
        self._restore_device_controls()
        self.show_app_message(
            "Reboot wait complete; controls restored and sensor queried."
        )
        self.query_sync_role()
        self.query_sensor_settings()

    def _check_reboot_data_recovery(self) -> None:
        if self._connected_port is None:
            return
        if self._valid_frame_count > self._reboot_frame_count:
            self.show_app_message(
                "Sensor data resumed after reboot."
            )
        else:
            self.show_app_message(
                "Reboot was sent but no QTFM data resumed within 10 seconds. "
                "Restore the Slave input clock or power-cycle the sensor, then "
                "reconnect the COM port."
            )

    def _restore_device_controls(self) -> None:
        connected = self._connected_port is not None
        self.master_button.setEnabled(connected)
        self.slave_button.setEnabled(connected)
        self.data_rate_combo.setEnabled(connected)
        self.set_rate_button.setEnabled(connected)
        self.reboot_button.setEnabled(connected)

    def set_selected_data_rate(self) -> None:
        """Reach the selected rate using acknowledged `}` cycle commands."""
        target = self.data_rate_combo.currentData()
        if target not in self.DATA_RATES_MS:
            return

        self._target_data_rate_ms = int(target)
        self._data_rate_attempts = 0
        self.data_rate_combo.setEnabled(False)
        self.set_rate_button.setEnabled(False)
        if self._data_rate_ms is None:
            self.show_app_message("Querying current data rate…")
            if self._send_sensor_command(b"s"):
                self._data_rate_timeout.start()
            else:
                self._abort_data_rate_change("Sensor is not connected.")
            return
        self._continue_data_rate_change()

    def _continue_data_rate_change(self) -> None:
        target = self._target_data_rate_ms
        current = self._data_rate_ms
        if target is None or current is None:
            return
        if current == target:
            self._finish_data_rate_change()
            return
        if self._data_rate_attempts >= len(self.DATA_RATES_MS):
            self._abort_data_rate_change(
                "Could not reach the selected data rate."
            )
            return

        self._data_rate_attempts += 1
        self.show_app_message(
            f"Changing data rate: {current} ms → {target} ms…"
        )
        if self._send_sensor_command(b"}"):
            self._data_rate_timeout.start()
        else:
            self._abort_data_rate_change("Data-rate command could not be sent.")

    def _finish_data_rate_change(self) -> None:
        self._data_rate_timeout.stop()
        current = self._data_rate_ms
        self._target_data_rate_ms = None
        self.data_rate_combo.setEnabled(self._connected_port is not None)
        self.set_rate_button.setEnabled(self._connected_port is not None)
        self.show_app_message(f"Data rate set to {current} ms.")

    def _abort_data_rate_change(self, reason: str = "") -> None:
        self._data_rate_timeout.stop()
        self._target_data_rate_ms = None
        self.data_rate_combo.setEnabled(self._connected_port is not None)
        self.set_rate_button.setEnabled(self._connected_port is not None)
        self.show_app_message(
            reason or "No data-rate acknowledgement received."
        )

    def _send_sensor_command(self, command: bytes) -> bool:
        worker = self._worker
        return bool(
            worker is not None
            and self._connected_port is not None
            and worker.send_command(command)
        )

    def _on_status_line(self, line: str) -> None:
        self.show_sensor_status_line(line)

        slave_operation_active = (
            self._pending_role == "slave"
            or self._sync_role in ("setting_slave", "verifying_slave", "slave")
            or self._slave_watch_started_at is not None
        )
        if re.search(r"#?ISR\.ER\s*1\b", line, re.IGNORECASE):
            if slave_operation_active:
                self._start_protective_master_recovery(
                    "Slave mode failed: sensor reported ISR.Er1 and scalar "
                    "streaming cannot be trusted. Connect the sync cable "
                    "before retrying."
                )
            else:
                self.show_app_message(
                    "Sensor reported ISR.Er1, but no Slave transition is "
                    "active; no protective mode change was sent."
                )

        mode_match = re.search(
            r"#?(?:(?:Master|Slave)\s+)?MS Mode:\s*([0-3])",
            line,
            re.IGNORECASE,
        )
        if mode_match:
            mode = mode_match.group(1)
            reported_role = "other"
            if mode == "1":
                reported_role = "master"
            elif mode == "0":
                reported_role = "slave"
            if reported_role == "slave":
                self._arm_slave_data_watch()
            else:
                self._disarm_slave_data_watch()
            requested_role = self._pending_role
            if (
                requested_role is not None
                and requested_role != reported_role
            ):
                self._pending_role = None
                self._pending_role_confirmed = False
                self._role_command_timeout.stop()
                protective_recovery = self._protective_master_recovery
                self._protective_master_recovery = False
                if protective_recovery:
                    self._set_sync_role("recovery_failed")
                elif reported_role in ("master", "slave"):
                    self._set_sync_role(reported_role)
                else:
                    self._set_sync_role("other")
                self._restore_device_controls()
                if protective_recovery:
                    self.show_app_message(
                        "Protective Master recovery failed: sensor reports "
                        f"{reported_role.title()} mode."
                    )
                else:
                    self.show_app_message(
                        "Mode change failed: requested "
                        f"{requested_role.title()}, sensor reports "
                        f"{reported_role.title()}."
                    )
            elif requested_role is not None:
                self._pending_role_confirmed = True
                self._set_sync_role(f"verifying_{reported_role}")
                self.show_app_message(
                    f"Sensor reported {reported_role.title()} mode; waiting "
                    "for the next scalar packet before declaring success."
                )
                self._role_command_timeout.start()
            elif reported_role in ("master", "slave"):
                if self._scalar_stream_is_recent():
                    self._reported_role_waiting_for_stream = None
                    self._set_sync_role(reported_role)
                else:
                    self._reported_role_waiting_for_stream = reported_role
                    self._set_sync_role(f"verifying_{reported_role}")
                    self.show_app_message(
                        f"Sensor reports {reported_role.title()} mode; waiting "
                        "for live scalar data to verify operation."
                    )
            else:
                self._reported_role_waiting_for_stream = None
                self._set_sync_role(reported_role)

        rate_match = re.search(r"#Data rate:\s*([\d.]+)", line, re.IGNORECASE)
        if rate_match:
            rate_value = float(rate_match.group(1))
            rounded_rate = int(round(rate_value))
            self._data_rate_ms = rounded_rate
            self.current_rate_label.setText(f"Current: {rate_value:g} ms")
            self.data_rate_changed.emit(rounded_rate)
            if self._target_data_rate_ms is not None:
                self._data_rate_timeout.stop()
                QTimer.singleShot(60, self._continue_data_rate_change)

        serial_match = re.search(
            r"#Serial Number:\s*(\S+)",
            line,
            re.IGNORECASE,
        )
        if serial_match:
            self._serial_number = serial_match.group(1)
            self.serial_number_label.setText(f"S/N: {self._serial_number}")
            self.serial_number_label.show()

        lowered = line.casefold()
        if "sensors sync complete" in lowered:
            self.sync_event.emit("complete")
        elif "needs input clock" in lowered:
            if slave_operation_active:
                self._arm_slave_data_watch()
                self.show_app_message(
                    "Slave sensor reports that it needs an input clock. "
                    "Watching for scalar data for five seconds before taking "
                    "protective action."
                )
            else:
                self.show_app_message(
                    "Sensor reported a missing input clock, but no Slave "
                    "transition is active; no protective mode change was sent."
                )
            self.sync_event.emit("needs_clock")

    def _scalar_stream_is_recent(self) -> bool:
        if self._last_scalar_received_at is None:
            return False
        rate_ms = self._data_rate_ms or 4
        window_s = max(2.0, rate_ms * 2.5 / 1000.0)
        return time.monotonic() - self._last_scalar_received_at <= window_s

    def _arm_slave_data_watch(self, now: float | None = None) -> None:
        if self._slave_watch_started_at is None:
            self._slave_watch_started_at = (
                time.monotonic() if now is None else now
            )

    def _disarm_slave_data_watch(self) -> None:
        self._slave_watch_started_at = None

    def _check_slave_data_continuity(self, now: float | None = None) -> None:
        started_at = self._slave_watch_started_at
        if (
            started_at is None
            or self._connected_port is None
            or self._protective_master_recovery
        ):
            return

        observed_at = time.monotonic() if now is None else now
        last_scalar_at = self._last_scalar_received_at
        continuity_reference = started_at
        if last_scalar_at is not None and last_scalar_at >= started_at:
            continuity_reference = last_scalar_at

        if observed_at - continuity_reference < self.SLAVE_SCALAR_TIMEOUT_S:
            return

        self._start_protective_master_recovery(
            "No continuous scalar magnetic data has been received from the "
            "Slave sensor for five seconds."
        )

    def _start_protective_master_recovery(self, message: str) -> None:
        if self._protective_master_recovery and self._pending_role == "master":
            self.show_app_message(
                f"{message} Protective Master recovery is already underway."
            )
            return

        self._pending_role = None
        self._pending_role_confirmed = False
        self._reported_role_waiting_for_stream = None
        self._disarm_slave_data_watch()
        self._role_command_timeout.stop()
        self._set_sync_role("slave_failed")
        self._restore_device_controls()
        if not self._send_sensor_command(b" "):
            self.show_app_message(
                f"{message} Protective Master command could not be queued; "
                "reboot or power-cycle this sensor."
            )
            return

        self._protective_master_recovery = True
        self._pending_role = "master"
        self.master_button.setEnabled(False)
        self.slave_button.setEnabled(False)
        self._set_sync_role("setting_master")
        self.show_app_message(
            f"{message} Protective Master command queued; waiting for "
            "Master MS Mode:1 and resumed scalar data."
        )
        rate_ms = self._data_rate_ms or 4
        self._role_command_timeout.setInterval(max(3000, rate_ms * 2 + 1500))
        self._role_command_timeout.start()

    def _on_command_written(self, command: bytes) -> None:
        if command == b"e":
            self.show_app_message(
                "Reboot command written to the serial port; waiting 3 seconds "
                "before restoring controls."
            )
        elif command == b"!":
            self.show_app_message(
                "Slave command written; waiting for mode response and scalar data."
            )
        elif command == b" ":
            if self._protective_master_recovery:
                self.show_app_message(
                    "Protective Master command written; waiting for "
                    "Master MS Mode:1 and resumed scalar data."
                )
            else:
                self.show_app_message(
                    "Master command written; waiting for mode response and "
                    "scalar data."
                )

    def _on_command_write_failed(self, command: bytes, message: str) -> None:
        protective_recovery = (
            command == b" " and self._protective_master_recovery
        )
        self._pending_role = None
        self._pending_role_confirmed = False
        self._reported_role_waiting_for_stream = None
        self._protective_master_recovery = False
        self._disarm_slave_data_watch()
        self._role_command_timeout.stop()
        self._reboot_recovery_timer.stop()
        self._reboot_data_check_timer.stop()
        self._restore_device_controls()
        if protective_recovery:
            self._set_sync_role("recovery_failed")
        self.show_app_message(
            f"Serial write failed for {command!r}: {message}"
        )

    def _set_sync_role(self, role: str) -> None:
        self._sync_role = role

        if role == "master":
            text = "Sync role: MASTER (SCB Sync Out)"
            object_name = "roleMaster"
            badge_text = "MASTER / OUT"
            badge_object_name = "roleBadgeMaster"
        elif role == "slave":
            text = "Sync role: SLAVE (SCB Sync In)"
            object_name = "roleSlave"
            badge_text = "SLAVE / IN"
            badge_object_name = "roleBadgeSlave"
        elif role in ("setting_master", "verifying_master"):
            text = "Sync role: setting Master…"
            object_name = "roleUnknown"
            badge_text = "VERIFYING…" if role.startswith("verifying") else "SETTING…"
            badge_object_name = "roleBadgeUnknown"
        elif role in ("setting_slave", "verifying_slave"):
            text = "Sync role: setting Slave…"
            object_name = "roleUnknown"
            badge_text = "VERIFYING…" if role.startswith("verifying") else "SETTING…"
            badge_object_name = "roleBadgeUnknown"
        elif role == "slave_failed":
            text = "Sync role: SLAVE FAILED"
            object_name = "roleUnknown"
            badge_text = "SLAVE FAILED"
            badge_object_name = "roleBadgeUnknown"
        elif role == "recovery_failed":
            text = "Sync role: MASTER RECOVERY FAILED"
            object_name = "roleUnknown"
            badge_text = "RECOVERY FAILED"
            badge_object_name = "roleBadgeUnknown"
        elif role == "other":
            text = "Sync role: External PPS / GPS"
            object_name = "roleUnknown"
            badge_text = "OTHER"
            badge_object_name = "roleBadgeUnknown"
        else:
            text = "Sync role: unknown"
            object_name = "roleUnknown"
            badge_text = "MODE: ?"
            badge_object_name = "roleBadgeUnknown"

        self.role_label.setText(text)
        self.role_label.setObjectName(object_name)
        self.role_label.style().unpolish(self.role_label)
        self.role_label.style().polish(self.role_label)

        self.role_badge.setText(badge_text)
        self.role_badge.setObjectName(badge_object_name)
        self.role_badge.style().unpolish(self.role_badge)
        self.role_badge.style().polish(self.role_badge)

        self.master_button.setProperty("active", role == "master")
        self.slave_button.setProperty("active", role == "slave")
        for button in (self.master_button, self.slave_button):
            button.style().unpolish(button)
            button.style().polish(button)

        self.sync_role_changed.emit(role)

    def _set_timestamp_state(self, state: str) -> None:
        self._timestamp_state = state

        if state == "available":
            self.timestamp_label.setText("MS timestamp available")
            self.timestamp_label.setObjectName("timestampAvailable")
            self.enable_timestamp_button.hide()
        elif state == "missing":
            self.timestamp_label.setText(
                "MS timestamp unavailable — plotting disabled"
            )
            self.timestamp_label.setObjectName("timestampMissing")
            self.enable_timestamp_button.setEnabled(True)
            self.enable_timestamp_button.show()
        elif state == "enabling":
            self.timestamp_label.setText("Enabling MS timestamp…")
            self.timestamp_label.setObjectName("timestampMissing")
            self.enable_timestamp_button.setEnabled(False)
            self.enable_timestamp_button.show()
        else:
            self.timestamp_label.setText("MS timestamp: not checked")
            self.timestamp_label.setObjectName("timestampUnknown")
            self.enable_timestamp_button.hide()

        self.timestamp_label.style().unpolish(self.timestamp_label)
        self.timestamp_label.style().polish(self.timestamp_label)
        self.timestamp_state_changed.emit(state)

    def _render_stream_health(self) -> None:
        port = self._connected_port
        if port is None:
            return

        baudrate = int(self.baud_combo.currentText())
        if self._stream_health == "valid":
            self._set_state(
                "connected",
                f"{port} at {baudrate:,} baud  •  Valid QTFM data  •  "
                f"{self._valid_frame_count:,} frames",
            )
        elif self._stream_health == "silent":
            self._set_state(
                "warning",
                "Port is open, but no data is arriving. Check the baud rate "
                "or sensor output.",
            )
        elif self._stream_health == "garbled":
            self._set_state(
                "warning",
                "Port is open, but the data is unreadable. The selected baud "
                "rate is probably wrong.",
            )
        elif self._stream_health == "unrecognized":
            self._set_state(
                "warning",
                "Port is open, but no valid QTFM frames were found. Check the "
                "baud rate and sensor output mode.",
            )
        else:
            self._set_state(
                "checking",
                f"{port} is open. Checking for valid QTFM data…",
            )

    def _on_error(self, message: str) -> None:
        self._last_error = message
        self._set_state("error", message)

    def _on_closed(self) -> None:
        worker = self._worker
        self._worker = None
        self._connected_port = None

        if worker is not None:
            worker.deleteLater()

        self._set_controls_enabled(True)
        self.connect_button.setText("CONNECT")
        self.connect_button.setEnabled(self.port_combo.currentData() is not None)
        self.connect_button.setProperty("connected", False)
        self.connect_button.style().unpolish(self.connect_button)
        self.connect_button.style().polish(self.connect_button)
        self.master_button.setEnabled(False)
        self.slave_button.setEnabled(False)
        self.data_rate_combo.setEnabled(False)
        self.set_rate_button.setEnabled(False)
        self.reboot_button.setEnabled(False)
        self._data_rate_timeout.stop()
        self._role_command_timeout.stop()
        self._reboot_recovery_timer.stop()
        self._reboot_data_check_timer.stop()
        self._pending_role = None
        self._pending_role_confirmed = False
        self._reported_role_waiting_for_stream = None
        self._last_scalar_received_at = None
        self._disarm_slave_data_watch()
        self._protective_master_recovery = False
        self._data_rate_ms = None
        self._target_data_rate_ms = None
        self.current_rate_label.setText("Current: unknown")
        self._serial_number = None
        self.serial_number_label.clear()
        self.serial_number_label.hide()
        self._latest_value_nt = None
        self._render_live_value()
        self.data_rate_changed.emit(None)
        self.show_app_message("Ready")
        self.show_sensor_status_line("No sensor messages")

        if self._last_error:
            self._set_state("error", self._last_error)
        else:
            self._set_state("disconnected", "Not connected")
        self._timestamp_missing_streak = 0
        self._set_timestamp_state("unknown")
        self._set_sync_role("unknown")
        self.connection_changed.emit()

    def _set_controls_enabled(self, enabled: bool) -> None:
        self.port_combo.setEnabled(enabled)
        self.baud_combo.setEnabled(enabled)

    def _set_state(self, state: str, detail: str) -> None:
        if state == "connected":
            color = LED_CONNECTED
            label = "Connected"
        elif state in ("connecting", "checking"):
            color = LED_PENDING
            label = "Connecting" if state == "connecting" else "Checking data"
        elif state == "warning":
            color = LED_PENDING
            label = "Data warning"
        elif state == "error":
            color = LED_DISCONNECTED
            label = "Connection error"
        else:
            color = MUTED_TEXT
            label = "Disconnected"

        self.status_dot.setStyleSheet(
            f"background-color: {color}; border-radius: 6px; border: none;"
        )
        self.state_label.setText(label)
        self.detail_label.setText(detail)
