"""
spectroscopy_tab.py - Spectroscopy tab (NMR signal analysis).

Connects to the lf-spectroscopy service (POST /analyze/, default :8002)
and shows two selectable interactive pyqtgraph plot slots.

Available views:
  1. Raw Signal        - time-domain real + envelope  [ms]
  2. Raw FFT           - FFT of raw data before preprocessing  [MHz]
  3. Demodulated FID   - BPF -> IQ-demod -> LPF -> decimation  [ms]
  4. NMR Spectrum      - zero-pad + FFT, magnitude, peak marker + FWHM  [MHz]

Left panel exposes all NMR parameters so the user can rerun analysis
without reloading the file.

Batch mode:  'Batch...' button -> BatchDialog -> POST /batch/ + polling.
Export:      MATLAB (.mat) / CSV / NPZ.
"""
from __future__ import annotations

import json
import os

import numpy as np
import pyqtgraph as pg
from PySide6.QtCore import QEvent, Qt, QThread, QTimer
from PySide6.QtGui import QColor, QFont
from PySide6.QtWidgets import (
    QCheckBox, QComboBox, QDialog, QDoubleSpinBox,
    QFileDialog, QFormLayout, QFrame, QGroupBox,
    QHBoxLayout, QHeaderView, QLabel, QLineEdit,
    QMessageBox, QProgressBar, QPushButton,
    QScrollArea, QSizePolicy, QSpinBox,
    QTableWidget, QTableWidgetItem,
    QVBoxLayout, QWidget,
)

from src.clients.spectroscopy_client import SpectroscopyClient, SpectroscopyError
from src.gui.tr_widgets import TrGroupBox, TrPushButton
from src.gui.workers import OrchestratorWorker
from src.gui.scheme_panel import system_is_dark
from src import i18n, theme


# -- Colour palette -------------------------------------------------------------
_C_RAW_REAL  = "#00cec9"   # Raw signal real      - cyan
_C_RAW_ENV   = "#f0c040"   # Raw signal envelope  - yellow
_C_DEM_REAL  = "#00b894"   # Demod FID real       - green
_C_DEM_IMAG  = "#e84393"   # Demod FID imag       - pink
_C_DEM_ENV   = "#fdcb6e"   # Demod FID envelope   - orange
_C_SPEC_MAG  = "#dfe6e9"   # NMR spectrum mag     - near-white
_C_SPEC_PHA  = "#a29bfe"   # NMR spectrum phase   - lavender
_C_PEAK      = "#e74c3c"   # Peak marker          - red

_KHZ_TO_MHZ = 1e-3
_HZ_TO_MHZ = 1e-6
_PLOT_CHOICES = [
    ("raw", "Raw Signal"),
    ("raw_fft", "Raw FFT"),
    ("demod", "Demodulated FID"),
    ("spectrum", "NMR Spectrum"),
]


# ===============================================================================
#  Interactive ViewBox
# ===============================================================================

class InteractiveViewBox(pg.ViewBox):
    def __init__(self, *, x_only: bool = False, **kwargs) -> None:
        super().__init__(**kwargs)
        self._x_only = x_only
        self.setMouseMode(pg.ViewBox.RectMode)
        self.setMouseEnabled(x=True, y=not x_only)

    def mouseDoubleClickEvent(self, ev) -> None:
        self.autoRange()
        ev.accept()

    def mouseDragEvent(self, ev, axis=None) -> None:
        if ev.modifiers() & Qt.ControlModifier:
            prev_mode = self.state["mouseMode"]
            self.setMouseMode(pg.ViewBox.PanMode)
            try:
                super().mouseDragEvent(ev, axis=axis)
            finally:
                self.setMouseMode(prev_mode)
            return

        super().mouseDragEvent(ev, axis=axis)


# ===============================================================================
#  SpectroscopyTab
# ===============================================================================

class SpectroscopyTab(QWidget):
    """
    NMR spectroscopy analysis tab.

    Constructor parameters
    ----------------------
    spectroscopy_url : str
        Base URL for the lf-spectroscopy service  (default :8002).
    """

    def __init__(
        self,
        spectroscopy_url: str = "http://localhost:8002",
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self._client = SpectroscopyClient(spectroscopy_url)
        self._worker: QThread | None = None
        self._last_result: dict | None = None
        self._last_json_path: str | None = None
        self._plot_widgets: dict[str, pg.PlotWidget] = {}
        self._plot_combos: list[QComboBox] = []
        self._plot_hosts: list[QVBoxLayout] = []
        self._plot_slot_widgets: list[pg.PlotWidget | None] = [None, None]
        self._plot_selection = ["demod", "spectrum"]

        root = QVBoxLayout(self)
        root.setContentsMargins(0, 0, 0, 0)
        root.setSpacing(0)
        root.addWidget(self._build_toolbar())
        root.addWidget(self._build_main(), stretch=1)
        root.addWidget(self._build_statusbar())

    # -- Public API --------------------------------------------------------

    def set_spectroscopy_url(self, url: str) -> None:
        self._client = SpectroscopyClient(url)

    def receive_scan_data(self, json_path: str) -> None:
        """
        Called automatically after a scan completes: load the raw JSON file
        and start NMR analysis without user interaction.
        """
        if not os.path.isfile(json_path):
            return
        self._last_json_path = json_path
        self._update_data_ranges_from_file(json_path)
        self._btn_analyze.setEnabled(True)
        self._run_analysis(json_path)

    def current_params(self) -> dict:
        """Return current NMR parameters as a plain dict."""
        return {
            "center_freq":   self._sb_fc.value()   * 1e6,   # MHz -> Hz
            "bandpass_bw":   self._sb_bw.value()   * 1e3,   # kHz -> Hz
            "lp_cutoff":     self._sb_lp.value()   * 1e3,   # kHz -> Hz
            "dec_factor":    self._sb_dec.value(),
            "zero_pad":      self._sb_zp.value(),
            "butter_order":  4,
            "voltage_range": 5.0,
            "averaging_num": self._sb_avg.value(),
            "data_num":      self._sb_dnum.value(),
            "channel_num":   self._sb_ch.value(),
        }

    # -- Toolbar -----------------------------------------------------------

    def apply_theme(self) -> None:
        p = theme.palette()
        self._toolbar.setStyleSheet(
            f"#SpecToolBar {{ background: {p['surface']}; border-bottom: 1px solid {p['border2']}; }}"
        )
        pg.setConfigOptions(
            antialias=True,
            background=QColor(p["bg"]),
            foreground=QColor(p["text"]),
        )
        for pw in (self._raw_widget, self._raw_fft_widget,
                   self._dem_widget, self._sp_widget):
            pw.setBackground(p["bg"])

    def _build_toolbar(self) -> QWidget:
        bar = QWidget()
        bar.setObjectName("SpecToolBar")
        p = theme.palette()
        bar.setStyleSheet(
            f"#SpecToolBar {{ background: {p['surface']}; border-bottom: 1px solid {p['border2']}; }}"
        )
        lay = QHBoxLayout(bar)
        lay.setContentsMargins(6, 4, 6, 4)
        lay.setSpacing(6)

        self._btn_load = TrPushButton("btn_load_json")
        self._btn_load.setToolTip("Load a hardware JSON file and run NMR analysis")
        self._btn_load.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self._btn_load.clicked.connect(self._load_json)
        lay.addWidget(self._btn_load)

        self._btn_analyze = TrPushButton("btn_analyze")
        self._btn_analyze.setToolTip(
            "Re-run analysis with the current parameters\n"
            "(same file, updated settings)"
        )
        self._btn_analyze.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self._btn_analyze.setEnabled(False)
        self._btn_analyze.clicked.connect(self._analyze)
        lay.addWidget(self._btn_analyze)

        lay.addWidget(_vsep())

        self._btn_batch = TrPushButton("btn_batch")
        self._btn_batch.setToolTip("Process a whole folder of hardware JSON files")
        self._btn_batch.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self._btn_batch.clicked.connect(self._open_batch_dialog)
        lay.addWidget(self._btn_batch)

        lay.addWidget(_vsep())

        self._btn_export = TrPushButton("btn_export_sp")
        self._btn_export.setToolTip("Save analysis results (.mat / .csv / .npz)")
        self._btn_export.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self._btn_export.setEnabled(False)
        self._btn_export.clicked.connect(self._export_results)
        lay.addWidget(self._btn_export)

        self._btn_clear = TrPushButton("btn_clear")
        self._btn_clear.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
        self._btn_clear.clicked.connect(self._clear_plots)
        lay.addWidget(self._btn_clear)

        lay.addStretch()

        self._progress = QProgressBar()
        self._progress.setRange(0, 0)
        self._progress.setFixedWidth(120)
        self._progress.setVisible(False)
        lay.addWidget(self._progress)

        self._toolbar = bar
        return bar

    # -- Main splitter -----------------------------------------------------

    def _build_main(self) -> QWidget:
        container = QWidget()
        lay = QHBoxLayout(container)
        lay.setContentsMargins(0, 0, 0, 0)
        lay.setSpacing(2)
        lay.addWidget(self._build_left_panel())
        lay.addWidget(self._build_plots_panel(), stretch=1)
        return container

    # -- Left panel --------------------------------------------------------

    def _build_left_panel(self) -> QScrollArea:
        inner = QWidget()
        inner.setMinimumWidth(200)
        inner.setMaximumWidth(290)
        lay = QVBoxLayout(inner)
        lay.setContentsMargins(6, 6, 6, 6)
        lay.setSpacing(8)

        # -- NMR Parameters -----------------------------------------------
        self._nmr_grp = TrGroupBox("grp_nmr_params")
        nmr_frm = QFormLayout(self._nmr_grp)
        nmr_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)

        self._sb_fc = QDoubleSpinBox()
        self._sb_fc.setRange(0.001, 100.0)
        self._sb_fc.setDecimals(4)
        self._sb_fc.setSuffix(" MHz")
        self._sb_fc.setSingleStep(0.01)
        self._sb_fc.setValue(2.95)
        self._sb_fc.setToolTip("Carrier / Larmor frequency")

        self._sb_bw = QDoubleSpinBox()
        self._sb_bw.setRange(1.0, 5000.0)
        self._sb_bw.setDecimals(1)
        self._sb_bw.setSuffix(" kHz")
        self._sb_bw.setSingleStep(10.0)
        self._sb_bw.setValue(100.0)
        self._sb_bw.setToolTip("Bandpass filter bandwidth (fc +/- BW/2)")

        self._sb_lp = QDoubleSpinBox()
        self._sb_lp.setRange(1.0, 5000.0)
        self._sb_lp.setDecimals(1)
        self._sb_lp.setSuffix(" kHz")
        self._sb_lp.setSingleStep(10.0)
        self._sb_lp.setValue(600.0)
        self._sb_lp.setToolTip("Low-pass cutoff after IQ demodulation")

        self._sb_dec = QSpinBox()
        self._sb_dec.setRange(1, 1000)
        self._sb_dec.setPrefix("x")
        self._sb_dec.setValue(10)
        self._sb_dec.setToolTip("Decimation factor")

        self._sb_zp = QSpinBox()
        self._sb_zp.setRange(0, 10_000_000)
        self._sb_zp.setValue(500_001)
        self._sb_zp.setSingleStep(10_000)
        self._sb_zp.setToolTip("Zero-padding length for FFT resolution")

        self._sb_avg = QSpinBox()
        self._sb_avg.setRange(0, 9999)
        self._sb_avg.setValue(0)
        self._sb_avg.setToolTip("averaging_num field in hardware JSON")

        self._sb_dnum = QSpinBox()
        self._sb_dnum.setRange(0, 9999)
        self._sb_dnum.setValue(0)
        self._sb_dnum.setToolTip("data_num field in hardware JSON")

        self._sb_ch = QSpinBox()
        self._sb_ch.setRange(0, 15)
        self._sb_ch.setValue(1)
        self._sb_ch.setToolTip("channel_num field in hardware JSON")

        nmr_frm.addRow("Center freq:", self._sb_fc)
        nmr_frm.addRow("BW:",          self._sb_bw)
        nmr_frm.addRow("LPF:",         self._sb_lp)
        nmr_frm.addRow("Decimation:",  self._sb_dec)
        nmr_frm.addRow("Zero pad:",    self._sb_zp)
        nmr_frm.addRow("Avg num:",     self._sb_avg)
        nmr_frm.addRow("Data num:",    self._sb_dnum)
        nmr_frm.addRow("Channel:",     self._sb_ch)
        lay.addWidget(self._nmr_grp)

        # -- Metadata ------------------------------------------------------
        self._meta_grp = TrGroupBox("grp_metadata")
        meta_frm = QFormLayout(self._meta_grp)
        meta_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
        self._lbl_n   = QLabel("-")
        self._lbl_fs  = QLabel("-")
        self._lbl_dur = QLabel("-")
        self._lbl_dec = QLabel("-")
        self._lbl_avg_range = QLabel("-")
        self._lbl_data_range = QLabel("-")
        self._lbl_ch_range = QLabel("-")
        meta_frm.addRow("Samples:",  self._lbl_n)
        meta_frm.addRow("Rate:",     self._lbl_fs)
        meta_frm.addRow("Duration:", self._lbl_dur)
        meta_frm.addRow("Dec x:",    self._lbl_dec)
        meta_frm.addRow("Avg range:", self._lbl_avg_range)
        meta_frm.addRow("Data range:", self._lbl_data_range)
        meta_frm.addRow("Channel range:", self._lbl_ch_range)
        lay.addWidget(self._meta_grp)

        # -- Metrics -------------------------------------------------------
        self._met_grp = TrGroupBox("grp_metrics")
        met_frm = QFormLayout(self._met_grp)
        met_frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
        mono9 = QFont("Courier New", 9)
        self._lbl_peak_f   = QLabel("-"); self._lbl_peak_f.setFont(mono9)
        self._lbl_fwhm     = QLabel("-"); self._lbl_fwhm.setFont(mono9)
        self._lbl_peak_amp = QLabel("-"); self._lbl_peak_amp.setFont(mono9)
        met_frm.addRow("Peak freq:",  self._lbl_peak_f)
        met_frm.addRow("FWHM:",       self._lbl_fwhm)
        met_frm.addRow("Amplitude:",  self._lbl_peak_amp)
        lay.addWidget(self._met_grp)

        lay.addStretch()

        # -- Cursor readout -------------------------------------------------

        scroll = QScrollArea()
        scroll.setWidget(inner)
        scroll.setWidgetResizable(True)
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        return scroll

    # -- Plots panel -------------------------------------------------------

    def _build_plots_panel(self) -> QWidget:
        p = theme.palette()
        pg.setConfigOptions(
            antialias=True,
            background=QColor(p["bg"]),
            foreground=QColor(p["text"]),
        )
        _dark = theme.is_dark()
        _cross = pg.mkPen(
            "#ffffff50" if _dark else "#00000050", width=1, style=Qt.DashLine
        )

        vsplit = QWidget()
        vsplit_lay = QVBoxLayout(vsplit)
        vsplit_lay.setContentsMargins(0, 0, 0, 0)
        vsplit_lay.setSpacing(2)

        self._raw_widget = pg.PlotWidget(
            viewBox=InteractiveViewBox(x_only=False)
        )
        p1 = self._raw_widget.getPlotItem()
        p1.setTitle("Raw Signal")
        p1.setLabel("bottom", "Время", units="мс", color=p["text"], size="9pt")
        p1.setLabel("left",   "Амплитуда", units="В", color=p["text"], size="9pt")
        p1.showGrid(x=True, y=True, alpha=0.2)
        p1.addLegend(offset=(10, 5))
        self._raw_real = p1.plot(pen=pg.mkPen(_C_RAW_REAL, width=1.5), name="Real")
        self._raw_env  = p1.plot(pen=pg.mkPen(_C_RAW_ENV,  width=1.5), name="Envelope")
        self._raw_vl = pg.InfiniteLine(angle=90, movable=False, pen=_cross)
        self._raw_hl = pg.InfiniteLine(angle=0,  movable=False, pen=_cross)
        self._raw_vl.setVisible(False)
        self._raw_hl.setVisible(False)
        p1.addItem(self._raw_vl, ignoreBounds=True)
        p1.addItem(self._raw_hl, ignoreBounds=True)
        self._raw_hover = self._make_hover_label()
        p1.addItem(self._raw_hover, ignoreBounds=True)
        self._raw_widget.scene().sigMouseMoved.connect(self._on_raw_mouse)
        self._raw_widget.viewport().installEventFilter(self)

        self._raw_fft_widget = pg.PlotWidget(
            viewBox=InteractiveViewBox(x_only=True)
        )
        p4 = self._raw_fft_widget.getPlotItem()
        p4.setTitle("Raw FFT")
        p4.setLabel("bottom", "Частота", units="МГц", color=p["text"], size="9pt")
        p4.setLabel("left",   "Амплитуда", units="В", color=p["text"], size="9pt")
        p4.showGrid(x=True, y=True, alpha=0.2)
        p4.addLegend(offset=(10, 5))
        self._raw_fft_mag = p4.plot(
            pen=pg.mkPen(_C_RAW_REAL, width=1.5), name="Magnitude"
        )
        self._raw_fft_phase = p4.plot(
            pen=pg.mkPen(_C_SPEC_PHA, width=1.0), name="Phase (rad)"
        )
        self._raw_fft_phase.setVisible(False)
        self._raw_fft_vl = pg.InfiniteLine(angle=90, movable=False, pen=_cross)
        self._raw_fft_hl = pg.InfiniteLine(angle=0,  movable=False, pen=_cross)
        self._raw_fft_vl.setVisible(False)
        self._raw_fft_hl.setVisible(False)
        p4.addItem(self._raw_fft_vl, ignoreBounds=True)
        p4.addItem(self._raw_fft_hl, ignoreBounds=True)
        self._raw_fft_hover = self._make_hover_label()
        p4.addItem(self._raw_fft_hover, ignoreBounds=True)
        self._raw_fft_widget.scene().sigMouseMoved.connect(self._on_raw_fft_mouse)
        self._raw_fft_widget.viewport().installEventFilter(self)

        self._dem_widget = pg.PlotWidget(
            viewBox=InteractiveViewBox(x_only=False)
        )
        p2 = self._dem_widget.getPlotItem()
        p2.setTitle("Demodulated FID")
        p2.setLabel("bottom", "Время", units="мс", color=p["text"], size="9pt")
        p2.setLabel("left",   "Амплитуда", units="отн. ед.", color=p["text"], size="9pt")
        p2.showGrid(x=True, y=True, alpha=0.2)
        p2.addLegend(offset=(10, 5))
        self._dem_real = p2.plot(pen=pg.mkPen(_C_DEM_REAL, width=1.5), name="Real")
        self._dem_imag = p2.plot(pen=pg.mkPen(_C_DEM_IMAG, width=1.5), name="Imag")
        self._dem_env  = p2.plot(pen=pg.mkPen(_C_DEM_ENV,  width=1.5), name="Envelope")
        self._dem_vl = pg.InfiniteLine(angle=90, movable=False, pen=_cross)
        self._dem_hl = pg.InfiniteLine(angle=0,  movable=False, pen=_cross)
        self._dem_vl.setVisible(False)
        self._dem_hl.setVisible(False)
        p2.addItem(self._dem_vl, ignoreBounds=True)
        p2.addItem(self._dem_hl, ignoreBounds=True)
        self._dem_hover = self._make_hover_label()
        p2.addItem(self._dem_hover, ignoreBounds=True)
        self._dem_widget.scene().sigMouseMoved.connect(self._on_dem_mouse)
        self._dem_widget.viewport().installEventFilter(self)

        self._sp_widget = pg.PlotWidget(
            viewBox=InteractiveViewBox(x_only=True)
        )
        p3 = self._sp_widget.getPlotItem()
        p3.setTitle("NMR Spectrum")
        p3.setLabel("bottom", "Частота", units="МГц", color=p["text"], size="9pt")
        p3.setLabel("left",   "Амплитуда", units="отн. ед.", color=p["text"], size="9pt")
        p3.showGrid(x=True, y=True, alpha=0.2)
        p3.addLegend(offset=(10, 5))
        self._sp_mag   = p3.plot(pen=pg.mkPen(_C_SPEC_MAG, width=1.5), name="Magnitude")
        self._sp_phase = p3.plot(pen=pg.mkPen(_C_SPEC_PHA, width=1.0), name="Phase (rad)")
        self._sp_phase.setVisible(False)
        self._peak_line = pg.InfiniteLine(
            angle=90, movable=False,
            pen=pg.mkPen(_C_PEAK, width=1.5, style=Qt.DashLine),
        )
        self._peak_line.setVisible(False)
        p3.addItem(self._peak_line)
        self._fwhm_region = pg.LinearRegionItem(
            values=[-1.0, 1.0],
            movable=False,
            brush=pg.mkBrush(231, 76, 60, 40),
            pen=pg.mkPen(_C_PEAK, width=0.5),
        )
        self._fwhm_region.setVisible(False)
        p3.addItem(self._fwhm_region)
        self._sp_vl = pg.InfiniteLine(angle=90, movable=False, pen=_cross)
        self._sp_hl = pg.InfiniteLine(angle=0,  movable=False, pen=_cross)
        self._sp_vl.setVisible(False)
        self._sp_hl.setVisible(False)
        p3.addItem(self._sp_vl, ignoreBounds=True)
        p3.addItem(self._sp_hl, ignoreBounds=True)
        self._sp_hover = self._make_hover_label()
        p3.addItem(self._sp_hover, ignoreBounds=True)
        self._sp_widget.scene().sigMouseMoved.connect(self._on_sp_mouse)
        self._sp_widget.viewport().installEventFilter(self)

        self._plot_widgets = {
            "raw": self._raw_widget,
            "raw_fft": self._raw_fft_widget,
            "demod": self._dem_widget,
            "spectrum": self._sp_widget,
        }

        vsplit_lay.addWidget(self._build_plot_slot(0, "demod"),    stretch=1)
        vsplit_lay.addWidget(self._build_plot_slot(1, "spectrum"), stretch=1)
        self._refresh_plot_slots()
        return vsplit

    def _build_plot_slot(self, slot_idx: int, default_key: str) -> QWidget:
        frame = QFrame()
        frame.setFrameShape(QFrame.StyledPanel)
        frame.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Ignored)
        lay = QVBoxLayout(frame)
        lay.setContentsMargins(4, 4, 4, 4)
        lay.setSpacing(4)

        combo = QComboBox()
        for key, title in _PLOT_CHOICES:
            combo.addItem(title, key)
        combo.setCurrentIndex(max(0, combo.findData(default_key)))
        combo.currentIndexChanged.connect(
            lambda _idx, slot=slot_idx: self._on_plot_slot_changed(slot)
        )
        lay.addWidget(combo)

        host = QVBoxLayout()
        host.setContentsMargins(0, 0, 0, 0)
        lay.addLayout(host, stretch=1)

        self._plot_combos.append(combo)
        self._plot_hosts.append(host)
        return frame

    def _on_plot_slot_changed(self, slot_idx: int) -> None:
        new_key = self._plot_combos[slot_idx].currentData()
        if not new_key:
            return

        other_idx = 1 - slot_idx
        prev_key = self._plot_selection[slot_idx]
        if new_key == self._plot_selection[other_idx]:
            self._plot_selection[other_idx] = prev_key
            other_combo = self._plot_combos[other_idx]
            old_state = other_combo.blockSignals(True)
            other_combo.setCurrentIndex(other_combo.findData(prev_key))
            other_combo.blockSignals(old_state)

        self._plot_selection[slot_idx] = new_key
        self._refresh_plot_slots()

    def _refresh_plot_slots(self) -> None:
        # Step 1: detach and hide every plot widget from all slot hosts.
        # QLayout.removeWidget() does NOT hide the widget, so we must call
        # hide() explicitly — otherwise detached widgets stay visible and
        # overlap the content of whichever slot is rendered on top of them.
        # We also iterate over all hosts (not just the "current" one) because
        # during a swap the widget may already have been moved to a different
        # host by the previous loop iteration, making a single-host removeWidget
        # a silent no-op.
        for pw in self._plot_widgets.values():
            for host in self._plot_hosts:
                host.removeWidget(pw)
            pw.hide()

        # Step 2: place and show only the widgets that are actually selected.
        for slot_idx, key in enumerate(self._plot_selection):
            pw = self._plot_widgets[key]
            self._plot_hosts[slot_idx].addWidget(pw)
            pw.show()
            self._plot_slot_widgets[slot_idx] = pw

    # -- Status bar --------------------------------------------------------

    def _build_statusbar(self) -> QFrame:
        bar = QFrame()
        bar.setFrameShape(QFrame.StyledPanel)
        bar.setFixedHeight(22)
        lay = QHBoxLayout(bar)
        lay.setContentsMargins(6, 0, 6, 0)
        self._status_lbl = QLabel(i18n.tr("spec_status_init"))
        self._status_lbl.setFont(QFont("Arial", 8))
        lay.addWidget(self._status_lbl)
        lay.addStretch()
        return bar

    # retranslate_ui is not needed — all widgets use TrPushButton / TrGroupBox.

    # -- Actions -----------------------------------------------------------

    def _load_json(self) -> None:
        path, _ = QFileDialog.getOpenFileName(
            self, "Load hardware JSON", "",
            "JSON files (*.json);;All files (*)"
        )
        if not path:
            return
        self._last_json_path = path
        self._update_data_ranges_from_file(path)
        self._btn_analyze.setEnabled(True)
        self._run_analysis(path)

    def _analyze(self) -> None:
        if self._last_json_path:
            self._run_analysis(self._last_json_path)

    def _run_analysis(self, path: str) -> None:
        fname = os.path.basename(path)
        self._set_busy(True, f"Analyzing {fname}...")
        params = self.current_params()
        self._worker = OrchestratorWorker(
            self._client.analyze_from_params, path, params
        )
        self._worker.finished.connect(self._on_result)
        self._worker.error.connect(self._on_error)
        self._worker.start()

    def _open_batch_dialog(self) -> None:
        dlg = BatchDialog(self._client, self.current_params(), parent=self)
        dlg.exec()

    def _export_results(self) -> None:
        if not self._last_result:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Save analysis results", "",
            "MATLAB files (*.mat);;CSV files (*.csv);;NumPy archive (*.npz)"
        )
        if not path:
            return
        try:
            if path.endswith(".mat"):
                _export_mat(path, self._last_result)
            elif path.endswith(".csv"):
                _export_csv_single(path, self._last_result)
            else:
                _export_npz(path, self._last_result)
            self._status(f"Saved -> {os.path.basename(path)}")
        except Exception as exc:
            QMessageBox.critical(self, "Export error", str(exc))

    def _clear_plots(self) -> None:
        empty: list = []
        for curve in (
            self._raw_real, self._raw_env,
            self._raw_fft_mag, self._raw_fft_phase,
            self._dem_real, self._dem_imag, self._dem_env,
            self._sp_mag, self._sp_phase,
        ):
            curve.setData(empty, empty)
        self._peak_line.setVisible(False)
        self._fwhm_region.setVisible(False)
        self._hide_hover(self._raw_vl, self._raw_hl, self._raw_hover)
        self._hide_hover(self._raw_fft_vl, self._raw_fft_hl, self._raw_fft_hover)
        self._hide_hover(self._dem_vl, self._dem_hl, self._dem_hover)
        self._hide_hover(self._sp_vl, self._sp_hl, self._sp_hover)
        self._last_result = None
        self._btn_export.setEnabled(False)
        self._update_metadata(None)
        self._update_data_ranges(None)
        self._update_metrics(None)
        self._status("Cleared")

    # -- Result handling ---------------------------------------------------

    def _on_result(self, result: dict) -> None:
        self._set_busy(False)
        self._last_result = result
        self._btn_export.setEnabled(True)
        self._plot_result(result)
        m = result.get("metrics", {})
        self._status(
            f"OK - peak {m.get('peak_freq_khz', 0) * _KHZ_TO_MHZ:.6f} MHz  |  "
            f"FWHM {m.get('fwhm_khz', 0) * 1e3:.1f} Hz  |  "
            f"{os.path.basename(self._last_json_path or '')}"
        )

    def _plot_result(self, result: dict) -> None:
        raw = result.get("raw_time",   {})
        raw_sp = result.get("raw_spectrum", {})
        dem = result.get("demod_time", {})
        sp  = result.get("spectrum",   {})
        met = result.get("metrics",    {})

        # -- Raw ----------------------------------------------------------
        t_r = raw.get("t_ms", [])
        self._raw_real.setData(t_r, raw.get("real",      []))
        self._raw_env. setData(t_r, raw.get("amplitude", []))
        f_raw_mhz = [x * _HZ_TO_MHZ for x in raw_sp.get("freq_hz", [])]
        self._raw_fft_mag.setData(f_raw_mhz, raw_sp.get("magnitude", []))
        self._raw_fft_phase.setData(f_raw_mhz, raw_sp.get("phase_rad", []))

        # -- Demod FID ----------------------------------------------------
        t_d = dem.get("t_ms", [])
        self._dem_real.setData(t_d, dem.get("real",      []))
        self._dem_imag.setData(t_d, dem.get("imag",      []))
        self._dem_env. setData(t_d, dem.get("amplitude", []))

        # -- Spectrum -----------------------------------------------------
        f_mhz = [x * _KHZ_TO_MHZ for x in sp.get("freq_khz", [])]
        self._sp_mag.  setData(f_mhz, sp.get("magnitude", []))
        self._sp_phase.setData(f_mhz, sp.get("phase_rad", []))

        # Peak marker + FWHM shading
        peak_f_mhz = float(met.get("peak_freq_khz", 0)) * _KHZ_TO_MHZ
        fwhm_mhz = float(met.get("fwhm_khz", 0)) * _KHZ_TO_MHZ
        self._peak_line.setValue(peak_f_mhz)
        self._peak_line.setVisible(True)
        hw = fwhm_mhz / 2.0
        self._fwhm_region.setRegion([peak_f_mhz - hw, peak_f_mhz + hw])
        self._fwhm_region.setVisible(True)

        self._update_metadata(result.get("metadata"))
        self._update_metrics(met)

    def _update_metadata(self, meta: dict | None) -> None:
        if meta is None:
            for lbl in (self._lbl_n, self._lbl_fs, self._lbl_dur, self._lbl_dec):
                lbl.setText("-")
            return
        self._lbl_n.  setText(str(meta.get("n_samples", "-")))
        fs = meta.get("sample_rate_hz")
        self._lbl_fs. setText(f"{fs / 1e6:.3f} MHz" if fs else "-")
        dur = meta.get("duration_ms")
        self._lbl_dur.setText(f"{dur:.4f} ms"       if dur is not None else "-")
        self._lbl_dec.setText(str(meta.get("dec_factor_actual", "-")))

    def _update_data_ranges_from_file(self, path: str) -> None:
        try:
            with open(path, "r", encoding="utf-8") as fh:
                payload = json.load(fh)
        except Exception:
            self._update_data_ranges(None)
            return

        items: list[dict] = []
        if isinstance(payload, list):
            items = [item for item in payload if isinstance(item, dict)]
        elif isinstance(payload, dict):
            for value in payload.values():
                if isinstance(value, list):
                    items.extend(item for item in value if isinstance(item, dict))

        avg_values = sorted({
            item.get("averaging_num")
            for item in items
            if isinstance(item.get("averaging_num"), int)
        })
        data_values = sorted({
            item.get("data_num")
            for item in items
            if isinstance(item.get("data_num"), int)
        })
        channel_values = sorted({
            ch.get("channel_num")
            for item in items
            for ch in item.get("channel_data", [])
            if isinstance(ch, dict) and isinstance(ch.get("channel_num"), int)
        })

        self._update_data_ranges({
            "avg": avg_values,
            "data": data_values,
            "channel": channel_values,
        })

    def _update_data_ranges(self, ranges: dict | None) -> None:
        if not ranges:
            for lbl in (self._lbl_avg_range, self._lbl_data_range, self._lbl_ch_range):
                lbl.setText("-")
            self._sb_avg.setRange(0, 9999)
            self._sb_dnum.setRange(0, 9999)
            self._sb_ch.setRange(0, 15)
            return

        self._set_spinbox_range_from_values(
            self._sb_avg, ranges.get("avg", []), self._lbl_avg_range
        )
        self._set_spinbox_range_from_values(
            self._sb_dnum, ranges.get("data", []), self._lbl_data_range
        )
        self._set_spinbox_range_from_values(
            self._sb_ch, ranges.get("channel", []), self._lbl_ch_range
        )

    def _set_spinbox_range_from_values(
        self,
        spinbox: QSpinBox,
        values: list[int],
        label: QLabel,
    ) -> None:
        if not values:
            label.setText("-")
            label.setToolTip("")
            return

        spinbox.setRange(values[0], values[-1])
        if spinbox.value() not in values:
            spinbox.setValue(values[0])

        text_values = ", ".join(str(v) for v in values)
        label.setToolTip(text_values)
        spinbox.setToolTip(f"Available values: {text_values}")

        if len(values) == 1:
            label.setText(f"{values[0]} (1)")
        elif len(values) <= 8:
            label.setText(f"{text_values} ({len(values)})")
        else:
            label.setText(f"{values[0]}...{values[-1]} ({len(values)})")

    def _update_metrics(self, met: dict | None) -> None:
        if met is None:
            for lbl in (self._lbl_peak_f, self._lbl_fwhm, self._lbl_peak_amp):
                lbl.setText("-")
            return
        pf = met.get("peak_freq_khz")
        fw = met.get("fwhm_khz")
        pa = met.get("peak_amplitude")
        self._lbl_peak_f.  setText(f"{pf * _KHZ_TO_MHZ:.6f} MHz" if pf is not None else "-")
        self._lbl_fwhm.    setText(f"{fw * 1e3:.2f} Hz"  if fw is not None else "-")
        self._lbl_peak_amp.setText(f"{pa:.4g}"            if pa is not None else "-")

    def _on_error(self, msg: str) -> None:
        self._set_busy(False)
        self._status(f"ERROR: {msg}")
        QMessageBox.critical(self, "Analysis error", msg)

    # -- Mouse / crosshair -------------------------------------------------

    def _on_raw_mouse(self, pos) -> None:
        self._update_hover(
            self._raw_widget,
            self._raw_vl, self._raw_hl, self._raw_hover,
            pos, "t", "мс", "A", "В",
        )

    def _on_raw_fft_mouse(self, pos) -> None:
        self._update_hover(
            self._raw_fft_widget,
            self._raw_fft_vl, self._raw_fft_hl, self._raw_fft_hover,
            pos, "f", "МГц", "A", "В",
        )

    def _on_dem_mouse(self, pos) -> None:
        self._update_hover(
            self._dem_widget,
            self._dem_vl, self._dem_hl, self._dem_hover,
            pos, "t", "мс", "A", "отн.",
        )

    def _on_sp_mouse(self, pos) -> None:
        self._update_hover(
            self._sp_widget,
            self._sp_vl,
            self._sp_hl,
            self._sp_hover,
            pos, "f", "МГц", "A", "отн.",
        )

    # -- Helpers -----------------------------------------------------------

    def _make_hover_label(self) -> pg.TextItem:
        dark = system_is_dark()
        label = pg.TextItem(
            anchor=(0, 1),
            color="#ddddff" if dark else "#1a1a2e",
            fill=pg.mkBrush(26, 26, 46, 200) if dark else pg.mkBrush(240, 240, 248, 200),
            border=pg.mkPen("#f0c04060" if dark else "#1a1a2e30"),
        )
        label.setFont(QFont("Courier New", 8))
        label.setVisible(False)
        return label

    def _update_hover(
        self,
        widget: pg.PlotWidget,
        vline: pg.InfiniteLine,
        hline: pg.InfiniteLine,
        label: pg.TextItem,
        pos,
        x_name: str,
        x_unit: str,
        y_name: str,
        y_unit: str = "",
    ) -> None:
        if not widget.sceneBoundingRect().contains(pos):
            self._hide_hover(vline, hline, label)
            return

        pt = widget.getPlotItem().getViewBox().mapSceneToView(pos)
        plot = widget.getPlotItem()
        x_range, y_range = plot.vb.viewRange()
        dx = (x_range[1] - x_range[0]) * 0.015
        dy = (y_range[1] - y_range[0]) * 0.04

        x_str = f"{x_name} = {pt.x():.4f} {x_unit}"
        y_str = f"{y_name} = {pt.y():.4g}" + (f" {y_unit}" if y_unit else "")

        vline.setVisible(True)
        hline.setVisible(True)
        vline.setPos(pt.x())
        hline.setPos(pt.y())
        label.setText(f"{x_str}\n{y_str}")
        label.setPos(pt.x() + dx, pt.y() + dy)
        label.setVisible(True)

    def _hide_hover(
        self,
        vline: pg.InfiniteLine,
        hline: pg.InfiniteLine,
        label: pg.TextItem,
    ) -> None:
        vline.setVisible(False)
        hline.setVisible(False)
        label.setVisible(False)

    def eventFilter(self, watched, event) -> bool:
        if event.type() == QEvent.Leave:
            if watched is self._raw_widget.viewport():
                self._hide_hover(self._raw_vl, self._raw_hl, self._raw_hover)
            elif watched is self._raw_fft_widget.viewport():
                self._hide_hover(
                    self._raw_fft_vl, self._raw_fft_hl, self._raw_fft_hover
                )
            elif watched is self._dem_widget.viewport():
                self._hide_hover(self._dem_vl, self._dem_hl, self._dem_hover)
            elif watched is self._sp_widget.viewport():
                self._hide_hover(self._sp_vl, self._sp_hl, self._sp_hover)
        return super().eventFilter(watched, event)

    def _set_busy(self, busy: bool, tip: str = "") -> None:
        self._progress.setVisible(busy)
        self._btn_load.   setEnabled(not busy)
        self._btn_analyze.setEnabled(not busy and self._last_json_path is not None)
        self._btn_batch.  setEnabled(not busy)
        if tip:
            self._status(tip)

    def _status(self, msg: str) -> None:
        self._status_lbl.setText(msg)


# ===============================================================================
#  BatchDialog
# ===============================================================================

class BatchDialog(QDialog):
    """
    Batch NMR analysis dialog.

    Submits POST /batch/ -> polls GET /batch/{id} every second until
    completion, updating a QTableWidget with per-file results.
    """

    _COL_FILE = 0
    _COL_PEAK = 1
    _COL_FWHM = 2
    _COL_AMP  = 3
    _COL_ST   = 4

    def __init__(
        self,
        client: SpectroscopyClient,
        current_params: dict,
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        self.setWindowTitle(i18n.tr("batch_title"))
        self.setMinimumSize(720, 540)

        self._client         = client
        self._current_params = current_params
        self._batch_id: str | None = None
        self._submit_worker: QThread | None = None
        self._poll_worker:   QThread | None = None
        self._timer = QTimer(self)
        self._timer.setInterval(1000)
        self._timer.timeout.connect(self._poll_batch)

        self._build_ui()

    # -- UI ----------------------------------------------------------------

    def _build_ui(self) -> None:
        lay = QVBoxLayout(self)
        lay.setSpacing(8)

        # Folder picker
        fold_row = QHBoxLayout()
        fold_row.addWidget(QLabel(i18n.tr("folder_label")))
        self._le_folder = QLineEdit()
        self._le_folder.setPlaceholderText("Path to folder containing *.json files")
        fold_row.addWidget(self._le_folder, stretch=1)
        btn_browse = QPushButton(i18n.tr("btn_browse"))
        btn_browse.clicked.connect(self._browse_folder)
        fold_row.addWidget(btn_browse)
        lay.addLayout(fold_row)

        # Params toggle
        self._cb_use_current = QCheckBox(i18n.tr("cb_use_current"))
        self._cb_use_current.setChecked(True)
        self._cb_use_current.toggled.connect(self._on_use_current_toggled)
        lay.addWidget(self._cb_use_current)

        self._params_widget = self._build_batch_params()
        self._params_widget.setVisible(False)
        lay.addWidget(self._params_widget)

        # Run row
        run_row = QHBoxLayout()
        self._btn_run = QPushButton(i18n.tr("btn_run_batch"))
        self._btn_run.setMinimumWidth(110)
        self._btn_run.clicked.connect(self._run_batch)
        run_row.addWidget(self._btn_run)
        self._prog = QProgressBar()
        self._prog.setValue(0)
        run_row.addWidget(self._prog, stretch=1)
        self._lbl_prog = QLabel("0 / 0")
        self._lbl_prog.setFixedWidth(60)
        run_row.addWidget(self._lbl_prog)
        lay.addLayout(run_row)

        # Results table
        self._table = QTableWidget(0, 5)
        self._table.setHorizontalHeaderLabels(
            ["File", "Peak (MHz)", "FWHM (Hz)", "Amplitude", "Status"]
        )
        hdr = self._table.horizontalHeader()
        hdr.setSectionResizeMode(self._COL_FILE, QHeaderView.Stretch)
        for c in (self._COL_PEAK, self._COL_FWHM, self._COL_AMP, self._COL_ST):
            hdr.setSectionResizeMode(c, QHeaderView.ResizeToContents)
        self._table.setEditTriggers(QTableWidget.NoEditTriggers)
        self._table.setSelectionBehavior(QTableWidget.SelectRows)
        self._table.setAlternatingRowColors(True)
        lay.addWidget(self._table, stretch=1)

        # Bottom buttons
        bot_row = QHBoxLayout()
        self._btn_csv = QPushButton(i18n.tr("btn_export_csv"))
        self._btn_csv.setEnabled(False)
        self._btn_csv.clicked.connect(self._export_csv)
        bot_row.addWidget(self._btn_csv)
        bot_row.addStretch()
        btn_close = QPushButton(i18n.tr("btn_close"))
        btn_close.clicked.connect(self.accept)
        bot_row.addWidget(btn_close)
        lay.addLayout(bot_row)

        # Status line
        self._status_lbl = QLabel(i18n.tr("batch_status_init"))
        lay.addWidget(self._status_lbl)

    def _build_batch_params(self) -> QWidget:
        w = QWidget()
        frm = QFormLayout(w)
        frm.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)

        def _dbl(lo, hi, suf, val, dec=1):
            sb = QDoubleSpinBox()
            sb.setRange(lo, hi)
            sb.setDecimals(dec)
            sb.setSuffix(f" {suf}")
            sb.setValue(val)
            return sb

        def _int(lo, hi, val, pfx=""):
            sb = QSpinBox()
            sb.setRange(lo, hi)
            sb.setValue(val)
            if pfx:
                sb.setPrefix(pfx)
            return sb

        self._b_fc  = _dbl(0.001, 100.0,    "MHz",  2.95,  dec=4)
        self._b_bw  = _dbl(1.0,   5000.0,   "kHz",  100.0)
        self._b_lp  = _dbl(1.0,   5000.0,   "kHz",  600.0)
        self._b_dec = _int(1,     1000,      10,     pfx="x")
        self._b_zp  = _int(0,     10_000_000, 500_001)
        self._b_ch  = _int(0,     15,        1)

        self._b_zp.setSingleStep(10_000)

        frm.addRow("Center freq:", self._b_fc)
        frm.addRow("BW:",          self._b_bw)
        frm.addRow("LPF:",         self._b_lp)
        frm.addRow("Decimation:",  self._b_dec)
        frm.addRow("Zero pad:",    self._b_zp)
        frm.addRow("Channel:",     self._b_ch)
        return w

    # -- Slots -------------------------------------------------------------

    def _on_use_current_toggled(self, checked: bool) -> None:
        self._params_widget.setVisible(not checked)

    def _browse_folder(self) -> None:
        p = QFileDialog.getExistingDirectory(self, "Select folder with JSON files")
        if p:
            self._le_folder.setText(p)

    def _get_params(self) -> dict:
        if self._cb_use_current.isChecked():
            return dict(self._current_params)
        return {
            "center_freq":   self._b_fc.value()  * 1e6,
            "bandpass_bw":   self._b_bw.value()  * 1e3,
            "lp_cutoff":     self._b_lp.value()  * 1e3,
            "dec_factor":    self._b_dec.value(),
            "zero_pad":      self._b_zp.value(),
            "butter_order":  4,
            "voltage_range": 5.0,
            "averaging_num": 0,
            "data_num":      0,
            "channel_num":   self._b_ch.value(),
        }

    def _run_batch(self) -> None:
        folder = self._le_folder.text().strip()
        if not folder:
            QMessageBox.warning(self, "Batch", "Please select a folder.")
            return
        if not os.path.isdir(folder):
            QMessageBox.warning(self, "Batch", f"Not a directory:\n{folder}")
            return

        self._table.setRowCount(0)
        self._btn_run.setEnabled(False)
        self._btn_csv.setEnabled(False)
        self._prog.setValue(0)
        self._lbl_prog.setText("0 / ?")
        self._status_lbl.setText("Submitting batch job...")

        params = self._get_params()
        self._submit_worker = OrchestratorWorker(
            self._client.submit_batch_from_params, folder, params
        )
        self._submit_worker.finished.connect(self._on_batch_submitted)
        self._submit_worker.error.connect(self._on_error)
        self._submit_worker.start()

    def _on_batch_submitted(self, batch_id: str) -> None:
        self._batch_id = batch_id
        self._status_lbl.setText(f"Batch accepted  (id: {batch_id})")
        self._timer.start()

    def _poll_batch(self) -> None:
        if self._poll_worker is not None and self._poll_worker.isRunning():
            return  # previous poll still in-flight - skip tick
        self._poll_worker = OrchestratorWorker(
            self._client.get_batch, self._batch_id
        )
        self._poll_worker.finished.connect(self._on_poll_result)
        self._poll_worker.error.connect(self._on_error)
        self._poll_worker.start()

    def _on_poll_result(self, data: dict) -> None:
        prog    = data.get("progress", {})
        done    = prog.get("done",  0)
        total   = prog.get("total", 0)
        status  = data.get("status",  "?")
        results = data.get("results", [])
        summary = data.get("summary", {})

        # Progress bar
        if total > 0:
            self._prog.setMaximum(total)
            self._prog.setValue(done)
        self._lbl_prog.setText(f"{done} / {total}")
        self._status_lbl.setText(f"Status: {status}  ({done}/{total})")

        # Rebuild table rows
        self._table.setRowCount(len(results))
        for row, r in enumerate(results):
            pf  = r.get("peak_freq_khz")
            fw  = r.get("fwhm_khz")
            pa  = r.get("peak_amplitude")
            st  = r.get("status", "?")

            self._table.setItem(
                row, self._COL_FILE,
                QTableWidgetItem(r.get("filename", "?")),
            )
            self._table.setItem(
                row, self._COL_PEAK,
                QTableWidgetItem(
                    f"{pf * _KHZ_TO_MHZ:.6f}" if pf is not None else "-"
                ),
            )
            self._table.setItem(
                row, self._COL_FWHM,
                QTableWidgetItem(f"{fw * 1e3:.2f}" if fw is not None else "-"),
            )
            self._table.setItem(
                row, self._COL_AMP,
                QTableWidgetItem(f"{pa:.4g}" if pa is not None else "-"),
            )
            it_st = QTableWidgetItem(st)
            if st == "error":
                it_st.setForeground(QColor("#e74c3c"))
                it_st.setToolTip(r.get("error") or "")
            else:
                it_st.setForeground(QColor("#2ecc71"))
            self._table.setItem(row, self._COL_ST, it_st)

        if status == "completed":
            self._timer.stop()
            self._btn_run.setEnabled(True)
            self._btn_csv.setEnabled(True)
            # Summary line
            n_ok  = summary.get("n_ok",  0)
            n_err = summary.get("n_error", 0)
            parts = [f"Done - {n_ok} ok, {n_err} error(s)"]
            mn = summary.get("mean_peak_freq_khz")
            sd = summary.get("std_peak_freq_khz")
            mw = summary.get("mean_fwhm_khz")
            if mn is not None:
                parts.append(
                    f"Peak: {mn * _KHZ_TO_MHZ:.6f} +/- {sd * _KHZ_TO_MHZ:.6f} MHz"
                )
            if mw is not None:
                parts.append(f"FWHM: {mw * 1e3:.1f} Hz")
            self._status_lbl.setText("  |  ".join(parts))

    def _on_error(self, msg: str) -> None:
        self._timer.stop()
        self._btn_run.setEnabled(True)
        self._status_lbl.setText(f"ERROR: {msg}")
        QMessageBox.critical(self, "Batch error", msg)

    def _export_csv(self) -> None:
        path, _ = QFileDialog.getSaveFileName(
            self, "Save batch results", "", "CSV files (*.csv)"
        )
        if not path:
            return
        try:
            rows = self._table.rowCount()
            cols = self._table.columnCount()
            headers = [
                self._table.horizontalHeaderItem(c).text() for c in range(cols)
            ]
            with open(path, "w", encoding="utf-8") as fh:
                fh.write(",".join(headers) + "\n")
                for r in range(rows):
                    vals = []
                    for c in range(cols):
                        it = self._table.item(r, c)
                        vals.append(it.text() if it else "")
                    fh.write(",".join(vals) + "\n")
            QMessageBox.information(self, "Export", f"Saved: {path}")
        except Exception as exc:
            QMessageBox.critical(self, "Export error", str(exc))

    def closeEvent(self, event) -> None:
        self._timer.stop()
        super().closeEvent(event)


# ===============================================================================
#  Export helpers
# ===============================================================================

def _export_mat(path: str, result: dict) -> None:
    from scipy.io import savemat
    mat: dict = {}
    for section, prefix in (
        ("raw_time",   "raw"),
        ("raw_spectrum", "raw_fft"),
        ("demod_time", "demod"),
        ("spectrum",   "spec"),
    ):
        for k, v in result.get(section, {}).items():
            mat[f"{prefix}_{k}"] = np.asarray(v, dtype=float)
    for k, v in result.get("metrics", {}).items():
        mat[k] = float(v) if v is not None else 0.0
    savemat(path, mat)


def _export_csv_single(path: str, result: dict) -> None:
    sp  = result.get("spectrum", {})
    met = result.get("metrics",  {})
    freq = sp.get("freq_khz",  [])
    mag  = sp.get("magnitude", [])
    with open(path, "w", encoding="utf-8") as fh:
        for k, v in met.items():
            fh.write(f"# {k}: {v}\n")
        fh.write("freq_khz,magnitude\n")
        for f, m in zip(freq, mag):
            fh.write(f"{f},{m}\n")


def _export_npz(path: str, result: dict) -> None:
    arrays: dict = {}
    for section, pfx in (
        ("raw_time",   "raw"),
        ("raw_spectrum", "raw_fft"),
        ("demod_time", "dem"),
        ("spectrum",   "sp"),
    ):
        for k, v in result.get(section, {}).items():
            arrays[f"{pfx}_{k}"] = np.asarray(v, dtype=float)
    np.savez(path, **arrays)


# ===============================================================================
#  Misc helpers
# ===============================================================================

def _vsep() -> QFrame:
    f = QFrame()
    f.setFrameShape(QFrame.VLine)
    f.setFrameShadow(QFrame.Sunken)
    f.setFixedWidth(2)
    return f
