// RxAudioProcessingWidget — RX audio noise reduction settings dialog.
//
// All controls are built programmatically (no .ui file) for easy future
// conversion to QML:
//   QGroupBox   → QML GroupBox
//   QFormLayout → QML GridLayout / ColumnLayout
//   QSlider     → QML Slider
//   QComboBox   → QML ComboBox
//   QCheckBox   → QML CheckBox
//   QRadioButton→ QML RadioButton
//   QLabel      → QML Text / Label

#include "rxaudioprocessingwidget.h"
#include "collapsiblesection.h"
#include "logcategories.h"
#include "speexnrprocessor.h"   // SpeexNrProcessor::presetCount() / bandsForPreset()
#include <cmath>
#include <QScrollArea>
#include <QSizePolicy>

// ─────────────────────────────────────────────────────────────────────────────

RxAudioProcessingWidget::RxAudioProcessingWidget(QWidget* parent)
    : QWidget(nullptr, Qt::Window)
{
    Q_UNUSED(parent);  // intentionally ignored — this is an independent top-level
                       // window; passing a parent would set WM_TRANSIENT_FOR on X11
                       // and force the window to stay above the main window.
    setWindowTitle(tr("RX Audio Processing"));
    buildUi();
    connect(&m_specDiagTimer, &QTimer::timeout,
            this, &RxAudioProcessingWidget::onSpecDiagTimer);
    m_specDiagTimer.setInterval(1000);
    m_specDiagTimer.start();
}

// ─── setPrefs / getPrefs ─────────────────────────────────────────────────────

void RxAudioProcessingWidget::setPrefs(const rxAudioProcessingPrefs& p)
{
    blockAll(true);
    populateFromPrefs(p);
    blockAll(false);
}

rxAudioProcessingPrefs RxAudioProcessingWidget::getPrefs() const
{
    rxAudioProcessingPrefs p;

    p.bypass        = bypassCheck->isChecked();
    // Stereo combo: 0=Left(1), 1=Right(2), 2=Mono sum(3); mono: always 3
    if (m_audioChannels > 1)
        p.channelSelect = channelCombo->currentIndex() + 1;  // 0→1, 1→2, 2→3
    else
        p.channelSelect = 3;
    const int algoId = algoGroup->checkedId();
    p.nrMode = static_cast<RxNrMode>(algoId);  // 0=None, 1=Speex, 2=ANR
    // nrEnabled follows master bypass and NR mode selection
    p.nrEnabled     = !p.bypass && (p.nrMode != RxNrMode::None);

    // Speex
    p.speexSuppression   = speexSuppress->value();          // already negative dB
    {
        int idx = speexBandsCombo->currentIndex();
        // Map combo index back to preset index (combo item data stores preset index)
        p.speexBandsPreset = speexBandsCombo->itemData(idx).toInt();
    }
    p.speexFrameMs       = (speexFrameCombo->currentIndex() == 0) ? 10 : 20;
    p.speexAgc           = speexAgcCheck->isChecked();
    p.speexAgcLevel      = static_cast<float>(speexAgcLevel->value());
    p.speexAgcMaxGain    = speexAgcMaxGain->value();
    p.speexVad           = speexVadCheck->isChecked();
    p.speexVadProbStart  = speexVadProbStart->value();
    p.speexVadProbCont   = speexVadProbCont->value();
    p.speexSnrDecay        = speexSnrDecay->value()    * 0.01f;
    p.speexNoiseUpdateRate = speexNoiseUpdate->value()  * 0.01f;
    p.speexPriorBase       = speexPriorBase->value()   * 0.01f;

    // ANR
    p.anrNoiseReductionDb = anrNoiseRedSlider->value();
    p.anrSensitivity      = anrSensSlider->value() * 0.1;
    p.anrFreqSmoothing    = anrSmoothSlider->value();

    // EQ
    p.eqEnabled = eqEnableCheck->isChecked();
    for (int i = 0; i < RX_EQ_BANDS; ++i) {
        p.eqGain[i] = eqGainSlider[i]->value() * 0.1f;  // slider int → dB
        p.eqFreq[i] = static_cast<float>(eqFreqDial[i]->value());
        p.eqQ[i]    = 1.0f;  // Q stored in prefs but not exposed in UI
    }

    // Output gain
    p.outputGainDB   = outputGain->value() * 0.1f;

    // Spectrum
    p.spectrumEnabled       = specGrp->isExpanded();
    p.spectrumFPS           = m_spectrumFps;
    p.specInhibitDuringTx   = specInhibitDuringTx->isChecked();

    return p;
}

void RxAudioProcessingWidget::setAntiAlias(bool on)
{
    if (specWidget) {
        specWidget->antiAlias = on;
        specWidget->update();
    }
    if (anrProfileSpec) {
        anrProfileSpec->antiAlias = on;
        anrProfileSpec->update();
    }
}

// ─── setConnected / setAudioChannels ─────────────────────────────────────────

void RxAudioProcessingWidget::setConnected(bool connected)
{
    m_radioConnected = connected;
    controlsContainer->setEnabled(connected);
    anrCollectBtn->setEnabled(connected);
    if (!connected) {
        if (m_anrCollecting) {
            anrCollectTimer->stop();
            m_anrCollecting = false;
        }
        anrCollectBtn->setText(m_anrHasProfile ? tr("Collect New Noise Sample")
                                               : tr("Collect Noise Sample"));
    }
}

void RxAudioProcessingWidget::setAudioChannels(int ch)
{
    m_audioChannels = ch;
    channelCombo->blockSignals(true);
    channelCombo->clear();
    if (ch > 1) {
        channelCombo->addItem(tr("Left"));        // index 0 → channelSelect 1
        channelCombo->addItem(tr("Right"));       // index 1 → channelSelect 2
        channelCombo->addItem(tr("Mono (sum)"));  // index 2 → channelSelect 3
        channelCombo->setCurrentIndex(2);         // default to mono sum
        channelCombo->setEnabled(!bypassCheck->isChecked());
    } else {
        channelCombo->addItem(tr("Mono"));
        channelCombo->setEnabled(false);
    }
    channelCombo->blockSignals(false);
    emit prefsChanged(getPrefs());
}

// ─── Any control changed ─────────────────────────────────────────────────────

void RxAudioProcessingWidget::onAnyControlChanged()
{
    // Update value labels
    lblSpeexSuppress->setText(QString::number(speexSuppress->value()) + " dB");
    lblAgcLevel->setText(QString::number(speexAgcLevel->value()));
    lblAgcMaxGain->setText(QString::number(speexAgcMaxGain->value()) + " dB");
    lblOutputGain->setText(QString::number(outputGain->value() * 0.1f, 'f', 1) + " dB");
    lblAnrNoiseRed->setText(QString::number(anrNoiseRedSlider->value()) + " dB");
    lblAnrSens->setText(QString::number(anrSensSlider->value() * 0.1, 'f', 1));
    lblAnrSmooth->setText(QString::number(anrSmoothSlider->value()));

    // Value labels
    lblVadProbStart->setText(QString::number(speexVadProbStart->value()) + " %");
    lblVadProbCont->setText(QString::number(speexVadProbCont->value())   + " %");
    lblSnrDecay->setText(QString::number(speexSnrDecay->value()   * 0.01, 'f', 2));
    lblNoiseUpdate->setText(QString::number(speexNoiseUpdate->value() * 0.01, 'f', 2));
    lblPriorBase->setText(QString::number(speexPriorBase->value()  * 0.01, 'f', 2));

    // EQ gain/freq labels
    for (int i = 0; i < RX_EQ_BANDS; ++i) {
        eqGainLabel[i]->setText(QString::number(eqGainSlider[i]->value() * 0.1f, 'f', 1) + " dB");
        int freq = eqFreqDial[i]->value();
        if (freq >= 1000)
            eqFreqLabel[i]->setText(QString::number(freq * 0.001, 'f', 2) + " kHz");
        else
            eqFreqLabel[i]->setText(QString::number(freq) + " Hz");
    }
    // EQ controls visible when checkbox is ticked
    {
        bool eqVis = eqEnableCheck->isChecked();
        if (eqVis != eqBandsWidget->isVisible()) {
            eqBandsWidget->setVisible(eqVis);
            updateSizeConstraints();
        }
    }

    // AGC controls visible only when checkbox is ticked
    bool agcVis = speexAgcCheck->isChecked();
    speexAgcLevel->setVisible(agcVis);   lblAgcLevel->setVisible(agcVis);
    speexAgcMaxGain->setVisible(agcVis); lblAgcMaxGain->setVisible(agcVis);

    // VAD prob sliders visible only when VAD is ticked
    bool vadVis = speexVadCheck->isChecked();
    speexVadProbStart->setVisible(vadVis); lblVadProbStart->setVisible(vadVis);
    speexVadProbCont->setVisible(vadVis);  lblVadProbCont->setVisible(vadVis);

    emit prefsChanged(getPrefs());
}

void RxAudioProcessingWidget::onBypassToggled(bool bypassed)
{
    setProcessingControlsEnabled(!bypassed);
    channelCombo->setEnabled(!bypassed && m_audioChannels > 1);
    emit prefsChanged(getPrefs());
}

void RxAudioProcessingWidget::onNrModeChanged(int id)
{
    // 0 = None, 1 = Speex, 2 = ANR — show/hide directly so layout adapts
    nonePage->setVisible(id == 0);
    speexGrp->setVisible(id == 1);
    anrGrp->setVisible(id == 2);
    updateSizeConstraints();
    emit prefsChanged(getPrefs());
}

void RxAudioProcessingWidget::onAlgorithmGroupToggled(bool)
{
    onNrModeChanged(algoGroup->checkedId());
}

// ─── setProcessingControlsEnabled ────────────────────────────────────────────

void RxAudioProcessingWidget::setProcessingControlsEnabled(bool enabled)
{
    algoGrp->setEnabled(enabled);
    speexGrp->setContentEnabled(enabled);
    eqGrp->setContentEnabled(enabled);
    gainGrp->setEnabled(enabled);
    // ANR group: outer box enabled by bypass, but inner controls gated by profile
    anrGrp->setContentEnabled(enabled);
    if (enabled)
        updateAnrControlState();
    // channelCombo is gated by bypass AND channel count in onBypassToggled()
}

// ─── buildUi ─────────────────────────────────────────────────────────────────

void RxAudioProcessingWidget::buildUi()
{
    // Outer scroll area so the dialog doesn't exceed screen height
    auto* scroll = new QScrollArea(this);
    scroll->setWidgetResizable(true);
    scroll->setFrameShape(QFrame::NoFrame);

    auto* inner = new QWidget;
    auto* mainLayout = new QVBoxLayout(inner);
    mainLayout->setSpacing(6);

    controlsContainer = inner;

    // Helper: slider + label row, returns the layout and label for later access
    auto makeSliderRow = [](QSlider*& sl, QLabel*& lbl, int lo, int hi, int val,
                            int labelWidth = 70) -> QHBoxLayout* {
        sl  = new QSlider(Qt::Horizontal);
        sl->setRange(lo, hi);
        sl->setValue(val);
        lbl = new QLabel;
        lbl->setMinimumWidth(labelWidth);
        lbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
        auto* row = new QHBoxLayout;
        row->addWidget(sl, 1);
        row->addWidget(lbl);
        return row;
    };

    // ── Master Bypass + Channel select ─────────────────────────────────────────
    {
        auto* topRow = new QHBoxLayout;
        bypassCheck = new QCheckBox(tr("Master Bypass (pass audio unchanged)"));
        QFont f = bypassCheck->font();
        f.setBold(true);
        bypassCheck->setFont(f);
        topRow->addWidget(bypassCheck);

        channelCombo = new QComboBox;
        channelCombo->addItem(tr("Mono"));           // placeholder for 1-ch
        channelCombo->setEnabled(false);
        channelCombo->setToolTip(tr(
            "Select which channel to process when the radio provides stereo audio.\n\n"
            "Left \u2014 Process the left channel only; right passes through unchanged.\n"
            "Right \u2014 Process the right channel only; left passes through unchanged.\n"
            "Mono (sum) \u2014 Mix both channels together before processing."));
        topRow->addWidget(channelCombo);

        mainLayout->addLayout(topRow);
        connect(bypassCheck, &QCheckBox::toggled,
                this, &RxAudioProcessingWidget::onBypassToggled);
        connect(channelCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
                this, &RxAudioProcessingWidget::onAnyControlChanged);
    }

    // ── Debug capture button (hidden unless --debug) ─────────────────────────
    {
        debugCaptureBtn = new QPushButton(tr("Capture RX Audio (5 s)"));
        debugCaptureBtn->setVisible(false);  // shown only via setDebugMode(true)
        mainLayout->addWidget(debugCaptureBtn);
        connect(debugCaptureBtn, &QPushButton::clicked,
                this, &RxAudioProcessingWidget::onDebugCaptureClicked);

        debugCaptureTimer = new QTimer(this);
        debugCaptureTimer->setInterval(1000);
        connect(debugCaptureTimer, &QTimer::timeout,
                this, &RxAudioProcessingWidget::onDebugCaptureCountdown);
    }

    // ── Algorithm selector ────────────────────────────────────────────────────
    {
        algoGrp   = new QGroupBox(tr("Noise Reduction Algorithm"));
        auto* row = new QHBoxLayout(algoGrp);
        algoNone  = new QRadioButton(tr("None"));
        algoSpeex = new QRadioButton(tr("Speex"));
        algoAnr   = new QRadioButton(tr("ANR"));
        algoNone->setChecked(true);
        algoGroup = new QButtonGroup(this);
        algoGroup->addButton(algoNone,  0);
        algoGroup->addButton(algoSpeex, 1);
        algoGroup->addButton(algoAnr,   2);
        algoNone->setToolTip(tr(
            "No noise reduction — audio passes through to the EQ and output gain only."));
        algoSpeex->setToolTip(tr(
            "Speex preprocessor: statistical noise estimation using a minimum mean-square\n"
            "error estimator using Ephraim-Malah spectral subtraction\n"
            "Works on all audio types.  Latency = one frame (~20 ms)."));
        algoAnr->setToolTip(tr(
            "ANR: Audacity Noise Reduction — requires a noise sample to be collected first.\n"
            "Analyses the noise spectrum and suppresses matching frequencies.\n"
            "Works on broadband noise.  Latency ≈ 40–80 ms depending on sample rate."));
        row->addWidget(algoNone);
        row->addWidget(algoSpeex);
        row->addWidget(algoAnr);
        row->addStretch();
        mainLayout->addWidget(algoGrp);
        connect(algoNone,  &QRadioButton::toggled, this, &RxAudioProcessingWidget::onAlgorithmGroupToggled);
        connect(algoSpeex, &QRadioButton::toggled, this, &RxAudioProcessingWidget::onAlgorithmGroupToggled);
        connect(algoAnr,   &QRadioButton::toggled, this, &RxAudioProcessingWidget::onAlgorithmGroupToggled);
    }

    // ── Algorithm-specific groups (shown/hidden based on selection) ─────────
    // ── "None" placeholder ───────────────────────────────────────────────────
    {
        nonePage = new QWidget;
        auto* noneLayout = new QVBoxLayout(nonePage);
        noneLayout->setContentsMargins(0, 0, 0, 0);
        auto* noneLabel = new QLabel(tr("No noise reduction selected.\n"
            "Audio passes through to the equalizer and output gain."));
        noneLabel->setWordWrap(true);
        QFont f = noneLabel->font();
        f.setItalic(true);
        noneLabel->setFont(f);
        noneLayout->addWidget(noneLabel);
        mainLayout->addWidget(nonePage);  // visible by default (None selected)
    }

    // ── Speex controls — stack page 1 ─────────────────────────────────────────
    {
        auto* speexBody = new QWidget;
        auto* form = new QFormLayout(speexBody);

        // Suppression
        {
            auto* row = makeSliderRow(speexSuppress, lblSpeexSuppress, -70, -1, -30, 80);
            speexSuppress->setAccessibleName(tr("Speex suppression"));
            speexSuppress->setToolTip(tr("Maximum noise attenuation in dB (e.g. -30 dB).  "
                                         "More negative = stronger suppression, more artefacts."));
            lblSpeexSuppress->setText("-30 dB");
            form->addRow(tr("Suppression:"), row);
        }

        // Band preset
        {
            speexBandsCombo = new QComboBox;
            const int nPresets = SpeexNrProcessor::presetCount();
            for (int i = 0; i < nPresets; ++i) {
                int bands = SpeexNrProcessor::bandsForPreset(i);
                speexBandsCombo->addItem(tr("%1 bands").arg(bands), QVariant(i));
            }
            speexBandsCombo->setToolTip(tr(
                "Number of Bark-scale frequency bands.\n"
                "More bands → finer spectral resolution, slower noise adaptation.\n"
                "Fewer bands → coarser but faster (good for rapid noise-floor changes)."));
            form->addRow(tr("Bark bands:"), speexBandsCombo);
            connect(speexBandsCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
                    this, &RxAudioProcessingWidget::onAnyControlChanged);
        }

        // Frame size
        {
            speexFrameCombo = new QComboBox;
            speexFrameCombo->addItem(tr("10 ms frames"));
            speexFrameCombo->addItem(tr("20 ms frames"));
            speexFrameCombo->setCurrentIndex(1);  // default 20 ms
            speexFrameCombo->setToolTip(tr(
                "Speex processing frame length.\n"
                "10 ms: lower latency, coarser frequency resolution.\n"
                "20 ms: higher latency, better frequency resolution (recommended)."));
            form->addRow(tr("Frame size:"), speexFrameCombo);
            connect(speexFrameCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
                    this, &RxAudioProcessingWidget::onAnyControlChanged);
        }

        // AGC
        {
            speexAgcCheck = new QCheckBox(tr("Enable automatic gain control (AGC)"));
            speexAgcCheck->setToolTip(tr(
                "Speex AGC normalises the signal level to a target RMS.\n"
                "Useful when the radio's AF gain drifts or signals vary widely."));
            form->addRow(speexAgcCheck);

            // AGC level: 1000–32000 (RMS target; 8000 is Speex default)
            speexAgcLevel = new QSlider(Qt::Horizontal);
            speexAgcLevel->setRange(500, 32000);
            speexAgcLevel->setValue(8000);
            speexAgcLevel->setAccessibleName(tr("AGC target level"));
            speexAgcLevel->setToolTip(tr("Target RMS level for AGC (Speex default: 8000)."));
            lblAgcLevel = new QLabel("8000");
            lblAgcLevel->setMinimumWidth(60);
            {
                auto* row = new QHBoxLayout;
                row->addWidget(speexAgcLevel, 1);
                row->addWidget(lblAgcLevel);
                form->addRow(tr("  AGC level:"), row);
            }

            auto* rowMG = makeSliderRow(speexAgcMaxGain, lblAgcMaxGain, 0, 60, 30, 60);
            speexAgcMaxGain->setAccessibleName(tr("AGC maximum gain"));
            speexAgcMaxGain->setToolTip(tr("Maximum gain the AGC may apply (dB)."));
            lblAgcMaxGain->setText("30 dB");
            form->addRow(tr("  AGC max gain:"), rowMG);
        }

        // VAD
        {
            speexVadCheck = new QCheckBox(tr("Enable voice activity detection (VAD)"));
            speexVadCheck->setToolTip(tr(
                "Speex VAD classifies each frame as speech or non-speech.\n"
                "When active, non-speech frames are suppressed more aggressively.\n"
                "Adjust prob-start and prob-continue to control sensitivity."));
            form->addRow(speexVadCheck);

            auto* rowPS = makeSliderRow(speexVadProbStart, lblVadProbStart, 0, 100, 85, 60);
            speexVadProbStart->setAccessibleName(tr("VAD probability to start"));
            speexVadProbStart->setToolTip(tr(
                "Probability required to switch from silence to voice state (%).\n"
                "Higher = harder to trigger voice detection."));
            lblVadProbStart->setText("85 %");
            form->addRow(tr("  Prob-start:"), rowPS);

            auto* rowPC = makeSliderRow(speexVadProbCont, lblVadProbCont, 0, 100, 65, 60);
            speexVadProbCont->setAccessibleName(tr("VAD probability to continue"));
            speexVadProbCont->setToolTip(tr(
                "Probability required to remain in voice state (%).\n"
                "Lower = voice state is held longer; higher = drops out faster."));
            lblVadProbCont->setText("65 %");
            form->addRow(tr("  Prob-cont:"), rowPC);
        }

        // ── Adaptation speed controls ──
        {
            auto* sepLabel = new QLabel(tr("Adaptation Speed"));
            QFont sf = sepLabel->font();
            sf.setBold(true);
            sepLabel->setFont(sf);
            form->addRow(sepLabel);

            // SNR Decay: 0..95 → 0.00..0.95
            auto* rowSD = makeSliderRow(speexSnrDecay, lblSnrDecay, 0, 95, 70, 60);
            speexSnrDecay->setAccessibleName(tr("SNR decay"));
            speexSnrDecay->setToolTip(tr(
                "SNR smoothing decay (0.00–0.95, default 0.70).\n"
                "Controls how long the algorithm \"remembers\" speech was present.\n"
                "Lower = faster recovery (static suppressed sooner after speech).\n"
                "Higher = smoother but slower adaptation.\n"
                "Recommended: 0.30–0.60 for faster response."));
            lblSnrDecay->setText("0.70");
            form->addRow(tr("  SNR decay:"), rowSD);

            // Noise update rate: 1..50 → 0.01..0.50
            auto* rowNU = makeSliderRow(speexNoiseUpdate, lblNoiseUpdate, 1, 50, 3, 60);
            speexNoiseUpdate->setAccessibleName(tr("Noise update rate"));
            speexNoiseUpdate->setToolTip(tr(
                "Noise floor update rate (0.01–0.50, default 0.03).\n"
                "How fast the noise floor estimate adapts.\n"
                "Higher = noise estimate catches up faster after speech.\n"
                "Too high may cause the algorithm to treat speech as noise."));
            lblNoiseUpdate->setText("0.03");
            form->addRow(tr("  Noise update:"), rowNU);

            // Prior base: 5..50 → 0.05..0.50
            auto* rowPB = makeSliderRow(speexPriorBase, lblPriorBase, 5, 50, 10, 60);
            speexPriorBase->setAccessibleName(tr("Prior base weight"));
            speexPriorBase->setToolTip(tr(
                "Prior SNR base weight (0.05–0.50, default 0.10).\n"
                "Minimum weight given to the current frame's observation.\n"
                "Higher = less inertia from previous frames (faster tracking).\n"
                "Too high may cause instability or musical noise."));
            lblPriorBase->setText("0.10");
            form->addRow(tr("  Prior base:"), rowPB);
        }

        speexGrp = new CollapsibleSection(tr("Speex Noise Suppressor"));
        speexGrp->setBodyWidget(speexBody);
        speexGrp->setVisible(false);  // hidden by default (None selected)
        mainLayout->addWidget(speexGrp);
        connect(speexGrp, &CollapsibleSection::expandedChanged, this,
                [this](bool) { updateSizeConstraints(); });
        connect(speexSuppress,     &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexAgcCheck,     &QCheckBox::toggled,    this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexAgcLevel,     &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexAgcMaxGain,   &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexVadCheck,     &QCheckBox::toggled,    this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexVadProbStart, &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexVadProbCont,  &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexSnrDecay,     &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexNoiseUpdate,  &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(speexPriorBase,    &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
    }

    // ── ANR controls — stack page 2 ───────────────────────────────────────────
    {
        auto* anrBody = new QWidget;
        auto* form = new QFormLayout(anrBody);

        // Noise reduction
        {
            auto* row = makeSliderRow(anrNoiseRedSlider, lblAnrNoiseRed, 0, 48, 12, 70);
            anrNoiseRedSlider->setAccessibleName(tr("ANR noise reduction"));
            anrNoiseRedSlider->setToolTip(tr(
                "Amount of noise suppression in dB.\n"
                "Higher values reduce more noise but may introduce artefacts.\n"
                "Typical range: 6–24 dB."));
            lblAnrNoiseRed->setText("12 dB");
            form->addRow(tr("Noise reduction:"), row);
        }

        // Sensitivity
        {
            // Stored as 0–100 integer (×0.1 → 0.0–10.0)
            auto* row = makeSliderRow(anrSensSlider, lblAnrSens, 0, 100, 11, 70);
            anrSensSlider->setAccessibleName(tr("ANR sensitivity"));
            anrSensSlider->setToolTip(tr(
                "Sensitivity: −log₁₀ of the probability that noise exceeds the threshold.\n"
                "Higher = less aggressive (fewer false positives, may miss noise).\n"
                "Lower  = more aggressive (may suppress signal as well as noise).\n"
                "Strategy 1: Tune to static and lower until you hear chimes, then raise slightly.\n"
                "Strategy 2: Tune to a signal and start low. Raise until the signal is nominally loud (watch the RxAudio meter)\n"
                "Default 1.1 is a good starting point."));
            lblAnrSens->setText("1.1");   // slider 11 × 0.1
            form->addRow(tr("Sensitivity:"), row);
        }

        // Frequency smoothing
        {
            auto* row = makeSliderRow(anrSmoothSlider, lblAnrSmooth, 0, 6, 0, 70);
            anrSmoothSlider->setAccessibleName(tr("ANR frequency smoothing"));
            anrSmoothSlider->setToolTip(tr(
                "Frequency smoothing (bands).\n"
                "Averages gain across neighbouring frequency bins to reduce musical noise.\n"
                "0 = off (sharper but may produce tonal artefacts); 3–6 = smooth."));
            lblAnrSmooth->setText("0");
            form->addRow(tr("Freq smoothing:"), row);
        }

        // Noise sample collection
        {
            anrCollectBtn = new QPushButton(tr("Collect Noise Sample"));
            anrCollectBtn->setToolTip(tr(
                "Click to start collecting a noise sample.\n"
                "Play audio containing only background noise, then click Stop.\n"
                "Collection stops automatically after 5 seconds.\n"
                "ANR will be disabled until a valid noise sample is collected."));
            anrCollectBtn->setEnabled(false); // enabled when radio connects

            lblAnrStatus = new QLabel(tr("No noise sample — collect one before using ANR."));
            lblAnrStatus->setWordWrap(true);
            QFont f = lblAnrStatus->font();
            f.setItalic(true);
            lblAnrStatus->setFont(f);

            form->addRow(anrCollectBtn);
            form->addRow(lblAnrStatus);

            // Static noise profile spectrum display
            anrProfileSpec = new SpectrumWidget;
            anrProfileSpec->logBins       = false;
            anrProfileSpec->showSecondary = false;
            anrProfileSpec->primaryLabel  = tr("Noise Floor");
            anrProfileSpec->setMinimumHeight(144);
            anrProfileSpec->setMaximumHeight(144);
            anrProfileSpec->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
            anrProfileSpec->setStaticMode();  // no timer; repaint only when data arrives
            anrProfileSpec->setVisible(false);  // shown after profile is collected
            anrProfileSpec->setAccessibleName(tr("Noise profile spectrum display"));
            form->addRow(anrProfileSpec);

            // 1-second tick timer for countdown during collection
            anrCollectTimer = new QTimer(this);
            anrCollectTimer->setInterval(1000);
            connect(anrCollectTimer, &QTimer::timeout,
                    this, &RxAudioProcessingWidget::onAnrCollectTimeout);
            connect(anrCollectBtn, &QPushButton::clicked,
                    this, &RxAudioProcessingWidget::onAnrCollectClicked);
        }

        anrGrp = new CollapsibleSection(tr("ANR — Audacity Noise Reduction"));
        anrGrp->setBodyWidget(anrBody);
        anrGrp->setVisible(false);  // hidden by default (None selected)
        mainLayout->addWidget(anrGrp);
        connect(anrGrp, &CollapsibleSection::expandedChanged, this,
                [this](bool) { updateSizeConstraints(); });

        connect(anrNoiseRedSlider, &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(anrSensSlider,     &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
        connect(anrSmoothSlider,   &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
    }

    // ── RX Equalizer ────────────────────────────────────────────────────────
    {
        // Header: Enable + Clear (always visible, even when section is collapsed)
        auto* eqHeader = new QWidget;
        {
            auto* hl = new QHBoxLayout(eqHeader);
            hl->setContentsMargins(0, 0, 0, 0);
            hl->setSpacing(6);
            eqEnableCheck = new QCheckBox(tr("Enable EQ"));
            eqEnableCheck->setToolTip(tr("Enable the 4-band receive equalizer."));
            eqClearBtn = new QPushButton(tr("Clear"));
            eqClearBtn->setToolTip(tr("Reset all EQ band gains to 0 dB (flat)."));
            hl->addWidget(eqEnableCheck);
            hl->addWidget(eqClearBtn);
        }

        auto* eqBodyOuter = new QWidget;
        auto* eqOuter = new QVBoxLayout(eqBodyOuter);
        eqOuter->setContentsMargins(0, 0, 0, 0);

        // Container for band controls (hidden when EQ is disabled)
        eqBandsWidget = new QWidget;
        auto* eqBandsLayout = new QVBoxLayout(eqBandsWidget);
        eqBandsLayout->setContentsMargins(0, 0, 0, 0);

        // Band names, frequency ranges, and dial ranges
        struct BandDef {
            const char* name;
            int freqMin, freqMax, freqDefault;
        };
        static const BandDef bandDefs[RX_EQ_BANDS] = {
            { "Low Shelf",    50,  250,  100 },
            { "Low Mid",     300, 1500,  800 },
            { "High Mid",   1000, 3000, 1200 },
            { "High Shelf", 1200, 4500, 2200 },
        };

        // Band columns: title, gain label, vertical slider, freq dial, freq label
        auto* bandsRow = new QHBoxLayout;
        bandsRow->setSpacing(2);
        for (int i = 0; i < RX_EQ_BANDS; ++i) {
            auto* col = new QVBoxLayout;
            col->setAlignment(Qt::AlignHCenter);

            // Band title
            eqBandTitle[i] = new QLabel(tr(bandDefs[i].name));
            eqBandTitle[i]->setAlignment(Qt::AlignCenter);
            QFont bf = eqBandTitle[i]->font();
            bf.setBold(true);
            eqBandTitle[i]->setFont(bf);
            col->addWidget(eqBandTitle[i]);

            // Gain label (on top of slider)
            eqGainLabel[i] = new QLabel("0.0 dB");
            eqGainLabel[i]->setAlignment(Qt::AlignCenter);
            eqGainLabel[i]->setMinimumWidth(50);
            col->addWidget(eqGainLabel[i]);

            // Vertical gain slider: -90..+90 (×0.1 → -9..+9 dB)
            eqGainSlider[i] = new QSlider(Qt::Vertical);
            eqGainSlider[i]->setRange(-90, 90);
            eqGainSlider[i]->setValue(0);
            eqGainSlider[i]->setTickPosition(QSlider::TicksBothSides);
            eqGainSlider[i]->setTickInterval(10);
            eqGainSlider[i]->setMinimumHeight(100);
            eqGainSlider[i]->setAccessibleName(tr("%1 gain").arg(tr(bandDefs[i].name)));
            eqGainSlider[i]->setToolTip(tr("%1: adjust gain from -9 to +9 dB").arg(bandDefs[i].name));
            col->addWidget(eqGainSlider[i], 1, Qt::AlignHCenter);

            // Frequency dial (knob)
            eqFreqDial[i] = new QDial;
            eqFreqDial[i]->setRange(bandDefs[i].freqMin, bandDefs[i].freqMax);
            eqFreqDial[i]->setValue(bandDefs[i].freqDefault);
            eqFreqDial[i]->setSingleStep((bandDefs[i].freqMax-bandDefs[i].freqMin) / 100);
            eqFreqDial[i]->setWrapping(false);
            eqFreqDial[i]->setNotchesVisible(false);
            eqFreqDial[i]->setFixedSize(35, 35);
            eqFreqDial[i]->setAccessibleName(tr("%1 frequency").arg(tr(bandDefs[i].name)));
            eqFreqDial[i]->setToolTip(tr("%1: adjust frequency for this band (%2–%3 Hz)")
                .arg(bandDefs[i].name)
                .arg(bandDefs[i].freqMin)
                .arg(bandDefs[i].freqMax));
            col->addWidget(eqFreqDial[i], 0, Qt::AlignHCenter);

            // Frequency label (below dial)
            int defFreq = bandDefs[i].freqDefault;
            if (defFreq >= 1000)
                eqFreqLabel[i] = new QLabel(QString::number(defFreq * 0.001, 'f', 2) + " kHz");
            else
                eqFreqLabel[i] = new QLabel(QString::number(defFreq) + " Hz");
            eqFreqLabel[i]->setAlignment(Qt::AlignCenter);
            eqFreqLabel[i]->setMinimumWidth(50);
            col->addWidget(eqFreqLabel[i]);

            bandsRow->addLayout(col);
        }
        eqBandsLayout->addLayout(bandsRow);
        eqBandsWidget->setVisible(false);  // hidden by default (EQ disabled)
        eqOuter->addWidget(eqBandsWidget);

        eqGrp = new CollapsibleSection(tr("Receive Equalizer"), eqHeader);
        eqGrp->setBodyWidget(eqBodyOuter);
        mainLayout->addWidget(eqGrp);
        connect(eqGrp, &CollapsibleSection::expandedChanged, this,
                [this](bool) { updateSizeConstraints(); });

        // Connect signals
        connect(eqEnableCheck, &QCheckBox::toggled,
                this, &RxAudioProcessingWidget::onAnyControlChanged);
        for (int i = 0; i < RX_EQ_BANDS; ++i) {
            connect(eqGainSlider[i], &QSlider::valueChanged,
                    this, &RxAudioProcessingWidget::onAnyControlChanged);
            connect(eqFreqDial[i], &QDial::valueChanged,
                    this, &RxAudioProcessingWidget::onAnyControlChanged);
        }
        connect(eqClearBtn, &QPushButton::clicked, this, [this]() {
            for (int i = 0; i < RX_EQ_BANDS; ++i)
                eqGainSlider[i]->setValue(0);
            // Slider valueChanged signals will trigger onAnyControlChanged
        });
    }

    // ── Output gain ───────────────────────────────────────────────────────────
    {
        gainGrp = new QGroupBox(tr("Output Gain"));
        auto* form = new QFormLayout(gainGrp);
        // -60..200 maps to -6..+20 dB at ×0.1 resolution
        auto* row = makeSliderRow(outputGain, lblOutputGain, -60, 200, 0, 80);
        outputGain->setTickPosition(QSlider::TicksBelow);
        outputGain->setTickInterval(50);
        outputGain->setAccessibleName(tr("Output gain"));
        outputGain->setToolTip(tr(
            "Post-NR output gain adjustment (-6 dB to +20 dB).\n"
            "Use to compensate for any level change caused by noise reduction."));
        lblOutputGain->setText("0.0 dB");
        form->addRow(tr("Output gain:"), row);
        mainLayout->addWidget(gainGrp);
        connect(outputGain, &QSlider::valueChanged, this, &RxAudioProcessingWidget::onAnyControlChanged);
    }

    // ── RX Spectrum ───────────────────────────────────────────────────────────
    {
        specInhibitDuringTx = new QCheckBox(tr("Inhibit during TX"));
        specInhibitDuringTx->setToolTip(tr(
            "Pause the RX spectrum display while the radio is transmitting."));
        specInhibitDuringTx->setChecked(true);

        auto* specBody = new QWidget;
        auto* vbox = new QVBoxLayout(specBody);
        vbox->setContentsMargins(0, 0, 0, 0);

        specWidget = new SpectrumWidget;
        specWidget->logBins    = true;
        specWidget->fftLength  = RxAudioProcessor::SPEC_FFT_LEN;
        specWidget->sampleRate = 8000.0;
        specWidget->showSecondary = true;
        specWidget->setMinimumHeight(120);
        specWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
        specWidget->setToolTip(tr("RX audio spectrum (input: green = radio signal before NR, "
                                  "output: orange = after noise reduction and gain)."));
        specWidget->setAccessibleName(tr("RX audio spectrum display"));
        vbox->addWidget(specWidget, 1);

        specGrp = new CollapsibleSection(tr("RX Spectrum"), specInhibitDuringTx);
        specGrp->setBodyWidget(specBody);
        specGrp->setExpanded(false);  // collapsed by default (spectrum off)
        specInhibitDuringTx->setEnabled(false);
        mainLayout->addWidget(specGrp, 1);  // stretch factor for the group

        connect(specGrp, &CollapsibleSection::expandedChanged,
                this, &RxAudioProcessingWidget::onSpecEnableToggled);
        connect(specInhibitDuringTx, &QCheckBox::toggled,
                this, &RxAudioProcessingWidget::onAnyControlChanged);
    }

    scroll->setWidget(inner);

    auto* outerLayout = new QVBoxLayout(this);
    outerLayout->setContentsMargins(0, 0, 0, 0);
    outerLayout->addWidget(scroll);

    // Start disabled — enabled when radio connects
    controlsContainer->setEnabled(false);

    // Minimum width; height adapts to visible content.
    setMinimumWidth(500);
    updateSizeConstraints();
}

// ─── blockAll ─────────────────────────────────────────────────────────────────

void RxAudioProcessingWidget::blockAll(bool block)
{
    bypassCheck->blockSignals(block);
    channelCombo->blockSignals(block);
    algoNone->blockSignals(block);
    algoSpeex->blockSignals(block);
    speexSuppress->blockSignals(block);
    speexBandsCombo->blockSignals(block);
    speexFrameCombo->blockSignals(block);
    speexAgcCheck->blockSignals(block);
    speexAgcLevel->blockSignals(block);
    speexAgcMaxGain->blockSignals(block);
    speexVadCheck->blockSignals(block);
    speexVadProbStart->blockSignals(block);
    speexVadProbCont->blockSignals(block);
    speexSnrDecay->blockSignals(block);
    speexNoiseUpdate->blockSignals(block);
    speexPriorBase->blockSignals(block);
    anrNoiseRedSlider->blockSignals(block);
    anrSensSlider->blockSignals(block);
    anrSmoothSlider->blockSignals(block);
    eqEnableCheck->blockSignals(block);
    for (int i = 0; i < RX_EQ_BANDS; ++i) {
        eqGainSlider[i]->blockSignals(block);
        eqFreqDial[i]->blockSignals(block);
    }
    outputGain->blockSignals(block);
    specGrp->blockSignals(block);
    specInhibitDuringTx->blockSignals(block);
}

// ─── populateFromPrefs ────────────────────────────────────────────────────────

void RxAudioProcessingWidget::populateFromPrefs(const rxAudioProcessingPrefs& p)
{
    bypassCheck->setChecked(p.bypass);
    setProcessingControlsEnabled(!p.bypass);

    // Channel select — map channelSelect (1=L, 2=R, 3=mono) to combo index
    if (m_audioChannels > 1) {
        int idx = qBound(0, p.channelSelect - 1, channelCombo->count() - 1);
        channelCombo->setCurrentIndex(idx);
    }

    // Algorithm
    const int modeId = static_cast<int>(p.nrMode);  // 0=None, 1=Speex, 2=ANR
    algoNone->setChecked(p.nrMode == RxNrMode::None);
    algoSpeex->setChecked(p.nrMode == RxNrMode::Speex);
    algoAnr->setChecked(p.nrMode == RxNrMode::Anr);
    nonePage->setVisible(modeId == 0);
    speexGrp->setVisible(modeId == 1);
    anrGrp->setVisible(modeId == 2);

    // Speex
    speexSuppress->setValue(qBound(-70, p.speexSuppression, -1));
    lblSpeexSuppress->setText(QString::number(p.speexSuppression) + " dB");

    // Find combo entry whose item data matches the saved preset index.
    // If not found (e.g. filterbank.h has fewer presets now), fall back to last entry.
    {
        int matchIdx = speexBandsCombo->count() - 1;
        for (int i = 0; i < speexBandsCombo->count(); ++i) {
            if (speexBandsCombo->itemData(i).toInt() == p.speexBandsPreset) {
                matchIdx = i;
                break;
            }
        }
        speexBandsCombo->setCurrentIndex(matchIdx);
    }

    speexFrameCombo->setCurrentIndex((p.speexFrameMs <= 10) ? 0 : 1);

    speexAgcCheck->setChecked(p.speexAgc);
    speexAgcLevel->setValue(qBound(500, static_cast<int>(p.speexAgcLevel), 32000));
    speexAgcMaxGain->setValue(qBound(0, p.speexAgcMaxGain, 60));
    lblAgcLevel->setText(QString::number(static_cast<int>(p.speexAgcLevel)));
    lblAgcMaxGain->setText(QString::number(p.speexAgcMaxGain) + " dB");

    bool agcVis = p.speexAgc;
    speexAgcLevel->setVisible(agcVis);   lblAgcLevel->setVisible(agcVis);
    speexAgcMaxGain->setVisible(agcVis); lblAgcMaxGain->setVisible(agcVis);

    speexVadCheck->setChecked(p.speexVad);
    speexVadProbStart->setValue(qBound(0, p.speexVadProbStart, 100));
    speexVadProbCont->setValue(qBound(0, p.speexVadProbCont, 100));
    lblVadProbStart->setText(QString::number(p.speexVadProbStart) + " %");
    lblVadProbCont->setText(QString::number(p.speexVadProbCont)   + " %");
    bool vadVis = p.speexVad;
    speexVadProbStart->setVisible(vadVis); lblVadProbStart->setVisible(vadVis);
    speexVadProbCont->setVisible(vadVis);  lblVadProbCont->setVisible(vadVis);

    // Adaptation speed
    speexSnrDecay->setValue(qBound(0, qRound(p.speexSnrDecay * 100.0f), 95));
    speexNoiseUpdate->setValue(qBound(1, qRound(p.speexNoiseUpdateRate * 100.0f), 50));
    speexPriorBase->setValue(qBound(5, qRound(p.speexPriorBase * 100.0f), 50));
    lblSnrDecay->setText(QString::number(p.speexSnrDecay, 'f', 2));
    lblNoiseUpdate->setText(QString::number(p.speexNoiseUpdateRate, 'f', 2));
    lblPriorBase->setText(QString::number(p.speexPriorBase, 'f', 2));

    // ANR
    anrNoiseRedSlider->setValue(qBound(0, static_cast<int>(p.anrNoiseReductionDb), 48));
    lblAnrNoiseRed->setText(QString::number(anrNoiseRedSlider->value()) + " dB");
    // Sensitivity stored as 0–100 integer (×0.1 → 0.0..10.0)
    anrSensSlider->setValue(qBound(0, qRound(p.anrSensitivity * 10.0), 100));
    lblAnrSens->setText(QString::number(anrSensSlider->value() * 0.1, 'f', 1));
    anrSmoothSlider->setValue(qBound(0, p.anrFreqSmoothing, 6));
    lblAnrSmooth->setText(QString::number(anrSmoothSlider->value()));
    updateAnrControlState();

    // EQ
    eqEnableCheck->setChecked(p.eqEnabled);
    eqBandsWidget->setVisible(p.eqEnabled);
    for (int i = 0; i < RX_EQ_BANDS; ++i) {
        eqGainSlider[i]->setValue(qBound(-90, qRound(p.eqGain[i] * 10.0f), 90));
        eqFreqDial[i]->setValue(qRound(p.eqFreq[i]));
        eqGainLabel[i]->setText(QString::number(p.eqGain[i], 'f', 1) + " dB");
        int freq = qRound(p.eqFreq[i]);
        if (freq >= 1000)
            eqFreqLabel[i]->setText(QString::number(freq * 0.001, 'f', 2) + " kHz");
        else
            eqFreqLabel[i]->setText(QString::number(freq) + " Hz");
    }

    // Output gain: -6..+20 dB in 0.1 dB steps → integer range -60..200
    outputGain->setValue(qBound(-60, qRound(p.outputGainDB * 10.0f), 200));
    lblOutputGain->setText(QString::number(p.outputGainDB, 'f', 1) + " dB");

    // Spectrum
    specGrp->setExpanded(p.spectrumEnabled);
    specInhibitDuringTx->setEnabled(p.spectrumEnabled);
    specInhibitDuringTx->setChecked(p.specInhibitDuringTx);
    m_spectrumFps = qBound(1, p.spectrumFPS, 60);
    specWidget->setFps(m_spectrumFps);

    // Defer size calculation so Qt processes all the visibility/layout changes first.
    QTimer::singleShot(0, this, &RxAudioProcessingWidget::updateSizeConstraints);
}

// ─── Spectrum slots ───────────────────────────────────────────────────────────

void RxAudioProcessingWidget::setTransmitting(bool transmitting)
{
    m_isTransmitting = transmitting;
    // When entering the inhibited state (radio starts transmitting), clear
    // stale spectrum data so the display goes blank rather than freezing.
    if (m_isTransmitting && specInhibitDuringTx && specInhibitDuringTx->isChecked()) {
        if (specWidget) {
            specWidget->spectrumPrimary.clear();
            specWidget->spectrumSecondary.clear();
        }
    }
}

void RxAudioProcessingWidget::onSpecEnableToggled(bool enabled)
{
    // CollapsibleSection already shows/hides the body widget.
    specInhibitDuringTx->setEnabled(enabled);
    if (!enabled) {
        specWidget->spectrumPrimary.clear();
        specWidget->spectrumSecondary.clear();
    }
    updateSizeConstraints();
    emit prefsChanged(getPrefs());
}

void RxAudioProcessingWidget::onSpectrumBins(QVector<double> inBins,
                                             QVector<double> outBins,
                                             float rawSR)
{
    if (!specGrp || !specGrp->isExpanded()) return;
    // Inhibit during transmit: drop bins while transmitting.
    if (m_isTransmitting && specInhibitDuringTx && specInhibitDuringTx->isChecked()) return;
    ++m_batchCount;

    if (rawSR != static_cast<float>(specWidget->sampleRate)) {
        // Effective sample rate after decimation to ~16 kHz.
        const int K = qMax(1, static_cast<int>(std::round(rawSR / 16000.0f)));
        specWidget->sampleRate = static_cast<double>(rawSR) / K;
        specWidget->fftLength  = RxAudioProcessor::SPEC_FFT_LEN;
    }

    specWidget->spectrumPrimary.assign(inBins.cbegin(),   inBins.cend());
    specWidget->spectrumSecondary.assign(outBins.cbegin(), outBins.cend());
}

void RxAudioProcessingWidget::onSpecDiagTimer()
{
    if (!specGrp || !specGrp->isExpanded()) {
        m_batchCount = 0;
        return;
    }
    m_batchCount = 0;
}

// ─── ANR profile slots ────────────────────────────────────────────────────────

void RxAudioProcessingWidget::onAnrCollectClicked()
{
    if (m_anrCollecting) {
        // User pressed "Stop Collecting" early
        anrCollectTimer->stop();
        m_anrCollecting = false;
        anrCollectBtn->setText(m_anrHasProfile ? tr("Collect New Noise Sample")
                                               : tr("Collect Noise Sample"));
        lblAnrStatus->setText(tr("Collecting stopped — waiting for profile to build…"));
        anrCollectBtn->setEnabled(false);
        emit anrCollectToggled(false);   // triggers wfmain → rxProc->stopAnrProfile()
    } else {
        // User pressed "Collect Noise Sample"
        m_anrCollecting = true;
        m_anrCountdown  = 5;
        anrCollectBtn->setText(tr("Stop Collecting… (auto-stop in %1 s)").arg(m_anrCountdown));
        lblAnrStatus->setText(tr("Recording noise sample — play audio with only background noise…"));
        anrCollectTimer->start();        // 1-second ticks
        emit anrCollectToggled(true);    // triggers wfmain → rxProc->startAnrProfile()
    }
}

void RxAudioProcessingWidget::onAnrCollectTimeout()
{
    // 1-second tick during collection
    if (!m_anrCollecting) return;

    --m_anrCountdown;
    if (m_anrCountdown > 0) {
        // Update countdown text
        anrCollectBtn->setText(tr("Stop Collecting… (auto-stop in %1 s)").arg(m_anrCountdown));
        return;
    }

    // Countdown reached zero — auto-stop
    anrCollectTimer->stop();
    m_anrCollecting = false;
    anrCollectBtn->setText(m_anrHasProfile ? tr("Collect New Noise Sample")
                                           : tr("Collect Noise Sample"));
    lblAnrStatus->setText(tr("5 s sample collected — building noise profile…"));
    anrCollectBtn->setEnabled(false);
    emit anrCollectToggled(false);  // triggers wfmain → rxProc->stopAnrProfile()
}

void RxAudioProcessingWidget::onAnrProfileReady(bool success)
{
    anrCollectBtn->setEnabled(m_radioConnected);
    if (success) {
        m_anrHasProfile = true;
        anrCollectBtn->setText(tr("Collect New Noise Sample"));
        lblAnrStatus->setText(tr("Noise profile ready — ANR active."));
    } else {
        m_anrHasProfile = false;
        anrCollectBtn->setText(tr("Collect Noise Sample"));
        lblAnrStatus->setText(tr("Profile build failed (sample too short?).  Try again."));
    }
    updateAnrControlState();
}

void RxAudioProcessingWidget::onAnrNoiseProfileBins(QVector<double> bins,
                                                     double sampleRate,
                                                     int windowSize)
{
    if (bins.isEmpty() || !anrProfileSpec) return;

    // Auto-scale: find the range of the dB values
    double lo = bins[0], hi = bins[0];
    for (int i = 1; i < bins.size(); ++i) {
        if (bins[i] < lo) lo = bins[i];
        if (bins[i] > hi) hi = bins[i];
    }
    // Pad the range and round to nice grid boundaries
    double range = hi - lo;
    if (range < 6.0) range = 6.0;
    double margin = range * 0.1;
    lo = std::floor((lo - margin) / 6.0) * 6.0;
    hi = std::ceil((hi + margin) / 6.0) * 6.0;

    anrProfileSpec->fftLength  = windowSize;
    anrProfileSpec->sampleRate = sampleRate;
    anrProfileSpec->minDb      = lo;
    anrProfileSpec->maxDb      = hi;
    anrProfileSpec->spectrumPrimary.assign(bins.cbegin(), bins.cend());
    anrProfileSpec->spectrumSecondary.clear();
    anrProfileSpec->setVisible(true);
    anrProfileSpec->update();   // trigger a single repaint (static mode)
    updateSizeConstraints();
}

// ─── onAnrModeChanged ─────────────────────────────────────────────────────────

void RxAudioProcessingWidget::onAnrModeChanged(QString modeName, bool hasProfile)
{
    m_anrHasProfile   = hasProfile;
    m_currentModeName = modeName;

    if (m_anrCollecting)
        return;  // Don't interrupt an in-progress collection countdown.

    updateAnrControlState();
}

// ─── updateAnrControlState ────────────────────────────────────────────────────

void RxAudioProcessingWidget::updateAnrControlState()
{
    // Parameter sliders are always interactable when ANR is selected so the
    // user can pre-configure them before collecting a noise sample.
    // The status label and button reflect the current mode and profile state.

    if (m_anrCollecting)
        return;  // Text is managed by the collection countdown slots.

    const bool hasMode = !m_currentModeName.isEmpty();

    if (m_anrHasProfile) {
        anrCollectBtn->setText(hasMode
            ? tr("Collect New Noise Sample (%1)").arg(m_currentModeName)
            : tr("Collect New Noise Sample"));
        lblAnrStatus->setText(hasMode
            ? tr("Noise profile for %1 ready — ANR active.").arg(m_currentModeName)
            : tr("Noise profile ready — ANR active."));
    } else {
        anrCollectBtn->setText(hasMode
            ? tr("Collect Noise Sample (%1)").arg(m_currentModeName)
            : tr("Collect Noise Sample"));
        lblAnrStatus->setText(hasMode
            ? tr("No noise sample for %1 — collect one before using ANR.").arg(m_currentModeName)
            : tr("No noise sample — collect one before using ANR."));
    }
}

// ─── updateSizeConstraints ───────────────────────────────────────────────────

void RxAudioProcessingWidget::updateSizeConstraints()
{
    // Guard against setMinimumHeight/setMaximumHeight triggering resizeEvent
    // before we reach our own resize() call — that would corrupt m_userSpectrumHeight.
    m_programmaticResize = true;

    const int contentH = controlsContainer->sizeHint().height();
    const int margins  = layout()->contentsMargins().top()
                       + layout()->contentsMargins().bottom() + 2;
    const int minH = contentH + margins;

    setMinimumHeight(minH);

    if (specGrp->isExpanded()) {
        // Spectrum visible: allow vertical expansion beyond minimum.
        // Preserve any extra height the user added by stretching.
        setMaximumHeight(QWIDGETSIZE_MAX);
        resize(width(), minH + m_userSpectrumHeight);
    } else {
        // No spectrum: lock height to exactly the content size.
        // Keep m_userSpectrumHeight so it can be restored when re-expanded.
        setMaximumHeight(minH);
        resize(width(), minH);
    }

    m_programmaticResize = false;
}

void RxAudioProcessingWidget::resizeEvent(QResizeEvent* event)
{
    QWidget::resizeEvent(event);
    if (m_programmaticResize)
        return;
    // User is dragging the window — capture the extra height beyond content.
    if (specGrp && specGrp->isExpanded()) {
        const int contentH = controlsContainer->sizeHint().height();
        const int margins  = layout()->contentsMargins().top()
                           + layout()->contentsMargins().bottom() + 2;
        int extra = event->size().height() - (contentH + margins);
        m_userSpectrumHeight = qMax(0, extra);
    }
}

// ─── Debug capture ──────────────────────────────────────────────────────────

void RxAudioProcessingWidget::setDebugMode(bool debug)
{
    m_debugMode = debug;
    if (debugCaptureBtn)
        debugCaptureBtn->setVisible(debug);
}

void RxAudioProcessingWidget::onDebugCaptureClicked()
{
    debugCaptureBtn->setEnabled(false);
    m_debugCountdown = 5;
    debugCaptureBtn->setText(tr("Recording... (%1)").arg(m_debugCountdown));
    debugCaptureTimer->start();
    emit debugCaptureRequested();
}

void RxAudioProcessingWidget::onDebugCaptureCountdown()
{
    m_debugCountdown--;
    if (m_debugCountdown > 0) {
        debugCaptureBtn->setText(tr("Recording... (%1)").arg(m_debugCountdown));
    } else {
        debugCaptureTimer->stop();
        debugCaptureBtn->setText(tr("Finishing..."));
    }
}

void RxAudioProcessingWidget::onDebugCaptureComplete(QString filePath)
{
    debugCaptureTimer->stop();
    debugCaptureBtn->setEnabled(true);
    debugCaptureBtn->setText(tr("Capture RX Audio (5 s)"));
    if (!filePath.isEmpty())
        qInfo(logAudio) << "Debug capture saved:" << filePath;
}
