"""Main window: connect two to eight QTFM sensors and plot their differences."""

from __future__ import annotations

import re
import time
from datetime import datetime

import serial.tools.list_ports
from PyQt5.QtCore import QTimer, Qt
from PyQt5.QtGui import QCloseEvent, QColor, QPalette, QPixmap
from PyQt5.QtWidgets import (
    QApplication,
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QFileDialog,
    QFrame,
    QGridLayout,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMainWindow,
    QMessageBox,
    QPushButton,
    QScrollArea,
    QSizePolicy,
    QTabWidget,
    QTextBrowser,
    QVBoxLayout,
    QWidget,
)

import settings_store
from asd_panel import ASDSpectrumPanel
from csv_recorder import CSVRecorder
from parse_recording_dialog import ParseRecordingDialog
from sensor_panel import SensorPanel
from sensors import (
    DEFAULT_SENSOR_COUNT,
    MAX_SENSORS,
    SENSOR_KEYS,
    difference_label,
)
from terminal_window import TerminalWindow
from theme import QUSPIN_LOGO_PATH
from time_series_panel import TimeSeriesPanel
from timestamp_sync_monitor import TimestampSyncMonitor, TimestampSyncStatus
from version import APP_VERSION

SENSOR_COLUMNS = 2


def _port_sort_key(port: str) -> list[object]:
    """Sort COM2 before COM10 while also supporting non-Windows port names."""
    return [
        int(part) if part.isdigit() else part.casefold()
        for part in re.split(r"(\d+)", port)
    ]


class CommandBar(QFrame):
    """Filter and ASCII command controls.

    The bar itself is passive: :class:`MainWindow` wires its widgets and
    routes what they send, so identical bars can live in the main window and
    the sensor setup window at the same time.
    """

    def __init__(
        self,
        filter_commands: tuple[tuple[str, bytes], ...],
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self.setObjectName("commandBar")
        row = QHBoxLayout(self)
        row.setContentsMargins(10, 6, 10, 6)
        row.setSpacing(9)

        title = QLabel("COMMANDS")
        title.setObjectName("commandBarTitle")
        row.addWidget(title)

        self.filter_combo = QComboBox()
        for label, command in filter_commands:
            self.filter_combo.addItem(label, command)
        self.filter_combo.setMinimumWidth(155)
        row.addWidget(self.filter_combo)

        self.ascii_command_edit = QLineEdit()
        self.ascii_command_edit.setPlaceholderText(
            "ASCII command (no line ending)"
        )
        self.ascii_command_edit.setMaxLength(128)
        self.ascii_command_edit.setMinimumWidth(200)
        self.ascii_command_edit.setMaximumWidth(300)
        row.addWidget(self.ascii_command_edit)

        self.send_ascii_button = QPushButton("SEND")
        self.send_ascii_button.setObjectName("deviceButton")
        row.addWidget(self.send_ascii_button)

        self.apply_filter_button = QPushButton("APPLY FILTERS")
        self.apply_filter_button.setObjectName("deviceButton")
        row.addWidget(self.apply_filter_button)

        self.command_target_combo = QComboBox()
        self.command_target_combo.setMinimumWidth(135)
        self.command_target_combo.setToolTip(
            "Choose which connected sensor receives filter and ASCII commands."
        )
        row.addWidget(self.command_target_combo)

        self.broadcast_status_label = QLabel(
            "Connect every sensor or choose one connected sensor"
        )
        self.broadcast_status_label.setObjectName("controlHint")
        self.broadcast_status_label.setMinimumWidth(80)
        self.broadcast_status_label.setWordWrap(True)
        row.addWidget(self.broadcast_status_label, 1)


class SensorSetupWindow(QDialog):
    """Separate window holding every sensor connection card.

    Keeping the cards out of the main window means the plot area never shrinks
    as sensors are added, and eight cards get enough room to avoid scrolling on
    a normal display.
    """

    def __init__(
        self,
        panels: list[SensorPanel],
        refresh_ports,
        command_bar: CommandBar,
        show_terminal,
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self.setObjectName("sensorSetupWindow")
        self.setWindowTitle("QTFM Sensor Connections")
        self.setModal(False)
        # A plain window frame, so it can be minimized and left open beside the
        # main window while measuring.
        self.setWindowFlags(Qt.Window)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(14, 12, 14, 12)
        layout.setSpacing(9)

        heading_row = QHBoxLayout()
        heading_row.setSpacing(10)
        heading = QLabel("SENSOR CONNECTIONS")
        heading.setObjectName("plotTitle")
        heading_row.addWidget(heading)

        self.hint_label = QLabel(
            "Each sensor needs its own COM port. Close this window at any "
            "time; the connections stay open."
        )
        self.hint_label.setObjectName("controlHint")
        heading_row.addWidget(self.hint_label, 1)

        self.terminal_button = QPushButton("TERMINAL")
        self.terminal_button.setObjectName("secondaryButton")
        self.terminal_button.setToolTip(
            "Open a terminal window showing one sensor's raw serial data."
        )
        self.terminal_button.clicked.connect(show_terminal)
        heading_row.addWidget(self.terminal_button)

        self.refresh_button = QPushButton("REFRESH PORTS")
        self.refresh_button.setObjectName("secondaryButton")
        self.refresh_button.clicked.connect(refresh_ports)
        heading_row.addWidget(self.refresh_button)
        layout.addLayout(heading_row)

        self.command_bar = command_bar
        layout.addWidget(command_bar)

        self._panels = list(panels)
        self._columns = 0
        self.container = QWidget()
        self.grid = QGridLayout(self.container)
        self.grid.setContentsMargins(0, 0, 0, 0)
        self.grid.setHorizontalSpacing(9)
        self.grid.setVerticalSpacing(9)
        for panel in self._panels:
            panel.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum)
        self._apply_columns(SENSOR_COLUMNS)

        # The scroll area only ever engages on a display too short for the
        # chosen number of cards.
        self.scroll = QScrollArea()
        self.scroll.setObjectName("sensorScroll")
        self.scroll.setWidgetResizable(True)
        self.scroll.setFrameShape(QFrame.NoFrame)
        self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.scroll.setWidget(self.container)
        layout.addWidget(self.scroll, 1)

    CHROME_WIDTH = 46
    # Window margins plus the heading row and the embedded command bar.
    CHROME_HEIGHT = 150

    def _apply_columns(self, columns: int) -> None:
        if columns == self._columns:
            return
        self._columns = columns
        for panel in self._panels:
            self.grid.removeWidget(panel)
        for index, panel in enumerate(self._panels):
            self.grid.addWidget(panel, index // columns, index % columns)
        for column in range(MAX_SENSORS):
            self.grid.setColumnStretch(column, 1 if column < columns else 0)
        for row in range(MAX_SENSORS + 1):
            self.grid.setRowStretch(row, 0)
        # Absorb slack below the cards instead of stretching them.
        self.grid.setRowStretch((len(self._panels) + columns - 1) // columns, 1)

    def _available_size(self) -> tuple[int, int]:
        screen = QApplication.primaryScreen()
        if screen is None:
            return 1600, 1000
        available = screen.availableGeometry()
        return (
            int(available.width() * 0.95),
            int(available.height() * 0.92),
        )

    def adjust_to_content(self) -> None:
        """Lay the visible cards out so they fit without a vertical scrollbar."""
        visible = [panel for panel in self._panels if not panel.isHidden()]
        if not visible:
            return

        card = visible[0].sizeHint()
        spacing = self.grid.horizontalSpacing()
        max_width, max_height = self._available_size()

        # Prefer few columns, but widen rather than force the user to scroll.
        columns = SENSOR_COLUMNS
        for candidate in (SENSOR_COLUMNS, 3, 4):
            rows = -(-len(visible) // candidate)
            width = (
                candidate * card.width()
                + (candidate - 1) * spacing
                + self.CHROME_WIDTH
            )
            height = (
                rows * card.height()
                + (rows - 1) * spacing
                + self.CHROME_HEIGHT
            )
            if width > max_width:
                break
            columns = candidate
            if height <= max_height:
                break
        self._apply_columns(columns)

        self.container.adjustSize()
        hint = self.container.sizeHint()
        width = min(hint.width() + self.CHROME_WIDTH, max_width)
        height = min(hint.height() + self.CHROME_HEIGHT, max_height)
        self.resize(max(width, 700), max(height, 320))


class SyncGuideDialog(QDialog):
    """Readable, scrollable instructions for physically wired SCB sync."""

    def __init__(self, parent: QWidget | None = None) -> None:
        super().__init__(parent)
        self.setWindowTitle("User Guide: Sync QTFM Sensors with SCB")
        self.setModal(True)
        self.setMinimumSize(700, 520)
        self.resize(820, 620)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(18, 18, 18, 14)
        layout.setSpacing(12)

        self.guide_browser = QTextBrowser()
        self.guide_browser.setOpenExternalLinks(True)
        browser_palette = self.guide_browser.palette()
        browser_palette.setColor(QPalette.Base, QColor("#FFFFFF"))
        browser_palette.setColor(QPalette.Text, QColor("#17191C"))
        browser_palette.setColor(QPalette.Link, QColor("#005EA8"))
        self.guide_browser.setPalette(browser_palette)
        self.guide_browser.document().setDefaultStyleSheet(
            "body { color: #17191C; background-color: #FFFFFF; }"
            "h2, h3, p, li { color: #17191C; }"
            "a { color: #005EA8; text-decoration: underline; }"
            "code { color: #17191C; background-color: #ECEEF1; }"
        )
        self.guide_browser.setHtml(
            "<body><h2>Sync QTFM Sensors with SCB</h2>"
            "<p><b>The physical SCB wiring decides each sensor's role. "
            "The roles are not interchangeable or arbitrary.</b></p>"
            "<ol>"
            "<li>Fully start every sensor. Use identical data rates and "
            "environmental settings.</li>"
            "<li>Connect the sync cabling between the SCBs.</li>"
            "<li>Find the sensor whose SCB has the cable connected to "
            "<b>Sync Out</b>. Set that sensor to <b>MASTER — SYNC OUT</b>. "
            "Exactly one sensor may be Master.</li>"
            "<li>Set every sensor whose SCB is fed from <b>Sync In</b> to "
            "<b>SLAVE — SYNC IN</b>. Confirm that scalar magnetic data "
            "continues from each of them.</li>"
            "<li>Click <b>SYNC SENSORS</b>. Wait for the original sensor "
            "message <code>#Sensors Sync Complete</code>.</li>"
            "<li>Confirm millisecond timestamps, equal sample intervals, "
            "zero sample phase, and exact timestamp groups before using "
            "the difference trace.</li>"
            "</ol>"
            "<h3>More than two sensors</h3>"
            "<ul>"
            "<li>One Master drives every Slave; distribute its Sync Out "
            "signal to each Slave's Sync In.</li>"
            "<li>The plots always show one sensor, a second sensor, and their "
            "difference. Pick which two with the <b>PLOT</b> selectors.</li>"
            "<li>All sensors must run the same data rate, and a CSV row is "
            "written only when every recorded sensor shares one exact "
            "millisecond timestamp. By default the CSV stores each sensor's "
            "field only — compute any X − Y difference afterwards with the "
            "<b>PARSE RECORDING</b> window (or "
            "<code>tools/gradiometer_parser.py</code>) — while <b>RECORD "
            "DIFFERENCES</b> also stores every reference − sensor "
            "column.</li>"
            "</ul>"
            "<h3>Important</h3>"
            "<ul>"
            "<li>Do not choose Master or Slave based on sensor letter, serial "
            "number, or convenience. Follow the SCB cable connectors.</li>"
            "<li>Two Masters are invalid and will give improper data.</li>"
            "<li>If a Sync In sensor reports <code>ISR.Er1</code> or stops "
            "producing scalar data for five seconds, the sensor firmware "
            "protects it by returning it to Master mode. The app reports the "
            "change and restores the controls.</li>"
            "</ul>"
            "<p><a href='https://quspin.com/qtfm-gen-2/"
            "qtfm-gen-2-user-guide/syncing-your-gen-2-qtfm-sensors/'>"
            "Open the official QTFM Gen 2 syncing guide</a></p></body>"
        )
        layout.addWidget(self.guide_browser, 1)

        buttons = QDialogButtonBox(QDialogButtonBox.Close)
        buttons.rejected.connect(self.reject)
        layout.addWidget(buttons)

        self.setStyleSheet(
            "QDialog { background-color: #F4F5F7; color: #17191C; }"
            "QTextBrowser {"
            " background-color: #FFFFFF; color: #17191C;"
            " border: 1px solid #B8BCC2; border-radius: 5px;"
            " padding: 12px; font-size: 10.5pt;"
            "}"
            "QPushButton {"
            " background-color: #FAA519; color: #FFFFFF;"
            " border: none; border-radius: 5px;"
            " min-width: 90px; padding: 7px 15px; font-weight: 700;"
            "}"
            "QPushButton:hover { background-color: #FFB640; }"
        )


class MainWindow(QMainWindow):
    FILTER_COMMANDS = (
        ("Filters off", b"{"),
        ("60 Hz notch", b"u"),
        ("50 Hz notch", b"v"),
        ("High-pass 10 Hz", b"w"),
        ("High-pass 20 Hz", b"x"),
        ("Low-pass 10 Hz", b"y"),
        ("Low-pass 20 Hz", b"z"),
    )

    def __init__(self) -> None:
        super().__init__()
        self.setWindowTitle(f"QTFM Simple Gradiometer V{APP_VERSION}")
        self.setMinimumSize(1080, 720)
        self.resize(1400, 940)
        self._sensor_count = DEFAULT_SENSOR_COUNT
        self._reference_key = SENSOR_KEYS[0]
        self._compare_key = SENSOR_KEYS[1]
        self.timestamp_sync_monitor = TimestampSyncMonitor(
            self.active_keys,
            self._reference_key,
        )
        self.csv_recorder = CSVRecorder()
        self._recording_started_at: float | None = None
        self._recording_elapsed_timer = QTimer(self)
        self._recording_elapsed_timer.setInterval(1000)
        self._recording_elapsed_timer.timeout.connect(
            self._update_recording_elapsed
        )

        self._saved_ports: dict[str, str] = {}
        self._build_ui()
        self._set_recording_indicator(False)
        self._recording_elapsed_timer.start()
        self._apply_sensor_layout()
        self._restore_settings()
        self.refresh_ports()
        # closeEvent covers the user closing the window; aboutToQuit covers
        # exits that bypass it (for example QApplication.quit()).
        application = QApplication.instance()
        if application is not None:
            application.aboutToQuit.connect(self._save_settings)

    # ------------------------------------------------------------ sensor set
    @property
    def sensor_count(self) -> int:
        return self._sensor_count

    @property
    def active_keys(self) -> tuple[str, ...]:
        return SENSOR_KEYS[: self._sensor_count]

    @property
    def active_panels(self) -> tuple[SensorPanel, ...]:
        return tuple(self.sensor_panels[key] for key in self.active_keys)

    @property
    def reference_key(self) -> str:
        return self._reference_key

    @property
    def compare_key(self) -> str:
        return self._compare_key

    @property
    def sensor_a(self) -> SensorPanel:
        return self.sensor_panels["A"]

    @property
    def sensor_b(self) -> SensorPanel:
        return self.sensor_panels["B"]

    # ------------------------------------------------------------------- UI
    def _build_ui(self) -> None:
        central = QWidget()
        central.setObjectName("mainBackground")
        self.setCentralWidget(central)

        outer = QVBoxLayout(central)
        outer.setContentsMargins(14, 11, 14, 11)
        outer.setSpacing(8)

        self._command_bars: list[CommandBar] = []
        outer.addWidget(self._build_header())

        self.sensor_panels: dict[str, SensorPanel] = {
            key: SensorPanel(key, self._port_available) for key in SENSOR_KEYS
        }
        self.terminal_window = TerminalWindow(self)
        self.setup_command_bar = self._create_command_bar()
        self.sensor_window = SensorSetupWindow(
            list(self.sensor_panels.values()),
            self.refresh_ports,
            self.setup_command_bar,
            self.show_terminal,
            self,
        )

        self.time_series = TimeSeriesPanel()
        self.asd_spectrum = ASDSpectrumPanel()
        self.time_series.gradiometer_sample.connect(
            self.asd_spectrum.add_gradiometer_sample
        )
        for key, panel in self.sensor_panels.items():
            panel.measurement_received.connect(
                lambda measurement, sensor=key: self._on_measurement(
                    sensor,
                    measurement,
                )
            )
            panel.timestamp_state_changed.connect(
                lambda state, sensor=key: self._on_timestamp_state(
                    sensor,
                    state,
                )
            )
            panel.connection_changed.connect(self._update_sync_controls)
            panel.connection_changed.connect(self._update_command_controls)
            panel.connection_changed.connect(self._update_connection_summary)
            panel.sync_role_changed.connect(self._update_sync_controls)
            panel.sync_role_changed.connect(self._update_gradiometer_gate)
            panel.data_rate_changed.connect(self._update_gradiometer_gate)
            panel.sync_event.connect(self._on_sync_event)
            panel.raw_lines_received.connect(
                lambda lines, sensor=key: self.terminal_window.append_lines(
                    sensor,
                    lines,
                )
            )

        self.plot_tabs = QTabWidget()
        self.plot_tabs.setObjectName("plotTabs")
        self.plot_tabs.addTab(self.time_series, "TIME SERIES")
        self.plot_tabs.addTab(self.asd_spectrum, "ASD")
        self.gradiometer_setup_bar = self._build_timestamp_sync_box()
        self.time_series.add_sync_controls(
            self.timestamp_alignment_label,
            self.sync_button,
        )

        self.plot_container = QWidget()
        plot_layout = QVBoxLayout(self.plot_container)
        plot_layout.setContentsMargins(0, 0, 0, 0)
        plot_layout.setSpacing(7)
        plot_layout.addWidget(self.gradiometer_setup_bar)
        plot_layout.addWidget(self.plot_tabs, 1)
        outer.addWidget(self.plot_container, 1)

        footer = QLabel(
            "Plotting requires each sensor's QTFM millisecond timestamp."
        )
        footer.setObjectName("footer")
        footer.setAlignment(Qt.AlignCenter)
        outer.addWidget(footer)
        self._update_command_controls()

    def _build_header(self) -> QFrame:
        header = QFrame()
        header.setObjectName("hero")
        header_layout = QVBoxLayout(header)
        header_layout.setContentsMargins(16, 10, 14, 10)
        header_layout.setSpacing(7)

        header_top = QHBoxLayout()
        header_top.setSpacing(12)

        self.logo_label = QLabel()
        self.logo_label.setObjectName("brandLogo")
        logo = QPixmap(str(QUSPIN_LOGO_PATH))
        if not logo.isNull():
            self.logo_label.setPixmap(
                logo.scaledToHeight(26, Qt.SmoothTransformation)
            )
        self.logo_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
        header_top.addWidget(self.logo_label)

        title_column = QVBoxLayout()
        title_column.setSpacing(2)
        title_row = QHBoxLayout()
        title_row.setSpacing(8)
        title = QLabel("QTFM SIMPLE GRADIOMETER")
        title.setObjectName("appTitle")
        title_row.addWidget(title)

        self.version_label = QLabel(f"V{APP_VERSION}")
        self.version_label.setObjectName("appVersion")
        self.version_label.setToolTip("Application version")
        title_row.addWidget(self.version_label, 0, Qt.AlignBottom)
        title_row.addStretch()
        title_column.addLayout(title_row)

        self.subtitle_label = QLabel()
        self.subtitle_label.setObjectName("appSubtitle")
        title_column.addWidget(self.subtitle_label)
        header_top.addLayout(title_column)
        header_top.addStretch()

        self.connection_summary_label = QLabel()
        self.connection_summary_label.setObjectName("controlHint")
        header_top.addWidget(self.connection_summary_label)

        count_label = QLabel("SENSORS")
        count_label.setObjectName("fieldLabel")
        header_top.addWidget(count_label)

        self.sensor_count_combo = QComboBox()
        for count in range(2, MAX_SENSORS + 1):
            self.sensor_count_combo.addItem(str(count), count)
        self.sensor_count_combo.setCurrentIndex(
            self.sensor_count_combo.findData(self._sensor_count)
        )
        self.sensor_count_combo.setMinimumWidth(70)
        self.sensor_count_combo.setToolTip(
            "Number of QTFM sensors connected, gated and recorded."
        )
        self.sensor_count_combo.currentIndexChanged.connect(
            self._on_sensor_count_changed
        )
        header_top.addWidget(self.sensor_count_combo)

        self.sensor_setup_button = QPushButton("SENSOR SETUP")
        self.sensor_setup_button.setObjectName("secondaryButton")
        self.sensor_setup_button.setToolTip(
            "Open the sensor connection window."
        )
        self.sensor_setup_button.clicked.connect(self.show_sensor_setup)
        header_top.addWidget(self.sensor_setup_button)

        self.terminal_button = QPushButton("TERMINAL")
        self.terminal_button.setObjectName("secondaryButton")
        self.terminal_button.setToolTip(
            "Open a terminal window showing one sensor's raw serial data."
        )
        self.terminal_button.clicked.connect(self.show_terminal)
        header_top.addWidget(self.terminal_button)
        header_layout.addLayout(header_top)

        recording_row = QHBoxLayout()
        recording_row.setSpacing(9)
        self.recording_bar = self._build_recording_bar()
        recording_row.addWidget(self.recording_bar, 1)

        recording_header = QFrame()
        recording_header.setObjectName("recordingHeader")
        recording_header_layout = QHBoxLayout(recording_header)
        recording_header_layout.setContentsMargins(10, 6, 10, 6)
        recording_header_layout.setSpacing(7)

        self.recording_led = QLabel()
        self.recording_led.setObjectName("recordingLed")
        self.recording_led.setFixedSize(12, 12)
        self.recording_led.setToolTip("CSV recording state")
        recording_header_layout.addWidget(self.recording_led)

        self.recording_timer_label = QLabel("Not recording")
        self.recording_timer_label.setObjectName("recordingTimer")
        self.recording_timer_label.setMinimumWidth(120)
        recording_header_layout.addWidget(self.recording_timer_label)
        recording_row.addWidget(recording_header)
        header_layout.addLayout(recording_row)

        self.command_bar = self._build_command_bar()
        header_layout.addWidget(self.command_bar)
        return header

    def _build_command_bar(self) -> CommandBar:
        return self._create_command_bar()

    def _create_command_bar(self) -> CommandBar:
        """Create a command bar and route its actions through this window."""
        bar = CommandBar(self.FILTER_COMMANDS)
        bar.send_ascii_button.clicked.connect(
            lambda _checked=False, b=bar: self._send_ascii_from(b)
        )
        bar.ascii_command_edit.returnPressed.connect(
            lambda b=bar: self._send_ascii_from(b)
        )
        bar.apply_filter_button.clicked.connect(
            lambda _checked=False, b=bar: self._apply_filter_from(b)
        )
        bar.command_target_combo.currentIndexChanged.connect(
            self._update_command_controls
        )
        self._command_bars.append(bar)
        return bar

    # The header bar's widgets, exposed under their historical names.
    @property
    def filter_combo(self) -> QComboBox:
        return self.command_bar.filter_combo

    @property
    def ascii_command_edit(self) -> QLineEdit:
        return self.command_bar.ascii_command_edit

    @property
    def send_ascii_button(self) -> QPushButton:
        return self.command_bar.send_ascii_button

    @property
    def apply_filter_button(self) -> QPushButton:
        return self.command_bar.apply_filter_button

    @property
    def command_target_combo(self) -> QComboBox:
        return self.command_bar.command_target_combo

    @property
    def broadcast_status_label(self) -> QLabel:
        return self.command_bar.broadcast_status_label

    def _build_recording_bar(self) -> QFrame:
        bar = QFrame()
        bar.setObjectName("actionBar")
        row = QHBoxLayout(bar)
        row.setContentsMargins(10, 5, 10, 5)
        row.setSpacing(9)

        self.start_recording_button = QPushButton("START RECORDING")
        self.start_recording_button.setObjectName("recordButton")
        self.start_recording_button.clicked.connect(self.start_csv_recording)
        row.addWidget(self.start_recording_button)

        # Doubled so Qt renders a literal ampersand instead of a mnemonic.
        self.stop_recording_button = QPushButton("STOP && SAVE")
        self.stop_recording_button.setObjectName("deviceButton")
        self.stop_recording_button.setEnabled(False)
        self.stop_recording_button.clicked.connect(self.stop_csv_recording)
        row.addWidget(self.stop_recording_button)

        self.record_differences_checkbox = QCheckBox("RECORD DIFFERENCES")
        self.record_differences_checkbox.setChecked(False)
        self.record_differences_checkbox.setToolTip(
            "Also store a reference − sensor column for every sensor, using "
            "the first PLOT sensor at the moment recording starts. Leave "
            "unchecked for smaller files and compute any X − Y channel "
            "afterwards with tools/gradiometer_parser.py."
        )
        row.addWidget(self.record_differences_checkbox)

        self.parse_recording_button = QPushButton("PARSE RECORDING")
        self.parse_recording_button.setObjectName("deviceButton")
        self.parse_recording_button.setToolTip(
            "Compute gradiometer X − Y channels from an existing recording."
        )
        self.parse_recording_button.clicked.connect(self.show_parse_dialog)
        row.addWidget(self.parse_recording_button)

        self.recording_status_label = QLabel("Exact-timestamp sensor CSV")
        self.recording_status_label.setObjectName("controlHint")
        self.recording_status_label.setToolTip(
            "Saves one CSV row per millisecond timestamp shared by every "
            "recorded sensor. By default only the sensor fields are stored "
            "(compute any X − Y channel afterwards with PARSE RECORDING or "
            "tools/gradiometer_parser.py); check RECORD DIFFERENCES for a "
            "self-contained file with reference − sensor columns."
        )
        row.addWidget(self.recording_status_label, 1)
        return bar

    def _build_timestamp_sync_box(self) -> QFrame:
        box = QFrame()
        box.setObjectName("syncWorkflowBox")
        box.setMinimumHeight(38)
        box.setMaximumHeight(46)
        workflow = QHBoxLayout(box)
        workflow.setContentsMargins(9, 4, 9, 4)
        workflow.setSpacing(8)

        heading = QLabel("GRADIOMETER SETUP")
        heading.setObjectName("syncSectionTitle")
        workflow.addWidget(heading)

        plot_label = QLabel("PLOT")
        plot_label.setObjectName("fieldLabel")
        workflow.addWidget(plot_label)

        self.reference_combo = QComboBox()
        self.reference_combo.setMinimumWidth(105)
        self.reference_combo.setToolTip(
            "First plotted sensor; the bottom plot subtracts the second "
            "sensor from this one. With RECORD DIFFERENCES checked, it is "
            "also the reference a new recording's difference columns "
            "subtract from."
        )
        self.reference_combo.currentIndexChanged.connect(
            self._on_reference_changed
        )
        workflow.addWidget(self.reference_combo)

        minus_label = QLabel("−")
        minus_label.setObjectName("syncSectionTitle")
        workflow.addWidget(minus_label)

        self.compare_combo = QComboBox()
        self.compare_combo.setMinimumWidth(105)
        self.compare_combo.setToolTip(
            "Second plotted sensor. The bottom plot is the first sensor "
            "minus this one."
        )
        self.compare_combo.currentIndexChanged.connect(
            self._on_compare_changed
        )
        workflow.addWidget(self.compare_combo)

        self.wiring_rule_label = QLabel(
            "SCB Sync Out → MASTER  |  SCB Sync In → SLAVE"
        )
        self.wiring_rule_label.setObjectName("wiringRule")
        self.wiring_rule_label.setToolTip(
            "Physical SCB connector wiring determines the sensor roles."
        )
        workflow.addWidget(self.wiring_rule_label)

        self.sync_status_label = QLabel("Connect every sensor.")
        self.sync_status_label.setObjectName("syncStatus")
        workflow.addWidget(self.sync_status_label, 1)

        self.timestamp_distance_label = QLabel("Distance: — ms")
        self.timestamp_distance_label.setObjectName("timestampMetric")
        workflow.addWidget(self.timestamp_distance_label)

        self.timestamp_interval_label = QLabel("Interval: — ms")
        self.timestamp_interval_label.setObjectName("timestampMetric")
        workflow.addWidget(self.timestamp_interval_label)

        self.timestamp_phase_label = QLabel("Phase: — ms")
        self.timestamp_phase_label.setObjectName("timestampMetric")
        workflow.addWidget(self.timestamp_phase_label)

        self.timestamp_alignment_label = QLabel(
            "WAITING FOR MS TIMESTAMPS"
        )
        self.timestamp_alignment_label.setObjectName(
            "timestampAlignmentWaiting"
        )

        self.sync_help_button = QPushButton("USER GUIDE")
        self.sync_help_button.setObjectName("helpButton")
        self.sync_help_button.setToolTip(
            "User Guide: Sync Two QTFM Sensors with SCB"
        )
        self.sync_help_button.clicked.connect(self.show_sync_guide)
        workflow.addWidget(self.sync_help_button)

        self.sync_button = QPushButton("SYNC SENSORS")
        self.sync_button.setObjectName("syncButtonProminent")
        self.sync_button.setEnabled(False)
        self.sync_button.clicked.connect(self.sync_sensors)
        # A space-starved toolbar row may compress children below their size
        # hint, clipping the label; pin the minimum to the styled hint.
        self.sync_button.setMinimumHeight(
            max(32, self.sync_button.sizeHint().height())
        )
        return box

    # --------------------------------------------------- settings persistence
    def _restore_settings(self) -> None:
        """Re-apply the user-adjusted settings saved by the previous run."""
        data = settings_store.load_settings()
        if not data:
            return
        try:
            self._apply_saved_settings(data)
        except Exception:
            # A hand-edited or truncated settings file must never prevent
            # the application from starting with defaults.
            self._saved_ports = {}

    def _apply_saved_settings(self, data: dict) -> None:
        count = data.get("sensor_count")
        if isinstance(count, int):
            index = self.sensor_count_combo.findData(count)
            if index >= 0:
                self.sensor_count_combo.setCurrentIndex(index)

        plot = data.get("plot", {})
        reference = plot.get("reference")
        compare = plot.get("compare")
        if (
            isinstance(reference, str)
            and isinstance(compare, str)
            and reference in self.active_keys
            and compare in self.active_keys
            and reference != compare
        ):
            self._reference_key = reference
            self._compare_key = compare
            self._apply_sensor_layout()

        recording = data.get("recording", {})
        if isinstance(recording.get("record_differences"), bool):
            self.record_differences_checkbox.setChecked(
                recording["record_differences"]
            )

        time_series = data.get("time_series", {})
        window_seconds = time_series.get("window_seconds")
        if isinstance(window_seconds, int):
            index = self.time_series.history_combo.findData(window_seconds)
            if index >= 0:
                self.time_series.history_combo.setCurrentIndex(index)

        asd = data.get("asd", {})
        point_count = asd.get("point_count")
        if isinstance(point_count, int):
            self.asd_spectrum.point_count_spin.setValue(point_count)
        if isinstance(asd.get("log_x"), bool):
            self.asd_spectrum.log_x_checkbox.setChecked(asd["log_x"])

        terminal = data.get("terminal", {})
        starts_with = terminal.get("starts_with")
        if isinstance(starts_with, str):
            self.terminal_window.filter_edit.setText(starts_with)
        terminal_sensor = terminal.get("sensor")
        if isinstance(terminal_sensor, str):
            index = self.terminal_window.sensor_combo.findData(terminal_sensor)
            if index >= 0:
                self.terminal_window.sensor_combo.setCurrentIndex(index)

        sensors = data.get("sensors", {})
        if isinstance(sensors, dict):
            for key, entry in sensors.items():
                panel = self.sensor_panels.get(key)
                if panel is None or not isinstance(entry, dict):
                    continue
                baud = entry.get("baud")
                if isinstance(baud, str) and baud in panel.BAUDRATES:
                    panel.baud_combo.setCurrentText(baud)
                rate = entry.get("data_rate_ms")
                if isinstance(rate, int):
                    index = panel.data_rate_combo.findData(rate)
                    if index >= 0:
                        panel.data_rate_combo.setCurrentIndex(index)
                port = entry.get("port")
                if isinstance(port, str) and port:
                    # Applied by the next refresh_ports, once the ports of
                    # this run are known.
                    self._saved_ports[key] = port

    def _collect_settings(self) -> dict:
        sensors: dict[str, dict] = {}
        for key, panel in self.sensor_panels.items():
            entry: dict = {"baud": panel.baud_combo.currentText()}
            port = panel.active_port or panel.port_combo.currentData()
            if isinstance(port, str):
                entry["port"] = port
            rate = panel.data_rate_combo.currentData()
            if isinstance(rate, int):
                entry["data_rate_ms"] = rate
            sensors[key] = entry
        return {
            "version": 1,
            "sensor_count": self._sensor_count,
            "plot": {
                "reference": self._reference_key,
                "compare": self._compare_key,
            },
            "recording": {
                "record_differences": (
                    self.record_differences_checkbox.isChecked()
                ),
            },
            "time_series": {
                "window_seconds": self.time_series.history_combo.currentData(),
            },
            "asd": {
                "point_count": self.asd_spectrum.point_count_spin.value(),
                "log_x": self.asd_spectrum.log_x_checkbox.isChecked(),
            },
            "terminal": {
                "starts_with": self.terminal_window.filter_edit.text(),
                "sensor": self.terminal_window.selected_sensor,
            },
            "sensors": sensors,
        }

    # ----------------------------------------------------- sensor selection
    @staticmethod
    def _present_window(window: QWidget) -> None:
        """Show a window and bring it to the front, restoring it if minimized."""
        window.setWindowState(
            (window.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive
        )
        window.show()
        window.raise_()
        window.activateWindow()

    def show_sensor_setup(self) -> None:
        """Open, raise and focus the sensor connection window."""
        if not self.sensor_window.isVisible():
            # Don't fight a size the user chose while the window was open.
            self.sensor_window.adjust_to_content()
        self._present_window(self.sensor_window)

    def show_terminal(self) -> None:
        """Open, raise and focus the raw serial terminal window."""
        self.terminal_window.set_sensors(self.active_keys)
        self._present_window(self.terminal_window)

    def show_parse_dialog(self) -> None:
        """Open, raise and focus the recording parser window."""
        if not hasattr(self, "parse_dialog"):
            self.parse_dialog = ParseRecordingDialog(self)
        self._present_window(self.parse_dialog)

    def _on_sensor_count_changed(self, *_args) -> None:
        requested = self.sensor_count_combo.currentData()
        if not isinstance(requested, int) or requested == self._sensor_count:
            return

        if self.csv_recorder.active:
            self.sensor_count_combo.blockSignals(True)
            self.sensor_count_combo.setCurrentIndex(
                self.sensor_count_combo.findData(self._sensor_count)
            )
            self.sensor_count_combo.blockSignals(False)
            QMessageBox.information(
                self,
                "Recording is active",
                "Stop the CSV recording before changing the sensor count.",
            )
            return

        dropped = [
            panel
            for panel in self.sensor_panels.values()
            if panel.sensor_key in SENSOR_KEYS[requested:]
            and panel.active_port is not None
        ]
        for panel in dropped:
            panel.disconnect()

        self._sensor_count = requested
        self._apply_sensor_layout()
        self.refresh_ports()

    def _on_reference_changed(self, *_args) -> None:
        selected = self.reference_combo.currentData()
        if not isinstance(selected, str) or selected == self._reference_key:
            return
        if selected == self._compare_key:
            # Picking the other plotted sensor flips the pair (A−B → B−A).
            self._compare_key = self._reference_key
        self._reference_key = selected
        self._apply_sensor_layout()

    def _on_compare_changed(self, *_args) -> None:
        selected = self.compare_combo.currentData()
        if not isinstance(selected, str) or selected == self._compare_key:
            return
        if selected == self._reference_key:
            self._reference_key = self._compare_key
        self._compare_key = selected
        self._apply_sensor_layout()

    def _apply_sensor_layout(self) -> None:
        """Push the active sensors and the plotted pair into every component."""
        active = self.active_keys
        if self._reference_key not in active:
            self._reference_key = active[0]
        if self._compare_key not in active or self._compare_key == self._reference_key:
            self._compare_key = next(
                (key for key in active if key != self._reference_key),
                self._reference_key,
            )

        for key, panel in self.sensor_panels.items():
            panel.setVisible(key in active)
        self.sensor_window.adjust_to_content()
        self.terminal_window.set_sensors(active)

        self._refresh_plot_combos()
        self._refresh_command_target_combo()

        self.timestamp_sync_monitor.set_sensors(active, self._reference_key)
        self.time_series.set_sensor_layout(
            active,
            self._reference_key,
            self._compare_key,
        )
        self.asd_spectrum.set_sensor_layout(
            active,
            self._reference_key,
            self._compare_key,
        )

        plotted = difference_label(self._reference_key, self._compare_key)
        self.subtitle_label.setText(
            f"{len(active)} sensors  •  plotting {plotted}  •  follow the SCB "
            "wiring, verify timing, then sync"
        )
        self.subtitle_label.setToolTip(
            "The recorded CSV holds every sensor's field per shared "
            "timestamp; compute differences with the PARSE RECORDING window "
            "or tools/gradiometer_parser.py."
        )
        self.sensor_window.setWindowTitle(
            f"QTFM Sensor Connections — {len(active)} sensors"
        )

        self._update_connection_summary()
        self._render_timestamp_sync_status(self.timestamp_sync_monitor.status())
        self._update_sync_controls()
        self._update_command_controls()
        self._update_gradiometer_gate()

    def _update_connection_summary(self, *_args) -> None:
        connected = sum(
            panel.connected_port is not None for panel in self.active_panels
        )
        self.connection_summary_label.setText(
            f"{connected} / {len(self.active_keys)} connected"
        )

    def _refresh_plot_combos(self) -> None:
        self.reference_combo.blockSignals(True)
        self.reference_combo.clear()
        for key in self.active_keys:
            self.reference_combo.addItem(f"Sensor {key}", key)
        self.reference_combo.setCurrentIndex(
            max(0, self.reference_combo.findData(self._reference_key))
        )
        self.reference_combo.blockSignals(False)

        # Both selectors list every sensor; choosing the same sensor in both
        # swaps the pair instead of being rejected.
        self.compare_combo.blockSignals(True)
        self.compare_combo.clear()
        for key in self.active_keys:
            self.compare_combo.addItem(f"Sensor {key}", key)
        self.compare_combo.setCurrentIndex(
            max(0, self.compare_combo.findData(self._compare_key))
        )
        self.compare_combo.blockSignals(False)

    def _refresh_command_target_combo(self) -> None:
        for bar in self._command_bars:
            combo = bar.command_target_combo
            previous = combo.currentData()
            combo.blockSignals(True)
            combo.clear()
            combo.addItem("All sensors", "both")
            for key in self.active_keys:
                combo.addItem(f"Sensor {key}", key)
            index = combo.findData(previous)
            combo.setCurrentIndex(max(0, index))
            combo.blockSignals(False)

    # ------------------------------------------------------------- recording
    def start_csv_recording(self) -> None:
        default_name = datetime.now().strftime(
            "qtfm_recording_%Y%m%d_%H%M%S.csv"
        )
        selected_path, _filter = QFileDialog.getSaveFileName(
            self,
            "Start QTFM CSV recording",
            default_name,
            "CSV files (*.csv)",
        )
        if not selected_path:
            return

        gradiometer_path = CSVRecorder.output_path(selected_path)
        if gradiometer_path.exists():
            answer = QMessageBox.question(
                self,
                "Overwrite recording file?",
                f"This file already exists:\n{gradiometer_path}\n\nOverwrite it?",
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No,
            )
            if answer != QMessageBox.Yes:
                return

        try:
            gradiometer_path = self.csv_recorder.start(
                selected_path,
                {
                    key: self.sensor_panels[key].serial_number
                    for key in self.active_keys
                },
                self.active_keys,
                reference=(
                    self._reference_key
                    if self.record_differences_checkbox.isChecked()
                    else None
                ),
            )
        except OSError as exc:
            QMessageBox.critical(
                self,
                "Could not start recording",
                str(exc),
            )
            return

        self.start_recording_button.setEnabled(False)
        self.stop_recording_button.setEnabled(True)
        self._recording_started_at = time.monotonic()
        self._set_recording_indicator(True)
        columns = (
            "fields + " + ", ".join(self.csv_recorder.difference_names())
            if self.csv_recorder.reference is not None
            else "fields only"
        )
        self.recording_status_label.setText(
            f"RECORDING  •  {gradiometer_path.name}  •  {columns}"
        )

    def stop_csv_recording(self) -> None:
        gradiometer_path = self.csv_recorder.stop()
        self.start_recording_button.setEnabled(True)
        self.stop_recording_button.setEnabled(False)
        self._set_recording_indicator(False)
        if gradiometer_path is not None:
            self.recording_status_label.setText(
                f"Saved {self.csv_recorder.rows:,} exact timestamp groups  •  "
                f"{gradiometer_path.name}"
            )
        else:
            self.recording_status_label.setText("Recording stopped.")

    @staticmethod
    def _format_elapsed_time(elapsed_seconds: float) -> str:
        total_seconds = max(0, int(elapsed_seconds))
        hours, remainder = divmod(total_seconds, 3600)
        minutes, seconds = divmod(remainder, 60)
        return f"{hours:02d}:{minutes:02d}:{seconds:02d}"

    def _set_recording_indicator(
        self,
        active: bool,
        inactive_text: str = "Not recording",
    ) -> None:
        self.recording_led.setProperty("active", active)
        self.recording_led.style().unpolish(self.recording_led)
        self.recording_led.style().polish(self.recording_led)
        if active:
            self.recording_timer_label.setText("REC  00:00:00")
            self.recording_timer_label.setProperty("active", True)
        else:
            self._recording_started_at = None
            self.recording_timer_label.setText(inactive_text)
            self.recording_timer_label.setProperty("active", False)
        self.recording_timer_label.style().unpolish(
            self.recording_timer_label
        )
        self.recording_timer_label.style().polish(
            self.recording_timer_label
        )
        # The plotted pair stays freely selectable while recording; the CSV
        # keeps the sensor set, reference choice, and column layout captured
        # when it started.
        self.sensor_count_combo.setEnabled(not active)
        self.record_differences_checkbox.setEnabled(not active)

    def _update_recording_elapsed(self, now: float | None = None) -> None:
        if (
            self._recording_started_at is None
            or not self.csv_recorder.active
        ):
            return
        observed_at = time.monotonic() if now is None else now
        elapsed = observed_at - self._recording_started_at
        self.recording_timer_label.setText(
            f"REC  {self._format_elapsed_time(elapsed)}"
        )

    # -------------------------------------------------------------- commands
    def apply_filter_command(self) -> None:
        self._apply_filter_from(self.command_bar)

    def send_ascii_command(self) -> None:
        self._send_ascii_from(self.command_bar)

    def _apply_filter_from(self, bar: CommandBar) -> None:
        command = bar.filter_combo.currentData()
        if not isinstance(command, bytes):
            return
        self._route_command(
            bar,
            command,
            f"filter: {bar.filter_combo.currentText()}",
        )

    def _send_ascii_from(self, bar: CommandBar) -> None:
        text = bar.ascii_command_edit.text()
        if not text:
            bar.broadcast_status_label.setText("Enter an ASCII command")
            return
        try:
            command = text.encode("ascii")
        except UnicodeEncodeError:
            bar.broadcast_status_label.setText("ASCII characters only")
            return

        if command == b"e":
            bar.broadcast_status_label.setText(
                "Use the independent REBOOT SENSOR buttons"
            )
            return
        if command == b"}":
            bar.broadcast_status_label.setText(
                "Use each sensor's independent data-rate control"
            )
            return

        if self._route_command(bar, command, f"ASCII {text!r}"):
            bar.ascii_command_edit.clear()

    def _selected_command_targets(
        self,
        bar: CommandBar,
    ) -> tuple[str, tuple[SensorPanel, ...]]:
        target = bar.command_target_combo.currentData()
        if isinstance(target, str) and target in self.active_keys:
            panel = self.sensor_panels[target]
            return panel.sensor_name, (panel,)
        return "all sensors", self.active_panels

    def _route_command(
        self,
        bar: CommandBar,
        command: bytes,
        description: str,
    ) -> bool:
        target_name, panels = self._selected_command_targets(bar)
        disconnected = [
            panel.sensor_name
            for panel in panels
            if panel.connected_port is None
        ]
        if disconnected:
            bar.broadcast_status_label.setText(
                f"Connect {', '.join(disconnected)} before sending"
            )
            return False

        if all(panel.send_command(command, description) for panel in panels):
            bar.broadcast_status_label.setText(
                f"Sent {description} to {target_name}"
            )
            return True
        bar.broadcast_status_label.setText(
            f"Command could not reach {target_name}"
        )
        return False

    def _update_command_controls(self, *_args) -> None:
        for bar in self._command_bars:
            target_name, panels = self._selected_command_targets(bar)
            enabled = bool(panels) and all(
                panel.connected_port is not None for panel in panels
            )
            for widget in (
                bar.filter_combo,
                bar.apply_filter_button,
                bar.ascii_command_edit,
                bar.send_ascii_button,
            ):
                widget.setEnabled(enabled)
            if enabled:
                bar.broadcast_status_label.setText(f"Target: {target_name}")
            elif target_name == "all sensors":
                bar.broadcast_status_label.setText(
                    "Connect every sensor or choose one connected sensor"
                )
            else:
                bar.broadcast_status_label.setText(f"Connect {target_name}")

    def show_sync_guide(self) -> None:
        guide = SyncGuideDialog(self)
        guide.exec_()

    # ---------------------------------------------------------- measurements
    def _on_measurement(self, sensor: str, measurement: object) -> None:
        if sensor not in self.active_keys:
            return
        status = self.timestamp_sync_monitor.add(sensor, measurement)
        self._render_timestamp_sync_status(status)
        self._update_gradiometer_gate()
        self.time_series.add_measurement(sensor, measurement)
        self.asd_spectrum.add_measurement(sensor, measurement)
        was_recording = self.csv_recorder.active
        previous_rows = self.csv_recorder.rows
        recorded = self.csv_recorder.add_measurement(
            sensor,
            measurement,
        )
        if was_recording and not recorded and not self.csv_recorder.active:
            self.start_recording_button.setEnabled(True)
            self.stop_recording_button.setEnabled(False)
            self._set_recording_indicator(False, "Recording stopped")
            self.recording_status_label.setText(
                f"RECORDING ERROR  •  {self.csv_recorder.last_error}"
            )
        elif (
            recorded
            and self.csv_recorder.rows != previous_rows
            and self.csv_recorder.rows % 100 == 0
        ):
            self.recording_status_label.setText(
                f"RECORDING  •  {self.csv_recorder.rows:,} exact timestamp "
                "groups"
            )

    def _on_timestamp_state(self, sensor: str, state: str) -> None:
        self.time_series.set_timestamp_state(sensor, state)
        if state != "available":
            self.timestamp_sync_monitor.clear_sensor(sensor)
        self._render_timestamp_sync_status(self.timestamp_sync_monitor.status())
        self._update_gradiometer_gate()

    def _update_gradiometer_gate(self, *_args) -> None:
        status = self.timestamp_sync_monitor.status()
        panels = self.active_panels
        timestamp_ready = all(
            panel.timestamp_state == "available" for panel in panels
        )
        rates = {
            panel.data_rate_ms
            for panel in panels
            if panel.data_rate_ms is not None
        }
        masters = [panel for panel in panels if panel.sync_role == "master"]

        if len(panels) < 2:
            enabled = False
            reason = "At least two sensors are required."
        elif len(masters) > 1:
            enabled = False
            names = ", ".join(panel.sensor_name for panel in masters)
            reason = (
                f"Invalid configuration: {names} all report Master. Verify "
                "the SCB wiring and set every Sync In sensor to Slave."
            )
        elif not timestamp_ready:
            enabled = False
            reason = "MS timestamps are required from every sensor."
        elif len(rates) > 1:
            enabled = False
            detail = ", ".join(f"{rate} ms" for rate in sorted(rates))
            reason = f"Data rates differ: {detail}."
        elif not status.ready:
            enabled = False
            reason = status.reason
        elif status.needs_sync:
            enabled = False
            reason = status.reason
        else:
            enabled = True
            rate_text = (
                f"{status.sample_interval_ms} ms"
                if status.sample_interval_ms is not None
                else "matching"
            )
            reason = (
                f"Enabled: exact timestamp groups at {rate_text} data rate."
            )
        self.time_series.set_gradiometer_status(enabled, reason)
        self.asd_spectrum.set_gradiometer_status(enabled, reason)
        self.csv_recorder.set_gradiometer_enabled(enabled)

    def _render_timestamp_sync_status(
        self,
        status: TimestampSyncStatus,
    ) -> None:
        self.timestamp_distance_label.setText(
            "Distance: — ms"
            if status.distance_ms is None
            else f"Distance: {status.distance_ms:,.1f} ms"
        )
        self.timestamp_interval_label.setText(
            "Interval: — ms"
            if status.sample_interval_ms is None
            else f"Interval: {status.sample_interval_ms} ms"
        )
        self.timestamp_phase_label.setText(
            "Phase: — ms"
            if status.phase_offset_ms is None
            else f"Phase: {status.phase_offset_ms} ms"
        )

        if status.needs_sync:
            label = f"NEEDS SYNC — {status.reason}"
            object_name = "timestampAlignmentBad"
        elif status.ready:
            label = f"TIMING OK — {status.reason}"
            object_name = "timestampAlignmentGood"
        else:
            label = status.reason.upper()
            object_name = "timestampAlignmentWaiting"

        self.timestamp_alignment_label.setText(label)
        self.timestamp_alignment_label.setObjectName(object_name)
        self.timestamp_alignment_label.style().unpolish(
            self.timestamp_alignment_label
        )
        self.timestamp_alignment_label.style().polish(
            self.timestamp_alignment_label
        )

    # ------------------------------------------------------------------ ports
    def refresh_ports(self) -> None:
        """Discover serial ports and update every sensor selector."""
        discovered = sorted(
            (
                (port.device, port.description or "")
                for port in serial.tools.list_ports.comports()
            ),
            key=lambda item: _port_sort_key(item[0]),
        )

        for panel in self.sensor_panels.values():
            panel.set_ports(discovered)

        # Apply the ports remembered from the previous run exactly once.
        saved_ports = self._saved_ports
        self._saved_ports = {}
        available = {device for device, _description in discovered}
        for key, port in saved_ports.items():
            panel = self.sensor_panels.get(key)
            if (
                panel is not None
                and panel.connected_port is None
                and port in available
            ):
                panel.select_port(port)

        # Spread distinct default ports over panels whose selection is empty
        # or duplicates an earlier panel's, without touching deliberate
        # (user-made or remembered) choices.
        panels = self.active_panels
        if len(discovered) >= len(panels):
            claimed: set[str] = set()
            for panel in panels:
                if panel.connected_port is not None:
                    claimed.add(panel.connected_port)
            for panel in panels:
                if panel.connected_port is not None:
                    continue
                current = panel.port_combo.currentData()
                if isinstance(current, str) and current not in claimed:
                    claimed.add(current)
                    continue
                for device, _description in discovered:
                    if device not in claimed:
                        panel.select_port(device)
                        claimed.add(device)
                        break

    def _port_available(self, port: str, requester: SensorPanel) -> bool:
        return all(
            panel.active_port != port
            for panel in self.sensor_panels.values()
            if panel is not requester
        )

    # ------------------------------------------------------------------- sync
    def _sync_group(self) -> tuple[SensorPanel, list[SensorPanel]] | None:
        """Return the single Master and its Slaves when the wiring is valid."""
        panels = self.active_panels
        if len(panels) < 2:
            return None
        if any(panel.connected_port is None for panel in panels):
            return None
        masters = [panel for panel in panels if panel.sync_role == "master"]
        slaves = [panel for panel in panels if panel.sync_role == "slave"]
        if len(masters) == 1 and len(slaves) == len(panels) - 1:
            return masters[0], slaves
        return None

    def _sync_pair(self) -> tuple[SensorPanel, SensorPanel] | None:
        """Two-sensor view of :meth:`_sync_group`."""
        group = self._sync_group()
        if group is None or len(self.active_panels) != 2:
            return None
        master, slaves = group
        return master, slaves[0]

    def _update_sync_controls(self, *_args) -> None:
        group = self._sync_group()
        self.sync_button.setEnabled(group is not None)
        self.sync_status_label.setProperty("complete", False)
        panels = self.active_panels

        if group is not None:
            master, slaves = group
            slave_text = (
                ", ".join(panel.sensor_key for panel in slaves)
                if len(slaves) <= 3
                else f"{len(slaves)} sensors"
            )
            # The wiring rule chip beside this label already spells out which
            # SCB connector each role maps to, so keep this short.
            self.sync_status_label.setText(
                f"Ready: {master.sensor_key} MASTER → {slave_text} SLAVE"
            )
        elif any(panel.connected_port is None for panel in panels):
            missing = [
                panel.sensor_key
                for panel in panels
                if panel.connected_port is None
            ]
            self.sync_status_label.setText(
                f"Connect sensor {', '.join(missing)}."
            )
        else:
            masters = [panel for panel in panels if panel.sync_role == "master"]
            if len(masters) > 1:
                names = ", ".join(panel.sensor_key for panel in masters)
                self.sync_status_label.setText(
                    f"INVALID: sensors {names} all report MASTER. Follow the "
                    "wiring: every SCB Sync In sensor must be Slave."
                )
            elif not masters:
                self.sync_status_label.setText(
                    "No sensor reports MASTER. Follow the wiring: the SCB "
                    "Sync Out sensor must be Master."
                )
            else:
                self.sync_status_label.setText(
                    "Roles are determined by the cable: SCB Sync Out = Master; "
                    "SCB Sync In = Slave."
                )
        self.sync_status_label.style().unpolish(self.sync_status_label)
        self.sync_status_label.style().polish(self.sync_status_label)
        self._update_connection_summary()

    def sync_sensors(self) -> None:
        group = self._sync_group()
        if group is None:
            return
        master, _slaves = group
        if master.send_sync_trigger():
            self.timestamp_sync_monitor.clear()
            self._render_timestamp_sync_status(
                self.timestamp_sync_monitor.status()
            )
            self.sync_button.setEnabled(False)
            self.sync_status_label.setText(
                f"Sync command sent to {master.sensor_name}; waiting for "
                "confirmation…"
            )
            QTimer.singleShot(1500, self._sync_confirmation_timeout)

    def _sync_confirmation_timeout(self) -> None:
        if "waiting for confirmation" in self.sync_status_label.text():
            self.sync_status_label.setText(
                "No sync confirmation received. Check the physical Sync_Out → "
                "Sync_In cable."
            )
            self.sync_status_label.setProperty("complete", False)
            self.sync_status_label.style().unpolish(self.sync_status_label)
            self.sync_status_label.style().polish(self.sync_status_label)
        self.sync_button.setEnabled(self._sync_group() is not None)

    def _on_sync_event(self, event: str) -> None:
        if event == "complete":
            self.sync_status_label.setText("Sensors sync complete.")
            self.sync_status_label.setProperty("complete", True)
        elif event == "needs_clock":
            self.sync_status_label.setText(
                "A Slave needs an input clock. Check the Sync_Out → Sync_In "
                "cable."
            )
            self.sync_status_label.setProperty("complete", False)
        self.sync_status_label.style().unpolish(self.sync_status_label)
        self.sync_status_label.style().polish(self.sync_status_label)
        self.sync_button.setEnabled(self._sync_group() is not None)

    def showEvent(self, event) -> None:
        super().showEvent(event)
        if not getattr(self, "_did_initial_show", False):
            self._did_initial_show = True
            # Connecting sensors is the first thing to do, so surface the
            # setup window once the main window is on screen.
            QTimer.singleShot(0, self.show_sensor_setup)

    def _save_settings(self) -> None:
        settings_store.save_settings(self._collect_settings())

    def closeEvent(self, event: QCloseEvent) -> None:
        self._save_settings()
        self.csv_recorder.stop()
        self.sensor_window.close()
        self.terminal_window.close()
        if hasattr(self, "parse_dialog"):
            self.parse_dialog.close()
        for panel in self.sensor_panels.values():
            panel.shutdown()
        event.accept()
