"""Small regression tests for the connection-only milestone."""

from __future__ import annotations

import csv
import math
import os
import sys
from pathlib import Path
from unittest.mock import patch

import numpy as np
import pytest
from PyQt5.QtCore import Qt

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

import settings_store


@pytest.fixture(autouse=True)
def _isolated_settings(tmp_path, monkeypatch):
    """Keep every test's persisted GUI settings in its own temporary file."""
    monkeypatch.setattr(
        settings_store,
        "settings_path",
        lambda: tmp_path / "qtfm_settings.json",
    )

from app import create_app
from asd_panel import (
    DIFFERENCE_KEY,
    ASDSpectrumPanel,
    calculate_asd,
    nearest_spectrum_value,
)
from csv_recorder import CSVRecorder
from main_window import MainWindow, SyncGuideDialog, _port_sort_key
from sensors import SENSOR_KEYS
from serial_connection import (
    QTFMMeasurement,
    StreamQualityMonitor,
    parse_qtfm_measurement,
    parse_qtfm_status_line,
)
from theme import GOLDEN_ROD
from time_series_panel import ExactTimestampMatcher, SensorTimebase, TimeSeriesPanel
from timestamp_sync_monitor import TimestampSyncMonitor


class FakePort:
    def __init__(self, device: str, description: str) -> None:
        self.device = device
        self.description = description


class FakeWorker:
    def __init__(self) -> None:
        self.commands: list[bytes] = []

    def send_command(self, command: bytes) -> bool:
        self.commands.append(command)
        return True


def test_com_ports_sort_naturally() -> None:
    ports = ["COM12", "COM2", "COM1"]
    assert sorted(ports, key=_port_sort_key) == ["COM1", "COM2", "COM12"]


def test_window_assigns_first_two_ports() -> None:
    app = create_app()
    fake_ports = [
        FakePort("COM12", "QTFM B"),
        FakePort("COM3", "QTFM A"),
    ]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        assert window.sensor_a.port_combo.currentData() == "COM3"
        assert window.sensor_b.port_combo.currentData() == "COM12"
        window.close()

    app.processEvents()


def test_commands_and_recording_are_visible_in_header() -> None:
    app = create_app()

    with patch("serial.tools.list_ports.comports", return_value=[]):
        window = MainWindow()

        assert window.command_bar.parentWidget().objectName() == "hero"
        assert window.recording_bar.parentWidget().objectName() == "hero"
        assert not window.command_bar.isHidden()
        assert not window.recording_bar.isHidden()
        # The setup bar sits full width directly above the plot tabs.
        assert window.gradiometer_setup_bar.parentWidget() is (
            window.plot_container
        )
        assert window.plot_tabs.parentWidget() is window.plot_container
        command_layout = window.command_bar.layout()
        assert command_layout.indexOf(window.filter_combo) < command_layout.indexOf(
            window.ascii_command_edit
        )
        assert command_layout.indexOf(
            window.apply_filter_button
        ) < command_layout.indexOf(window.command_target_combo)
        assert window.apply_filter_button.text() == "APPLY FILTERS"
        assert window.timestamp_alignment_label.parentWidget() is window.time_series
        assert window.sync_button.parentWidget() is window.time_series
        window.close()

    app.processEvents()


def test_scb_sync_guide_is_scrollable_and_explains_fixed_wiring_roles() -> None:
    app = create_app()
    guide = SyncGuideDialog()
    text = guide.guide_browser.toPlainText()

    assert guide.minimumWidth() >= 700
    assert guide.minimumHeight() >= 500
    assert "physical SCB wiring decides each sensor's role" in text
    assert "Sync Out" in text
    assert "MASTER" in text
    assert "Sync In" in text
    assert "SLAVE" in text
    assert guide.guide_browser.openExternalLinks()
    guide.close()
    app.processEvents()


def test_same_port_cannot_be_opened_twice() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_a._connected_port = "COM3"
        assert not window._port_available("COM3", window.sensor_b)
        window.close()

    app.processEvents()


def test_valid_qtfm_frames_are_accepted() -> None:
    monitor = StreamQualityMonitor(started_at=0.0)
    monitor.feed(
        b">100!45231.25_@1s20\r\n"
        b">101!45231.50_@2s20\r\n"
        b">102!45231.75_@3s20\r\n",
        now=0.5,
    )

    assert monitor.valid_frames == 3
    assert monitor.evaluate(now=0.5) == "valid"


def test_wrong_baud_garbage_is_rejected() -> None:
    monitor = StreamQualityMonitor(started_at=0.0)
    monitor.feed(b"\xff\x00\x81garbage\r\n" * 20, now=0.5)

    assert monitor.valid_frames == 0
    assert monitor.evaluate(now=3.0) == "garbled"


def test_silent_port_is_reported_after_grace_period() -> None:
    monitor = StreamQualityMonitor(started_at=0.0)

    assert monitor.evaluate(now=1.0) == "checking"
    assert monitor.evaluate(now=3.0) == "silent"


def test_readable_non_qtfm_lines_are_rejected() -> None:
    monitor = StreamQualityMonitor(started_at=0.0)
    monitor.feed(b"hello\r\nstatus only\r\nstill not data\r\n", now=0.5)

    assert monitor.evaluate(now=3.0) == "unrecognized"


def test_sensor_panel_shows_wrong_baud_warning() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        panel._connected_port = "COM3"
        panel._on_stream_health("garbled")

        assert panel.state_label.text() == "Data warning"
        assert "baud rate is probably wrong" in panel.detail_label.text()
        window.close()

    app.processEvents()


def test_official_qtfm_scalar_format_is_parsed() -> None:
    measurement = parse_qtfm_measurement(
        b"!50064.277_Y-24470.347=@046>232933340s109v024",
        host_time=12.5,
    )

    assert measurement == QTFMMeasurement(
        value_nt=50064.277,
        valid=True,
        timestamp_ms=232933340,
        counter=46,
        host_time=12.5,
    )


def test_invalid_scalar_marker_is_preserved() -> None:
    measurement = parse_qtfm_measurement(b"!50064.277*@007>1000", host_time=1.0)

    assert measurement is not None
    assert not measurement.valid


def test_original_printable_sensor_status_lines_are_forwarded() -> None:
    assert parse_qtfm_status_line(b"#Slave MS Mode:0\r\n") == (
        "#Slave MS Mode:0"
    )
    assert parse_qtfm_status_line(b"#ISR.Er1\r\n") == "#ISR.Er1"
    assert parse_qtfm_status_line(b"\xff\x00garbage\r\n") is None
    assert parse_qtfm_status_line(b"!50000.0_@1>1000\r\n") is None


def test_sensor_timebase_uses_even_device_timestamp_spacing() -> None:
    timebase = SensorTimebase()
    measurements = [
        QTFMMeasurement(50_000.0, True, 1000, None, 10.0),
        QTFMMeasurement(50_001.0, True, 1004, None, 10.0),
        QTFMMeasurement(50_002.0, True, 1008, None, 10.0),
    ]

    times = [timebase.map_time(item, app_origin=10.0) for item in measurements]

    assert times == [0.0, 0.004, 0.008]


def test_sensor_timebase_rejects_missing_timestamp() -> None:
    timebase = SensorTimebase()
    measurement = QTFMMeasurement(50_000.0, True, None, None, 10.0)

    assert timebase.map_time(measurement, app_origin=10.0) is None


def test_time_series_panel_plots_sensors_and_exact_timestamp_difference() -> None:
    app = create_app()
    panel = TimeSeriesPanel()
    panel.set_timestamp_state("A", "available")
    panel.set_timestamp_state("B", "available")
    panel.set_gradiometer_status(True, "Timing aligned.")

    for index in range(10):
        timestamp_ms = index * 10
        host_time = 100.0 + index * 0.01
        panel.add_measurement(
            "A",
            QTFMMeasurement(50_010.0 + index, True, timestamp_ms, None, host_time),
        )
        panel.add_measurement(
            "B",
            QTFMMeasurement(50_000.0 + index, True, timestamp_ms, None, host_time),
        )

    panel._refresh_curves()

    assert len(panel.curve_a.xData) == 10
    assert len(panel.curve_b.xData) == 10
    assert len(panel.curve_difference.xData) == 10
    assert panel.curve_a.yData[-1] == 50_019.0
    assert panel.curve_b.yData[-1] == 50_009.0
    assert panel.curve_difference.yData[-1] == 10.0
    assert panel.plot_a.getAxis("left").labelText == "Field (nT)"
    panel.close()
    app.processEvents()


def _feed_eight_sensors(panel: TimeSeriesPanel, samples: int = 10) -> None:
    for index in range(samples):
        timestamp_ms = index * 10
        host_time = 100.0 + index * 0.01
        for offset, key in enumerate("ABCDEFGH"):
            panel.add_measurement(
                key,
                QTFMMeasurement(
                    50_000.0 + 100.0 * offset + index,
                    True,
                    timestamp_ms,
                    None,
                    host_time,
                ),
            )


def test_time_series_keeps_three_plots_for_any_selected_pair() -> None:
    app = create_app()
    panel = TimeSeriesPanel()
    keys = tuple("ABCDEFGH")
    panel.set_sensor_layout(keys, "C", "H")
    for key in keys:
        panel.set_timestamp_state(key, "available")
    panel.set_gradiometer_status(True, "Timing aligned.")

    _feed_eight_sensors(panel)
    panel._refresh_curves()

    assert panel.active_sensors == keys
    assert panel.reference == "C" and panel.compare == "H"
    # Exactly three plots, holding the two chosen sensors and their difference.
    assert panel.plot_a.titleLabel.text == "Sensor C"
    assert panel.plot_b.titleLabel.text == "Sensor H"
    assert "Gradiometer C − H" in panel.plot_difference.titleLabel.text
    assert panel.curve_a.yData[-1] == 50_209.0
    assert panel.curve_b.yData[-1] == 50_709.0
    assert panel.curve_difference.yData[-1] == -500.0
    # The label reports the live value plus how many pairs have matched.
    assert panel.value_difference_label.text() == (
        "C − H: -500.000 nT  (10 pairs)"
    )
    assert panel.pair_count == 10
    panel.close()
    app.processEvents()


def test_time_series_pair_change_restarts_the_difference() -> None:
    app = create_app()
    panel = TimeSeriesPanel()
    keys = tuple("ABCDEFGH")
    panel.set_sensor_layout(keys, "A", "B")
    for key in keys:
        panel.set_timestamp_state(key, "available")
    panel.set_gradiometer_status(True, "Timing aligned.")

    _feed_eight_sensors(panel)
    panel._refresh_curves()
    assert panel.curve_difference.yData[-1] == -100.0

    panel.set_sensor_layout(keys, "A", "D")
    # The buffered difference belonged to A − B, so it is discarded.
    assert panel.curve_difference.yData is None
    # Sensor history survives the change, so the new plots are populated at once.
    panel._refresh_curves()
    assert panel.curve_b.yData[-1] == 50_309.0

    _feed_eight_sensors(panel)
    panel._refresh_curves()
    assert panel.curve_difference.yData[-1] == -300.0
    panel.close()
    app.processEvents()


def test_time_series_traces_are_all_golden_rod() -> None:
    app = create_app()
    panel = TimeSeriesPanel()
    panel.set_sensor_layout(tuple("ABCDEFGH"), "E", "G")

    colors = {
        curve.opts["pen"].color().name().upper()
        for curve in (panel.curve_a, panel.curve_b, panel.curve_difference)
    }
    assert colors == {GOLDEN_ROD.upper()}
    panel.close()
    app.processEvents()


def test_exact_timestamp_matcher_does_not_pair_nearby_packets() -> None:
    matcher = ExactTimestampMatcher()

    assert matcher.add("A", 1000, 50_010.0, 0.0) == []
    assert matcher.add("B", 1001, 50_000.0, 0.001) == []
    assert matcher.add("B", 1000, 50_001.0, 0.0) == [("B", 0.0, 9.0)]


def test_exact_timestamp_matcher_subtracts_every_sensor_once() -> None:
    matcher = ExactTimestampMatcher("A", ("A", "B", "C"))

    assert matcher.add("B", 500, 50_001.0, 0.0) == []
    assert matcher.add("A", 500, 50_010.0, 0.0) == [("B", 0.0, 9.0)]
    # C arrives late and still pairs with the buffered reference sample.
    assert matcher.add("C", 500, 50_004.0, 0.0) == [("C", 0.0, 6.0)]
    # No duplicate pair is produced for a timestamp already matched.
    assert matcher.add("A", 504, 50_011.0, 0.004) == []


def test_asd_defaults_to_1000_points_linear_x_log_y_and_finds_sine_peak() -> None:
    samples = [
        (index / 100.0, math.sin(2.0 * math.pi * 10.0 * index / 100.0))
        for index in range(100)
    ]
    result = calculate_asd(samples, 100)

    assert result is not None
    frequencies, values = result
    peak_frequency = frequencies[values.argmax()]
    assert abs(peak_frequency - 10.0) < 1e-9

    app = create_app()
    panel = ASDSpectrumPanel()
    assert panel.point_count_spin.value() == 1000
    assert not panel.log_x_checkbox.isChecked()
    assert not panel.plot.ctrl.logXCheck.isChecked()
    assert panel.plot.ctrl.logYCheck.isChecked()
    panel.log_x_checkbox.setChecked(True)
    assert panel.plot.ctrl.logXCheck.isChecked()
    assert panel.plot.ctrl.logYCheck.isChecked()
    assert panel.plot.legend is not None
    panel.close()
    app.processEvents()


def test_asd_rejects_irregular_timestamp_spacing() -> None:
    samples = [(index / 100.0, float(index)) for index in range(100)]
    samples[-1] = (2.0, samples[-1][1])

    assert calculate_asd(samples, 100) is None


def test_asd_cursor_reads_nearest_frequency_bin_for_all_traces() -> None:
    frequencies = np.asarray([5.0, 10.0, 15.0])
    assert nearest_spectrum_value(
        frequencies,
        np.asarray([1.0, 2.0, 3.0]),
        11.0,
    ) == (10.0, 2.0)

    app = create_app()
    panel = ASDSpectrumPanel()
    panel._display_spectra["A"] = (frequencies, np.asarray([1.0, 2.0, 3.0]))
    panel._display_spectra["B"] = (frequencies, np.asarray([4.0, 5.0, 6.0]))
    panel._display_spectra[DIFFERENCE_KEY] = (
        frequencies,
        np.asarray([0.1, 0.2, 0.3]),
    )
    text = panel._cursor_text(11.0, 0.25)

    assert "10 Hz" in text
    assert "Y 0.25 nT/√Hz" in text
    assert "A 2" in text
    assert "B 5" in text
    assert "A − B 0.2" in text
    panel.close()
    app.processEvents()


def test_asd_difference_stream_is_not_confused_with_a_sensor() -> None:
    """The difference key must never collide with a sensor key ("A".."H")."""
    app = create_app()
    panel = ASDSpectrumPanel()
    keys = tuple("ABCDEFGH")
    panel.set_sensor_layout(keys, "C", "H")
    panel.set_gradiometer_status(True, "Timing aligned.")

    assert DIFFERENCE_KEY not in SENSOR_KEYS
    for index, key in enumerate(keys):
        panel.add_measurement(
            key,
            QTFMMeasurement(50_000.0 + index, True, 1000, None, 10.0),
        )
    panel.add_gradiometer_sample("H", 0.0, -5.0)

    assert list(panel._streams[DIFFERENCE_KEY]) == [(0.0, -5.0)]
    assert list(panel._streams["D"]) == [(0.0, 50_003.0)]
    panel.close()
    app.processEvents()


def test_csv_recorder_writes_one_exact_pair_file_with_serial_headers(
    tmp_path: Path,
) -> None:
    recorder = CSVRecorder()
    gradiometer_path = recorder.start(
        tmp_path / "survey.csv",
        {"A": "A-123", "B": "B-456"},
        ("A", "B"),
    )
    recorder.set_gradiometer_enabled(True)

    recorder.add_measurement(
        "A",
        QTFMMeasurement(50_010.0, True, 1000, 1, 10.0),
    )
    recorder.add_measurement(
        "B",
        QTFMMeasurement(50_000.0, True, 1000, 2, 10.0),
    )
    stopped_path = recorder.stop()

    assert gradiometer_path.name == "survey_gradiometer.csv"
    assert stopped_path == gradiometer_path
    assert not (tmp_path / "survey_raw.csv").exists()

    rows = list(csv.reader(gradiometer_path.open(encoding="utf-8")))
    # Field columns only: differences are recomputed by the parser tool.
    assert rows[0] == [
        "pc_time_utc",
        "timestamp_ms",
        "sensor_a_nt [A-123]",
        "sensor_b_nt [B-456]",
    ]
    assert rows[1][1:] == [
        "1000",
        "50010.000000000",
        "50000.000000000",
    ]


def test_csv_recorder_writes_one_row_per_shared_timestamp_for_four_sensors(
    tmp_path: Path,
) -> None:
    recorder = CSVRecorder()
    keys = ("A", "B", "C", "D")
    path = recorder.start(
        tmp_path / "array.csv",
        {key: f"SN-{key}" for key in keys},
        keys,
    )
    recorder.set_gradiometer_enabled(True)

    for offset, key in enumerate(keys):
        recorder.add_measurement(
            key,
            QTFMMeasurement(50_000.0 + offset, True, 2000, offset, 10.0),
        )
    # A timestamp that only three sensors share must not produce a row.
    for offset, key in enumerate(keys[:3]):
        recorder.add_measurement(
            key,
            QTFMMeasurement(50_000.0 + offset, True, 2004, offset, 10.004),
        )
    recorder.stop()

    rows = list(csv.reader(path.open(encoding="utf-8")))
    assert rows[0] == [
        "pc_time_utc",
        "timestamp_ms",
        "sensor_a_nt [SN-A]",
        "sensor_b_nt [SN-B]",
        "sensor_c_nt [SN-C]",
        "sensor_d_nt [SN-D]",
    ]
    assert len(rows) == 2
    assert rows[1][1] == "2000"
    assert rows[1][2:] == [
        "50000.000000000",
        "50001.000000000",
        "50002.000000000",
        "50003.000000000",
    ]
    assert recorder.sensors == keys


def test_csv_recorder_difference_columns_are_opt_in(tmp_path: Path) -> None:
    recorder = CSVRecorder()
    keys = ("A", "B", "C")
    path = recorder.start(
        tmp_path / "with_diffs.csv",
        {key: f"SN-{key}" for key in keys},
        keys,
        reference="B",
    )
    recorder.set_gradiometer_enabled(True)
    for offset, key in enumerate(keys):
        recorder.add_measurement(
            key,
            QTFMMeasurement(50_000.0 + offset, True, 3000, offset, 10.0),
        )
    recorder.stop()

    assert recorder.reference == "B"
    assert recorder.difference_names() == ("B − A", "B − C")
    rows = list(csv.reader(path.open(encoding="utf-8")))
    assert rows[0] == [
        "pc_time_utc",
        "timestamp_ms",
        "sensor_a_nt [SN-A]",
        "sensor_b_nt [SN-B]",
        "sensor_c_nt [SN-C]",
        "b_minus_a_nt",
        "b_minus_c_nt",
    ]
    assert rows[1][-2:] == ["1.000000000", "-1.000000000"]


def test_record_differences_checkbox_controls_csv_columns(
    tmp_path: Path,
) -> None:
    app = create_app()
    fake_ports = [FakePort(f"COM{index + 3}", "QTFM") for index in range(2)]

    with (
        patch("serial.tools.list_ports.comports", return_value=fake_ports),
        patch(
            "main_window.QFileDialog.getSaveFileName",
            side_effect=[
                (str(tmp_path / "small.csv"), "CSV files (*.csv)"),
                (str(tmp_path / "full.csv"), "CSV files (*.csv)"),
            ],
        ),
    ):
        window = MainWindow()
        assert not window.record_differences_checkbox.isChecked()

        window.start_csv_recording()
        assert window.csv_recorder.reference is None
        assert not window.record_differences_checkbox.isEnabled()
        assert "fields only" in window.recording_status_label.text()
        window.stop_csv_recording()
        assert window.record_differences_checkbox.isEnabled()

        window.record_differences_checkbox.setChecked(True)
        window.start_csv_recording()
        assert window.csv_recorder.reference == window.reference_key
        assert "fields + A − B" in window.recording_status_label.text()
        window.stop_csv_recording()
        window.close()

    app.processEvents()

    small = list(csv.reader((tmp_path / "small_gradiometer.csv").open()))
    full = list(csv.reader((tmp_path / "full_gradiometer.csv").open()))
    assert small[0][-1].startswith("sensor_b_nt")
    assert full[0][-1] == "a_minus_b_nt"


def _record_four_sensor_csv(tmp_path: Path, samples: int = 3) -> Path:
    recorder = CSVRecorder()
    keys = ("A", "B", "C", "D")
    path = recorder.start(
        tmp_path / "survey.csv",
        {key: f"SN-{key}" for key in keys},
        keys,
    )
    recorder.set_gradiometer_enabled(True)
    for step in range(samples):
        for offset, key in enumerate(keys):
            recorder.add_measurement(
                key,
                QTFMMeasurement(
                    50_000.0 + 10.0 * offset + step,
                    True,
                    1000 + step * 4,
                    step,
                    10.0 + step * 0.004,
                ),
            )
    recorder.stop()
    return path


def test_parse_recording_dialog_computes_pairs_inside_the_app(
    tmp_path: Path,
) -> None:
    app = create_app()

    with patch("serial.tools.list_ports.comports", return_value=[]):
        window = MainWindow()
        recording = _record_four_sensor_csv(tmp_path)

        window.show_parse_dialog()
        dialog = window.parse_dialog
        assert dialog.isVisible()

        assert dialog.load_recording(str(recording))
        assert "Sensor A (S/N SN-A)" in dialog.sensors_label.text()
        # Loading pre-fills the first pair and a default output path.
        assert dialog.pairs_edit.text() == "A-B"
        assert dialog.output_edit.text().endswith("_differences.csv")

        # ADD PAIR appends the combo selection without duplicating pairs.
        dialog.first_combo.setCurrentIndex(dialog.first_combo.findData("C"))
        dialog.second_combo.setCurrentIndex(dialog.second_combo.findData("A"))
        dialog._add_pair()
        dialog._add_pair()
        assert dialog.pairs_edit.text() == "A-B C-A"

        output = tmp_path / "in_app.csv"
        dialog.output_edit.setText(str(output))
        dialog._parse()
        assert dialog._worker is not None
        assert dialog._worker.wait(10_000)
        for _ in range(20):
            app.processEvents()
            if dialog._worker is None:
                break

        assert "Wrote 3 rows" in dialog.status_label.text()
        rows = list(csv.reader(output.open(encoding="utf-8")))
        assert rows[0][-2:] == ["a_minus_b_nt", "c_minus_a_nt"]
        assert rows[1][-2:] == ["-10.000000000", "20.000000000"]
        window.close()

    app.processEvents()


def test_parse_recording_dialog_reports_bad_input(tmp_path: Path) -> None:
    app = create_app()

    with patch("serial.tools.list_ports.comports", return_value=[]):
        window = MainWindow()
        window.show_parse_dialog()
        dialog = window.parse_dialog

        bad = tmp_path / "not_a_recording.csv"
        bad.write_text("just,some,columns\n1,2,3\n", encoding="utf-8")
        assert not dialog.load_recording(str(bad))
        assert "no sensor field columns" in dialog.status_label.text()
        assert not dialog.parse_button.isEnabled()

        recording = _record_four_sensor_csv(tmp_path)
        assert dialog.load_recording(str(recording))
        dialog.pairs_edit.setText("A-Z")
        dialog._parse()
        assert "Invalid pair" not in dialog.status_label.text()
        # A-Z parses but Z is absent; the worker reports the parser's error.
        assert dialog._worker is not None
        assert dialog._worker.wait(10_000)
        for _ in range(20):
            app.processEvents()
            if dialog._worker is None:
                break
        assert "not present" in dialog.status_label.text()
        window.close()

    app.processEvents()


def test_gradiometer_parser_computes_any_selected_pairs(
    tmp_path: Path,
) -> None:
    from tools.gradiometer_parser import (
        compute_differences,
        list_sensors,
        parse_pair,
    )

    recording = _record_four_sensor_csv(tmp_path)

    assert list_sensors(recording) == [
        ("A", "SN-A"),
        ("B", "SN-B"),
        ("C", "SN-C"),
        ("D", "SN-D"),
    ]
    assert parse_pair("c-a") == ("C", "A")
    assert parse_pair("A−D") == ("A", "D")

    output = tmp_path / "differences.csv"
    written, skipped = compute_differences(
        recording,
        [("C", "A"), ("B", "D")],
        output,
    )
    assert (written, skipped) == (3, 0)

    rows = list(csv.reader(output.open(encoding="utf-8")))
    assert rows[0] == [
        "pc_time_utc",
        "timestamp_ms",
        "c_minus_a_nt",
        "b_minus_d_nt",
    ]
    assert rows[1][1] == "1000"
    assert rows[1][2] == "20.000000000"
    assert rows[1][3] == "-20.000000000"


def test_gradiometer_parser_can_include_fields_and_rejects_unknown_sensors(
    tmp_path: Path,
) -> None:
    import pytest

    from tools.gradiometer_parser import compute_differences

    recording = _record_four_sensor_csv(tmp_path)
    output = tmp_path / "with_fields.csv"
    written, _skipped = compute_differences(
        recording,
        [("A", "B")],
        output,
        include_fields=True,
    )
    assert written == 3

    rows = list(csv.reader(output.open(encoding="utf-8")))
    assert rows[0] == [
        "pc_time_utc",
        "timestamp_ms",
        "sensor_a_nt [SN-A]",
        "sensor_b_nt [SN-B]",
        "a_minus_b_nt",
    ]
    assert rows[1][2:] == [
        "50000.000000000",
        "50010.000000000",
        "-10.000000000",
    ]

    with pytest.raises(ValueError, match="Sensor.*H.*not present"):
        compute_differences(recording, [("A", "H")], tmp_path / "bad.csv")


def test_header_shows_recording_led_and_elapsed_time(tmp_path: Path) -> None:
    app = create_app()
    selected_path = tmp_path / "timed_recording.csv"

    with (
        patch("serial.tools.list_ports.comports", return_value=[]),
        patch(
            "main_window.QFileDialog.getSaveFileName",
            return_value=(str(selected_path), "CSV files (*.csv)"),
        ),
    ):
        window = MainWindow()
        assert not window.recording_led.property("active")
        assert window.recording_timer_label.text() == "Not recording"

        with patch("main_window.time.monotonic", return_value=100.0):
            window.start_csv_recording()

        assert window.recording_led.property("active")
        assert window.recording_timer_label.text() == "REC  00:00:00"

        window._update_recording_elapsed(now=3761.9)
        assert window.recording_timer_label.text() == "REC  01:01:01"

        window.stop_csv_recording()
        assert not window.recording_led.property("active")
        assert window.recording_timer_label.text() == "Not recording"
        window.close()

    app.processEvents()


def test_asd_averages_only_new_windows_and_can_restart() -> None:
    app = create_app()
    panel = ASDSpectrumPanel()
    panel.point_count_spin.setValue(8)
    panel._streams["A"].extend(
        (index * 0.01, math.sin(index))
        for index in range(8)
    )

    panel.refresh_spectra()
    assert panel._average_counts["A"] == 1
    first_average = panel.curve_a.yData.copy()

    panel.refresh_spectra()
    assert panel._average_counts["A"] == 1

    panel._streams["A"].append((0.08, 2.0))
    panel.refresh_spectra()
    assert panel._average_counts["A"] == 2
    assert panel.curve_a.yData is not None

    panel.restart_averaging()
    assert panel._average_counts["A"] == 1
    assert panel.curve_a.yData is not None
    assert first_average.shape == panel.curve_a.yData.shape
    panel.close()
    app.processEvents()


def test_time_series_panel_does_not_plot_without_timestamp() -> None:
    app = create_app()
    panel = TimeSeriesPanel()
    panel.set_timestamp_state("A", "missing")
    panel.add_measurement(
        "A",
        QTFMMeasurement(50_000.0, True, None, None, 10.0),
    )
    panel._refresh_curves()

    assert panel.curve_a.xData is None
    assert "CANNOT PLOT" in panel.plot_a.titleLabel.text
    assert panel.value_a_label.text() == "A: timestamp required"
    assert "SYNC SENSORS" in panel.plot_difference.titleLabel.text
    panel.close()
    app.processEvents()


def test_sensor_panel_reports_timestamp_availability() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        for index in range(5):
            panel._on_measurement(
                QTFMMeasurement(50_000.0, True, None, None, float(index))
            )

        assert panel.timestamp_label.text() == (
            "MS timestamp unavailable — plotting disabled"
        )
        assert panel.enable_timestamp_button.isVisibleTo(panel)

        panel._on_measurement(
            QTFMMeasurement(50_000.0, True, 1000, None, 6.0)
        )
        assert panel.timestamp_label.text() == "MS timestamp available"
        assert panel.enable_timestamp_button.isHidden()
        window.close()

    app.processEvents()


def test_sensor_panel_routes_master_slave_query_and_sync_commands() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        worker = FakeWorker()
        panel._worker = worker
        panel._connected_port = "COM3"

        panel.set_master()
        panel._on_status_line("#Master MS Mode:1")
        assert panel.sync_role == "verifying_master"
        panel._on_measurement(
            QTFMMeasurement(50_000.0, True, 1000, None, 1.0)
        )
        assert panel.sync_role == "master"

        panel.set_slave()
        panel.query_sync_role()
        panel._on_status_line("#Slave MS Mode:0")
        assert panel.sync_role == "verifying_slave"
        panel._on_measurement(
            QTFMMeasurement(50_000.0, True, 1004, None, 1.004)
        )
        assert panel.sync_role == "slave"

        panel._on_status_line("#Master MS Mode:1")
        assert panel.send_sync_trigger()

        assert worker.commands == [b" ", b"!", b'"', b"T"]
        assert panel.sync_role == "master"
        window.close()

    app.processEvents()


def test_slave_confirmation_timeout_does_not_force_protective_master() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        worker = FakeWorker()
        panel._worker = worker
        panel._connected_port = "COM3"

        panel.set_slave()
        panel._on_role_command_timeout()
        assert panel.sync_role == "unknown"
        assert panel.master_button.isEnabled()
        assert "not yet been verified" in panel.app_status_label.text()
        assert "ISR.Er1" in panel.app_status_label.text()
        assert panel.sensor_status_label.text() == "No sensor messages"
        assert worker.commands == [b"!"]

        panel.reboot_sensor()
        assert not panel.master_button.isEnabled()
        panel._finish_reboot_recovery()
        assert panel.master_button.isEnabled()
        assert panel.reboot_button.isEnabled()
        assert worker.commands[:2] == [b"!", b"e"]
        window.close()

    app.processEvents()


def test_sync_button_requires_one_master_and_one_slave() -> None:
    app = create_app()
    fake_ports = [
        FakePort("COM3", "QTFM A"),
        FakePort("COM4", "QTFM B"),
    ]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_a._connected_port = "COM3"
        window.sensor_b._connected_port = "COM4"
        window.sensor_a._set_sync_role("master")
        window.sensor_b._set_sync_role("master")
        assert not window.sync_button.isEnabled()

        window.sensor_b._set_sync_role("slave")
        assert window.sync_button.isEnabled()
        assert window.sync_button.objectName() == "syncButtonProminent"
        # A compressed toolbar row must never shrink the button below the
        # height its styled label needs, or the text clips.
        assert (
            window.sync_button.minimumHeight()
            >= window.sync_button.sizeHint().height()
        )
        assert window.sync_help_button.text() == "USER GUIDE"
        assert window.sync_help_button.toolTip() == (
            "User Guide: Sync Two QTFM Sensors with SCB"
        )
        assert window.sensor_a.master_button.text() == "SYNC OUT → MASTER"
        assert window.sensor_a.slave_button.text() == "SYNC IN → SLAVE"
        assert "Sync Out → MASTER" in window.wiring_rule_label.text()
        assert "Sync In → SLAVE" in window.wiring_rule_label.text()
        assert window._sync_pair() == (window.sensor_a, window.sensor_b)

        window.sensor_a._set_sync_role("slave")
        assert not window.sync_button.isEnabled()
        assert "No sensor reports MASTER" in window.sync_status_label.text()
        assert "Sync Out sensor must be Master" in window.sync_status_label.text()
        window.close()

    app.processEvents()


def test_sync_requires_exactly_one_master_across_four_sensors() -> None:
    app = create_app()
    fake_ports = [FakePort(f"COM{index + 3}", "QTFM") for index in range(4)]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_count_combo.setCurrentIndex(
            window.sensor_count_combo.findData(4)
        )
        assert window.active_keys == ("A", "B", "C", "D")

        for index, key in enumerate(window.active_keys):
            panel = window.sensor_panels[key]
            panel._connected_port = f"COM{index + 3}"
            panel._set_sync_role("slave")
        assert not window.sync_button.isEnabled()
        assert "No sensor reports MASTER" in window.sync_status_label.text()

        window.sensor_panels["A"]._set_sync_role("master")
        assert window.sync_button.isEnabled()
        master, slaves = window._sync_group()
        assert master is window.sensor_panels["A"]
        assert [panel.sensor_key for panel in slaves] == ["B", "C", "D"]
        # The two-sensor helper is only meaningful for a two-sensor setup.
        assert window._sync_pair() is None

        window.sensor_panels["C"]._set_sync_role("master")
        assert not window.sync_button.isEnabled()
        assert "all report MASTER" in window.sync_status_label.text()
        window.close()

    app.processEvents()


def test_sensor_count_change_reconfigures_every_component() -> None:
    app = create_app()
    fake_ports = [FakePort(f"COM{index + 3}", "QTFM") for index in range(3)]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_count_combo.setCurrentIndex(
            window.sensor_count_combo.findData(3)
        )

        setup = window.sensor_window
        assert window.sensor_panels["C"].isVisibleTo(setup)
        assert not window.sensor_panels["D"].isVisibleTo(setup)
        assert window.time_series.active_sensors == ("A", "B", "C")
        assert window.timestamp_sync_monitor.sensors == ("A", "B", "C")
        assert window.sensor_panels["C"].port_combo.currentData() == "COM5"
        # The cards live in their own window so the plot area never shrinks.
        assert window.sensor_panels["C"].window() is setup
        assert setup is not window

        window.reference_combo.setCurrentIndex(
            window.reference_combo.findData("B")
        )
        assert window.reference_key == "B"
        assert window.time_series.reference == "B"
        # B was the compare sensor, so a different one takes that slot.
        assert window.compare_key != "B"
        assert window.time_series.compare == window.compare_key

        window.compare_combo.setCurrentIndex(
            window.compare_combo.findData("C")
        )
        assert window.time_series.compare == "C"
        assert window.asd_spectrum.compare == "C"
        assert window.time_series.value_difference_label.text().startswith(
            "B − C"
        )
        window.close()

    app.processEvents()


def test_sensor_mode_badge_tracks_reported_role() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a

        panel._on_status_line("#MS Mode:1 (Master)")
        panel._on_measurement(
            QTFMMeasurement(50_000.0, True, 1000, None, 1.0)
        )
        assert panel.role_badge.text() == "MASTER / OUT"
        assert panel.role_badge.objectName() == "roleBadgeMaster"

        panel._on_status_line("#MS Mode:0 (Slave)")
        assert panel.role_badge.text() == "SLAVE / IN"
        assert panel.role_badge.objectName() == "roleBadgeSlave"
        window.close()

    app.processEvents()


def test_slave_mode_requires_response_and_subsequent_scalar_data() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        panel._worker = FakeWorker()
        panel._connected_port = "COM3"

        panel.set_slave()
        assert panel._worker.commands == [b"!"]
        panel._on_status_line("#Slave MS Mode:0")

        assert panel.sync_role == "verifying_slave"
        assert panel.sensor_status_label.text() == "#Slave MS Mode:0"
        assert "waiting for the next scalar packet" in panel.app_status_label.text()

        panel._on_measurement(
            QTFMMeasurement(50_000.0, True, 1000, None, 1.0)
        )

        assert panel.sync_role == "slave"
        assert "scalar data continued" in panel.app_status_label.text()
        assert panel.sensor_status_label.text() == "#Slave MS Mode:0"
        window.close()

    app.processEvents()


def test_isr_er1_marks_slave_failed_and_preserves_sensor_message() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        panel._worker = FakeWorker()
        panel._connected_port = "COM3"

        panel.set_slave()
        panel._on_status_line("#ISR.Er1")

        assert panel.sync_role == "setting_master"
        assert panel.sensor_status_label.text() == "#ISR.Er1"
        assert "sync cable" in panel.app_status_label.text()
        assert "Protective Master command queued" in panel.app_status_label.text()
        assert panel._worker.commands == [b"!", b" "]
        assert not panel.master_button.isEnabled()
        assert panel.reboot_button.isEnabled()
        window.close()

    app.processEvents()


def test_five_second_slave_scalar_gap_triggers_protective_master() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        panel._worker = FakeWorker()
        panel._connected_port = "COM3"

        with patch("sensor_panel.time.monotonic", return_value=100.0):
            panel.set_slave()
            panel._on_status_line("#Slave MS Mode:0")

        panel._check_slave_data_continuity(now=104.99)
        assert panel._worker.commands == [b"!"]
        assert panel.sync_role == "verifying_slave"

        panel._check_slave_data_continuity(now=105.0)
        assert panel._worker.commands == [b"!", b" "]
        assert panel.sync_role == "setting_master"
        assert "five seconds" in panel.app_status_label.text()
        window.close()

    app.processEvents()


def test_wrong_mode_response_does_not_trigger_protective_master() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        panel._worker = FakeWorker()
        panel._connected_port = "COM3"

        panel.set_slave()
        panel._on_status_line("#Master MS Mode:1")

        assert panel._worker.commands == [b"!"]
        assert panel.sync_role == "master"
        assert "Mode change failed" in panel.app_status_label.text()
        window.close()

    app.processEvents()


def test_old_isr_er1_does_not_force_master_without_active_slave_mode() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        panel._worker = FakeWorker()
        panel._connected_port = "COM3"
        panel._set_sync_role("master")

        panel._on_status_line("#ISR.Er1")

        assert panel.sync_role == "master"
        assert panel._worker.commands == []
        assert panel.sensor_status_label.text() == "#ISR.Er1"
        assert "no protective mode change" in panel.app_status_label.text()
        window.close()

    app.processEvents()


def test_sensor_status_bar_and_data_rate_follow_firmware_messages() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        panel._on_status_line("#Data rate: 4.0000")

        assert panel.sensor_status_label.text() == "#Data rate: 4.0000"
        assert panel.data_rate_ms == 4
        assert panel.current_rate_label.text() == "Current: 4 ms"

        panel._on_status_line("#Serial Number: T2M0-1234")
        assert panel.serial_number == "T2M0-1234"
        assert panel.serial_number_label.text() == "S/N: T2M0-1234"
        window.close()

    app.processEvents()


def test_data_rate_selector_cycles_until_acknowledged_target() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        panel = window.sensor_a
        worker = FakeWorker()
        panel._worker = worker
        panel._connected_port = "COM3"
        panel._data_rate_ms = 4
        panel.data_rate_combo.setCurrentIndex(panel.data_rate_combo.findData(8))

        panel.set_selected_data_rate()
        panel._on_status_line("#Data rate: 8")
        app.processEvents()

        assert worker.commands == [b"}"]
        assert panel.data_rate_ms == 8
        window.close()

    app.processEvents()


def test_ascii_and_filter_commands_follow_selected_target() -> None:
    app = create_app()
    fake_ports = [
        FakePort("COM3", "QTFM A"),
        FakePort("COM4", "QTFM B"),
    ]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        worker_a = FakeWorker()
        worker_b = FakeWorker()
        window.sensor_a._worker = worker_a
        window.sensor_b._worker = worker_b
        window.sensor_a._connected_port = "COM3"
        window.sensor_b._connected_port = "COM4"
        window._update_command_controls()

        window.ascii_command_edit.setText("s")
        window.send_ascii_command()

        window.command_target_combo.setCurrentIndex(
            window.command_target_combo.findData("A")
        )
        window.apply_filter_command()

        window.command_target_combo.setCurrentIndex(
            window.command_target_combo.findData("B")
        )
        window.ascii_command_edit.setText("q")
        window.send_ascii_command()

        window.ascii_command_edit.setText("e")
        window.send_ascii_command()

        assert worker_a.commands == [b"s", b"{"]
        assert worker_b.commands == [b"s", b"q"]
        assert "independent REBOOT" in window.broadcast_status_label.text()
        assert window.ascii_command_edit.maximumWidth() == 300
        assert window.send_ascii_button.text() == "SEND"
        window.close()

    app.processEvents()


def test_command_target_enables_controls_for_one_connected_sensor() -> None:
    app = create_app()
    fake_ports = [
        FakePort("COM3", "QTFM A"),
        FakePort("COM4", "QTFM B"),
    ]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_a._connected_port = "COM3"
        window.sensor_b._connected_port = None
        window._update_command_controls()

        assert not window.send_ascii_button.isEnabled()
        assert "Connect every sensor" in window.broadcast_status_label.text()

        window.command_target_combo.setCurrentIndex(
            window.command_target_combo.findData("A")
        )
        assert window.send_ascii_button.isEnabled()
        assert window.broadcast_status_label.text() == "Target: Sensor A"

        window.command_target_combo.setCurrentIndex(
            window.command_target_combo.findData("B")
        )
        assert not window.send_ascii_button.isEnabled()
        assert window.broadcast_status_label.text() == "Connect Sensor B"
        window.close()

    app.processEvents()


def test_gradiometer_pauses_for_rate_mismatch_then_uses_exact_pairs() -> None:
    app = create_app()
    fake_ports = [
        FakePort("COM3", "QTFM A"),
        FakePort("COM4", "QTFM B"),
    ]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_a._set_timestamp_state("available")
        window.sensor_b._set_timestamp_state("available")
        window.sensor_a._on_status_line("#Data rate: 4")
        window.sensor_b._on_status_line("#Data rate: 8")

        for timestamp in (60, 64, 68, 72):
            host_time = 10.0 + (timestamp - 60) / 1000.0
            window._on_measurement(
                "A",
                QTFMMeasurement(50_010.0, True, timestamp, None, host_time),
            )
            window._on_measurement(
                "B",
                QTFMMeasurement(50_000.0, True, timestamp, None, host_time),
            )

        assert not window.time_series._gradiometer_enabled
        assert "Data rates differ" in window.time_series._gradiometer_reason

        window.sensor_b._on_status_line("#Data rate: 4")
        for timestamp in (76, 80):
            host_time = 10.0 + (timestamp - 60) / 1000.0
            window._on_measurement(
                "A",
                QTFMMeasurement(50_012.0, True, timestamp, None, host_time),
            )
            window._on_measurement(
                "B",
                QTFMMeasurement(50_003.0, True, timestamp, None, host_time),
            )

        window.time_series._refresh_curves()
        assert window.time_series._gradiometer_enabled
        assert window.time_series.curve_difference.yData[-1] == 9.0
        assert len(window.asd_spectrum._streams[DIFFERENCE_KEY]) == 2

        window.sensor_a._set_sync_role("slave")
        window.sensor_b._set_sync_role("slave")
        assert window.time_series._gradiometer_enabled

        window.sensor_a._set_sync_role("master")
        window.sensor_b._set_sync_role("master")
        assert not window.time_series._gradiometer_enabled
        assert (
            "all report Master" in window.time_series._gradiometer_reason
        )
        window.close()

    app.processEvents()


def test_settings_survive_close_and_reopen() -> None:
    app = create_app()
    fake_ports = [FakePort(f"COM{index + 3}", "QTFM") for index in range(4)]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_count_combo.setCurrentIndex(
            window.sensor_count_combo.findData(4)
        )
        window.reference_combo.setCurrentIndex(
            window.reference_combo.findData("C")
        )
        window.compare_combo.setCurrentIndex(
            window.compare_combo.findData("D")
        )
        window.record_differences_checkbox.setChecked(True)
        window.sensor_a.baud_combo.setCurrentText("230400")
        window.sensor_a.select_port("COM6")
        window.time_series.history_combo.setCurrentIndex(
            window.time_series.history_combo.findData(60)
        )
        window.asd_spectrum.point_count_spin.setValue(2000)
        window.asd_spectrum.log_x_checkbox.setChecked(True)
        window.terminal_window.filter_edit.setText("#")
        window.close()

        assert settings_store.settings_path().exists()

        reopened = MainWindow()
        assert reopened.sensor_count == 4
        assert (reopened.reference_key, reopened.compare_key) == ("C", "D")
        assert reopened.record_differences_checkbox.isChecked()
        assert reopened.sensor_a.baud_combo.currentText() == "230400"
        assert reopened.sensor_a.port_combo.currentData() == "COM6"
        assert reopened.time_series.history_combo.currentData() == 60
        assert reopened.asd_spectrum.point_count_spin.value() == 2000
        assert reopened.asd_spectrum.log_x_checkbox.isChecked()
        assert reopened.terminal_window.filter_edit.text() == "#"
        # The remembered port is applied once; a manual change plus a
        # refresh must not snap back to the remembered value.
        reopened.sensor_a.select_port("COM4")
        reopened.refresh_ports()
        assert reopened.sensor_a.port_combo.currentData() == "COM4"
        reopened.close()

    app.processEvents()


def test_corrupt_settings_file_is_ignored() -> None:
    app = create_app()
    settings_store.settings_path().write_text(
        '{"sensor_count": "not-a-number", "plot": 5, "sensors": []}',
        encoding="utf-8",
    )

    with patch("serial.tools.list_ports.comports", return_value=[]):
        window = MainWindow()
        assert window.sensor_count == 2
        assert (window.reference_key, window.compare_key) == ("A", "B")
        window.close()

    app.processEvents()


def test_app_window_icon_is_set() -> None:
    from theme import APP_ICON_PATH

    assert APP_ICON_PATH.exists()
    app = create_app()
    assert not app.windowIcon().isNull()


def test_version_is_visible_in_title_bar_and_header() -> None:
    from version import APP_VERSION

    app = create_app()
    with patch("serial.tools.list_ports.comports", return_value=[]):
        window = MainWindow()
        assert APP_VERSION in window.windowTitle()
        assert window.version_label.text() == f"V{APP_VERSION}"
        assert not window.version_label.isHidden()
        window.close()

    app.processEvents()


def test_sensor_setup_button_restores_a_minimized_window() -> None:
    app = create_app()

    with patch("serial.tools.list_ports.comports", return_value=[]):
        window = MainWindow()
        window.show_sensor_setup()
        assert window.sensor_window.isVisible()

        window.sensor_window.setWindowState(Qt.WindowMinimized)
        window.show_sensor_setup()
        assert not (
            window.sensor_window.windowState() & Qt.WindowMinimized
        )
        window.close()

    app.processEvents()


def test_plot_pair_selectors_allow_any_two_sensors_and_swap() -> None:
    app = create_app()
    fake_ports = [FakePort(f"COM{index + 3}", "QTFM") for index in range(4)]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        window.sensor_count_combo.setCurrentIndex(
            window.sensor_count_combo.findData(4)
        )
        assert (window.reference_key, window.compare_key) == ("A", "B")

        # Any pair, in any order, without touching sensor A.
        window.reference_combo.setCurrentIndex(
            window.reference_combo.findData("D")
        )
        window.compare_combo.setCurrentIndex(
            window.compare_combo.findData("C")
        )
        assert (window.reference_key, window.compare_key) == ("D", "C")
        assert window.time_series.reference == "D"
        assert window.time_series.compare == "C"

        # Selecting the other plotted sensor swaps the pair instead of failing.
        window.reference_combo.setCurrentIndex(
            window.reference_combo.findData("C")
        )
        assert (window.reference_key, window.compare_key) == ("C", "D")
        window.compare_combo.setCurrentIndex(
            window.compare_combo.findData("C")
        )
        assert (window.reference_key, window.compare_key) == ("D", "C")
        window.close()

    app.processEvents()


def test_plot_pair_stays_free_while_recording(tmp_path: Path) -> None:
    app = create_app()
    fake_ports = [FakePort(f"COM{index + 3}", "QTFM") for index in range(3)]

    with (
        patch("serial.tools.list_ports.comports", return_value=fake_ports),
        patch(
            "main_window.QFileDialog.getSaveFileName",
            return_value=(str(tmp_path / "rec.csv"), "CSV files (*.csv)"),
        ),
    ):
        window = MainWindow()
        window.sensor_count_combo.setCurrentIndex(
            window.sensor_count_combo.findData(3)
        )
        window.start_csv_recording()
        assert window.csv_recorder.active
        assert window.reference_combo.isEnabled()
        assert not window.sensor_count_combo.isEnabled()

        window.reference_combo.setCurrentIndex(
            window.reference_combo.findData("C")
        )
        assert window.reference_key == "C"
        # The running CSV keeps the sensor set captured when it started and
        # records fields only, so the plot change cannot affect it.
        assert window.csv_recorder.active
        assert window.csv_recorder.sensors == ("A", "B", "C")

        window.stop_csv_recording()
        window.close()

    app.processEvents()


def test_gradiometer_feedback_counts_pairs_before_a_finite_value() -> None:
    app = create_app()
    panel = TimeSeriesPanel()
    panel.set_sensor_layout(("A", "B"), "A", "B")
    for key in ("A", "B"):
        panel.set_timestamp_state(key, "available")
    panel.set_gradiometer_status(True, "Timing aligned.")

    # Invalid packets pair by timestamp but produce NaN differences, so the
    # user sees matched-packet progress before anything can be plotted.
    for index in range(3):
        panel.add_measurement(
            "A",
            QTFMMeasurement(50_000.0, False, index * 4, None, 10.0),
        )
        panel.add_measurement(
            "B",
            QTFMMeasurement(50_001.0, True, index * 4, None, 10.0),
        )
    panel._update_value_labels()

    assert panel.pair_count == 3
    assert "3 packets matched" in panel.value_difference_label.text()
    panel.close()
    app.processEvents()


def test_setup_window_has_a_working_command_bar() -> None:
    app = create_app()
    fake_ports = [FakePort("COM3", "QTFM")]

    with patch("serial.tools.list_ports.comports", return_value=fake_ports):
        window = MainWindow()
        assert window.setup_command_bar.parentWidget() is window.sensor_window
        assert window.setup_command_bar is not window.command_bar

        worker = FakeWorker()
        window.sensor_a._worker = worker
        window.sensor_a._connected_port = "COM3"
        window._update_command_controls()

        setup_bar = window.setup_command_bar
        setup_bar.command_target_combo.setCurrentIndex(
            setup_bar.command_target_combo.findData("A")
        )
        assert setup_bar.send_ascii_button.isEnabled()

        setup_bar.ascii_command_edit.setText("s")
        window._send_ascii_from(setup_bar)

        assert worker.commands == [b"s"]
        assert setup_bar.broadcast_status_label.text().startswith("Sent")
        assert setup_bar.ascii_command_edit.text() == ""
        # Each bar keeps its own target; the header bar is unaffected.
        assert window.command_target_combo.currentData() == "both"
        window.close()

    app.processEvents()


def test_terminal_shows_selected_sensor_and_filters_by_first_character() -> None:
    app = create_app()

    with patch("serial.tools.list_ports.comports", return_value=[]):
        window = MainWindow()
        window.show_terminal()
        terminal = window.terminal_window
        assert terminal.isVisible()
        assert terminal.selected_sensor == "A"

        terminal.append_lines("A", ["!50000.1_>100", "#Data rate: 4"])
        assert "!50000.1_>100" in terminal.view.toPlainText()

        # Lines from a sensor that is not selected are ignored.
        terminal.append_lines("B", ["#from B"])
        assert "#from B" not in terminal.view.toPlainText()

        # The filter keeps only lines starting with one of the characters.
        terminal.clear()
        terminal.filter_edit.setText("#")
        terminal.append_lines(
            "A",
            ["!50000.2_>104", "#Serial Number: T2M0-1", ""],
        )
        assert terminal.view.toPlainText() == "#Serial Number: T2M0-1"
        assert "3 lines received" in terminal.status_label.text()
        assert "1 shown" in terminal.status_label.text()

        # Pause discards new lines.
        terminal.pause_button.setChecked(True)
        terminal.append_lines("A", ["#while paused"])
        assert "#while paused" not in terminal.view.toPlainText()

        # The raw-line path from a sensor panel reaches the terminal.
        terminal.pause_button.setChecked(False)
        terminal.filter_edit.setText("")
        window.sensor_a.raw_lines_received.emit(["#via panel signal"])
        assert "#via panel signal" in terminal.view.toPlainText()
        window.close()

    app.processEvents()


def _feed_timestamp_series(
    monitor: TimestampSyncMonitor,
    sensor: str,
    timestamps: list[int],
    host_start: float = 10.0,
) -> None:
    first_timestamp = timestamps[0]
    for timestamp in timestamps:
        host_time = host_start + (timestamp - first_timestamp) / 1000.0
        monitor.add(
            sensor,
            QTFMMeasurement(50_000.0, True, timestamp, None, host_time),
        )


def test_timestamp_distance_over_1000_ms_needs_sync() -> None:
    monitor = TimestampSyncMonitor()
    _feed_timestamp_series(monitor, "A", [2060, 2064, 2068, 2072])
    _feed_timestamp_series(monitor, "B", [60, 64, 68, 72])

    status = monitor.status()
    assert status.ready
    assert status.needs_sync
    assert status.distance_ms == 2000.0
    assert "over 1000 ms" in status.reason


def test_timestamp_phase_mismatch_needs_sync() -> None:
    monitor = TimestampSyncMonitor()
    _feed_timestamp_series(monitor, "A", [62, 66, 70, 74])
    _feed_timestamp_series(monitor, "B", [60, 64, 68, 72])

    status = monitor.status()
    assert status.ready
    assert status.needs_sync
    assert status.sample_interval_ms == 4
    assert status.phase_offset_ms == 2


def test_timestamp_monitor_reports_the_worst_sensor_of_four() -> None:
    monitor = TimestampSyncMonitor(("A", "B", "C", "D"), "A")
    for sensor in ("A", "B", "C"):
        _feed_timestamp_series(monitor, sensor, [60, 64, 68, 72])
    _feed_timestamp_series(monitor, "D", [62, 66, 70, 74])

    status = monitor.status()
    assert status.ready
    assert status.needs_sync
    assert status.sample_interval_ms == 4
    assert status.phase_offset_ms == 2
    assert "A vs D" in status.reason


def test_timestamp_monitor_waits_for_every_configured_sensor() -> None:
    monitor = TimestampSyncMonitor(("A", "B", "C"), "A")
    _feed_timestamp_series(monitor, "A", [60, 64, 68, 72])
    _feed_timestamp_series(monitor, "B", [60, 64, 68, 72])

    status = monitor.status()
    assert not status.ready
    assert "C" in status.reason


def test_aligned_timestamp_cadence_is_ok() -> None:
    monitor = TimestampSyncMonitor()
    _feed_timestamp_series(monitor, "A", [60, 64, 68, 72])
    _feed_timestamp_series(monitor, "B", [60, 64, 68, 72])

    status = monitor.status()
    assert status.ready
    assert not status.needs_sync
    assert status.distance_ms == 0.0
    assert status.phase_offset_ms == 0
