#include "RadioSetupDialog.h"
#include "CwDecodeSettings.h"
#include "GuardedSlider.h"
#include "ComboStyle.h"
#include "SliceColorManager.h"
#include "models/RadioModel.h"
#include "models/XvtrPolicy.h"
#include "core/AppSettings.h"
#include "core/AutomationBridgeSettings.h"
#include "core/backends/hl2/Hl2Discovery.h"   // HL2 custom-nickname settings key
#include "core/NetworkSettings.h"
#include "core/PanadapterStream.h"
#include "core/KiwiSdrManager.h"
#include "KiwiPublicReceiverPicker.h"
#include "core/LogManager.h"
#include "core/PeripheralSettings.h"
#include <QApplication>
#include <QAbstractItemView>
#include <QSysInfo>
#include "core/AudioEngine.h"
#ifdef HAVE_SERIALPORT
#include "core/SerialPortController.h"
#include "core/FlexControlManager.h"
#include <QSerialPortInfo>
#endif
#include "core/FirmwareUploader.h"
#include "core/FirmwareStager.h"
#include "core/TgxlConnection.h"
#include "core/PgxlConnection.h"
#include "core/AcomConnection.h"
#include "core/WanConnection.h"   // PinnedCertInfo + WanCertCache (#2951)
#include "core/CallsignLookupService.h"
#include "core/QrzLookupSettings.h"
#include "models/AntennaGeniusModel.h"

#include <QCloseEvent>
#include <QTabWidget>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <QGroupBox>
#include <QFrame>
#include <QHeaderView>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QSlider>
#include <QComboBox>
#include <QSpinBox>
#include <QDialogButtonBox>
#include <QCheckBox>
#include <QDoubleValidator>
#include <QTimer>
#include <QVector>
#include <QDesktopServices>
#include <QUrl>
#include <QMediaDevices>
#include <QAudioDevice>
#include <QFileDialog>
#include <QStandardPaths>
#include <QFileInfo>
#include <QMessageBox>
#include <QColorDialog>
#include <QRadioButton>
#include <QProgressBar>
#include <QProcess>
#include <QListWidget>
#include <QStackedWidget>
#include <QStyle>
#include <QPlainTextEdit>
#include <QSplitter>
#include <QScrollArea>
#include <QHostAddress>
#include <QClipboard>
#include <QDebug>
#include <QGuiApplication>
#include <QPainter>
#include <QRandomGenerator>
#include <QPaintEvent>
#include <QPointer>
#include <QScreen>
#include <QSignalBlocker>
#include <QSizePolicy>
#include <QToolButton>
#include <QTreeWidget>
#include <QAction>
#include <QKeySequence>

#include <algorithm>
#include <functional>
#include <memory>
#include <utility>
#include "core/ThemeManager.h"

namespace AetherSDR {

static const QString kGroupStyle =
    "QGroupBox { border: 1px solid #304050; border-radius: 4px; "
    "margin-top: 8px; padding-top: 12px; font-weight: bold; color: #8aa8c0; }"
    "QGroupBox::title { subcontrol-origin: margin; left: 10px; "
    "padding: 0 4px; }";

static const QString kLabelStyle =
    "QLabel { color: #c8d8e8; font-size: 12px; }";

static const QString kValueStyle =
    "QLabel { color: #00c8ff; font-size: 12px; font-weight: bold; }";

static const QString kEditStyle =
    "QLineEdit { background: #1a2a3a; border: 1px solid #304050; "
    "border-radius: 3px; color: #c8d8e8; font-size: 12px; padding: 2px 4px; }";

static const QString kKiwiRowStyle =
    "QFrame#kiwiAntennaRow { background: #101622; border: 1px solid #203040; "
    "border-radius: 3px; }";

static const QString kKiwiActionButtonStyle =
    "QPushButton { background: #183548; border: 1px solid #28506a; "
    "border-radius: 4px; color: #d6e7f5; font-size: 12px; "
    "font-weight: bold; padding: 4px 10px; }"
    "QPushButton:hover { background: #20465e; }"
    "QPushButton:pressed { background: #132c3d; }";

static const QString kKiwiIconButtonStyle =
    "QPushButton { background: #183548; border: 1px solid #28506a; "
    "border-radius: 4px; padding: 3px; }"
    "QPushButton:hover { background: #20465e; }"
    "QPushButton:pressed { background: #132c3d; }";

// Shared indicator block for all QCheckBox instances in this dialog.
// Uses ThemeManager tokens (Low Latency architecture) with hover + disabled
// pseudo-states (FreeDV Reporter pattern) so boxes are visible in dark mode.
static const QString kCheckBoxIndicator =
    "QCheckBox::indicator { width: 14px; height: 14px; "
    "border: 2px solid {{color.background.3}}; border-radius: 3px; background: {{color.background.0}}; }"
    "QCheckBox::indicator:hover { border-color: {{color.accent}}; background: {{color.background.1}}; }"
    "QCheckBox::indicator:checked { border: 2px solid {{color.accent}}; background: {{color.background.2}}; }"
    "QCheckBox::indicator:disabled { border-color: {{color.background.2}}; background: {{color.background.0}}; }";

static constexpr int kInfoLeftLabelWidth = 112;
static constexpr int kInfoRightLabelWidth = 160;

static QString kiwiSetupApiPolicyText(KiwiSdrProtocol::ApiPolicy policy)
{
    switch (policy) {
    case KiwiSdrProtocol::ApiPolicy::Disabled:
        return QStringLiteral("API disabled");
    case KiwiSdrProtocol::ApiPolicy::Limited:
        return QStringLiteral("API limited");
    case KiwiSdrProtocol::ApiPolicy::Open:
        return QStringLiteral("API open");
    case KiwiSdrProtocol::ApiPolicy::Unknown:
        break;
    }
    return QStringLiteral("API unknown");
}

static QString kiwiSetupMetadataSummary(const KiwiSdrManager* manager,
                                        const QString& id)
{
    if (!manager || id.isEmpty()) {
        return QString();
    }

    const KiwiSdrProtocol::ReceiverMetadata metadata =
        manager->receiverMetadata(id);
    const KiwiSdrProtocol::ProtocolState protocol =
        manager->protocolState(id);
    QStringList parts;
    if (!metadata.serverVersion.isEmpty()) {
        parts << QStringLiteral("v%1").arg(metadata.serverVersion);
    }
    if (metadata.hasUsers && metadata.hasUsersMax) {
        parts << QStringLiteral("%1/%2 users")
                     .arg(metadata.users)
                     .arg(metadata.usersMax);
    } else if (metadata.hasUsers) {
        parts << QStringLiteral("%1 users").arg(metadata.users);
    }
    if (metadata.hasBusy && metadata.busy) {
        parts << QStringLiteral("busy");
    }
    if (metadata.hasCampStatus
        && metadata.campStatus != KiwiSdrProtocol::CampStatus::Unknown) {
        switch (metadata.campStatus) {
        case KiwiSdrProtocol::CampStatus::Offered:
            parts << QStringLiteral("monitor offered");
            break;
        case KiwiSdrProtocol::CampStatus::Queued:
            if (metadata.hasCampQueuePosition
                && metadata.hasCampQueueWaiters) {
                parts << QStringLiteral("queue %1/%2")
                             .arg(metadata.campQueuePosition)
                             .arg(metadata.campQueueWaiters);
            } else {
                parts << QStringLiteral("queued");
            }
            break;
        case KiwiSdrProtocol::CampStatus::Accepted:
            parts << (metadata.hasCampReceiverChannel
                          ? QStringLiteral("camping RX%1")
                                .arg(metadata.campReceiverChannel)
                          : QStringLiteral("camping"));
            break;
        case KiwiSdrProtocol::CampStatus::Rejected:
            parts << QStringLiteral("camp rejected");
            break;
        case KiwiSdrProtocol::CampStatus::AudioStopped:
            parts << QStringLiteral("camp audio stopped");
            break;
        case KiwiSdrProtocol::CampStatus::Disconnected:
            parts << QStringLiteral("camp disconnected");
            break;
        case KiwiSdrProtocol::CampStatus::Unknown:
            break;
        }
    }
    if (metadata.hasExtApi) {
        parts << QStringLiteral("%1 (%2)")
                     .arg(kiwiSetupApiPolicyText(metadata.apiPolicy))
                     .arg(metadata.extApi);
    }
    if (metadata.hasGpsGood) {
        parts << (metadata.gpsGood ? QStringLiteral("GPS good")
                                   : QStringLiteral("GPS not good"));
    }
    if (metadata.hasAdcClipping && metadata.adcClipping) {
        parts << QStringLiteral("ADC clipping");
    }
    if (metadata.hasCoverageCenter && metadata.hasCoverageBandwidth) {
        const double lowMhz =
            metadata.coverageCenterMhz - metadata.coverageBandwidthMhz * 0.5;
        const double highMhz =
            metadata.coverageCenterMhz + metadata.coverageBandwidthMhz * 0.5;
        parts << QStringLiteral("%1-%2 MHz")
                     .arg(lowMhz, 0, 'f', 3)
                     .arg(highMhz, 0, 'f', 3);
    }
    if (protocol.sound.observed
        && protocol.sound.lastObservedLayout
            != KiwiSdrProtocol::FrameLayout::Unknown) {
        parts << QStringLiteral("SND %1")
                     .arg(KiwiSdrProtocol::frameLayoutName(
                         protocol.sound.lastObservedLayout));
    }
    if (protocol.waterfall.observed
        && protocol.waterfall.lastObservedLayout
            != KiwiSdrProtocol::FrameLayout::Unknown) {
        parts << QStringLiteral("W/F %1")
                     .arg(KiwiSdrProtocol::frameLayoutName(
                         protocol.waterfall.lastObservedLayout));
    }
    return parts.join(QStringLiteral(" · "));
}

// Wrap a tab page in a vertical QScrollArea so tabs whose stacked groups exceed
// the dialog's visible height (Themes, Audio, Filters, Peripherals on small or
// high-DPI displays) get a vertical scrollbar instead of forcing the dialog
// past the screen edge (#3345). setWidgetResizable(true) keeps horizontal
// expansion intact and hides the scrollbar when content already fits — users
// on wide screens see no visual change.
static QWidget* wrapTabInScrollArea(QWidget* content)
{
    auto* scroll = new QScrollArea;
    scroll->setWidgetResizable(true);
    scroll->setFrameShape(QFrame::NoFrame);
    scroll->setStyleSheet("QScrollArea { background: transparent; border: none; }");
    scroll->setWidget(content);
    return scroll;
}

static QString displayOrDash(const QString& value)
{
    const QString trimmed = value.trimmed();
    return trimmed.isEmpty() ? QStringLiteral("—") : trimmed;
}

static QString radioSerialNumber(const RadioModel* model)
{
    if (!model) {
        return QStringLiteral("—");
    }
    return displayOrDash(model->chassisSerial().isEmpty()
                             ? model->serial()
                             : model->chassisSerial());
}

static QString prefixedVersion(const QString& version)
{
    const QString trimmed = version.trimmed();
    if (trimmed.isEmpty()) {
        return QStringLiteral("—");
    }
    if (trimmed.startsWith(QLatin1Char('v'), Qt::CaseInsensitive)) {
        return trimmed;
    }
    return QStringLiteral("v%1").arg(trimmed);
}

static QString radioOptionsText(const RadioModel* model)
{
    if (!model) {
        return QStringLiteral("—");
    }
    if (!model->radioOptions().isEmpty()) {
        return model->radioOptions();
    }
    return model->amplifier().present() ? QStringLiteral("GPS, PGXL") : QStringLiteral("GPS");
}

static void showCopiedPopup(QWidget* anchor);

class CopyValueButton final : public QToolButton {
public:
    explicit CopyValueButton(QString fieldName,
                             std::function<QString()> valueProvider,
                             QWidget* parent = nullptr)
        : QToolButton(parent),
          m_fieldName(std::move(fieldName)),
          m_valueProvider(std::move(valueProvider))
    {
        setAutoRaise(true);
        setCursor(Qt::PointingHandCursor);
        setFocusPolicy(Qt::TabFocus);
        setFixedSize(20, 20);
        setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
        setStyleSheet(
            "QToolButton { background: transparent; border: 0; padding: 0; margin: 0; }"
            "QToolButton:focus { outline: none; }");
        resetToolTip();
        setAccessibleName(toolTip());

        connect(this, &QToolButton::clicked, this, [this] {
            if (!m_valueProvider) {
                return;
            }

            const QString text = m_valueProvider().trimmed();
            if (text.isEmpty() || text == QStringLiteral("—")) {
                return;
            }

            if (QClipboard* clipboard = QGuiApplication::clipboard()) {
                clipboard->setText(text);
            }

            showCopiedPopup(this);
        });
    }

protected:
    void paintEvent(QPaintEvent*) override
    {
        QPainter painter(this);
        painter.setRenderHint(QPainter::Antialiasing);

        const bool hasCopyableValue = [this] {
            if (!m_valueProvider) return false;
            const QString text = m_valueProvider().trimmed();
            return !text.isEmpty() && text != QStringLiteral("—");
        }();

        if (hasCopyableValue && (underMouse() || hasFocus())) {
            painter.setPen(Qt::NoPen);
            painter.setBrush(QColor(255, 255, 255, 16));
            painter.drawRoundedRect(rect().adjusted(1, 1, -1, -1), 3, 3);
        }

        QColor stroke = QColor(QStringLiteral("#8090a0"));
        if (!hasCopyableValue) {
            stroke = QColor(QStringLiteral("#405060"));
        } else if (isDown()) {
            stroke = QColor(QStringLiteral("#00b4d8"));
        } else if (underMouse() || hasFocus()) {
            stroke = QColor(QStringLiteral("#c8d8e8"));
        }

        painter.setPen(QPen(stroke, 1.25));
        painter.setBrush(Qt::NoBrush);

        const qreal left = (width() - 16.0) / 2.0;
        const qreal top = (height() - 16.0) / 2.0;
        const QRectF back(left + 3.0, top + 1.5, 8.5, 11.0);
        const QRectF front(left + 5.5, top + 4.0, 8.5, 11.0);

        painter.drawRoundedRect(back, 1.5, 1.5);
        painter.fillRect(front.adjusted(0.8, 0.8, -0.8, -0.8), QColor(QStringLiteral("#0f0f1a")));
        painter.drawRoundedRect(front, 1.5, 1.5);
    }

private:
    void resetToolTip()
    {
        setToolTip(QStringLiteral("Copy %1").arg(m_fieldName));
    }

    QString m_fieldName;
    std::function<QString()> m_valueProvider;
};

static QWidget* makeCopyableValueLabel(const QString& fieldName, QLabel* valueLabel)
{
    auto* wrapper = new QWidget;
    auto* layout = new QHBoxLayout(wrapper);
    layout->setContentsMargins(0, 0, 0, 0);
    layout->setSpacing(4);

    valueLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
    valueLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred);
    layout->addWidget(valueLabel);
    layout->addStretch(1);
    layout->addWidget(new CopyValueButton(fieldName, [valueLabel] {
        return valueLabel->text();
    }, wrapper));

    return wrapper;
}

static void showCopiedPopup(QWidget* anchor)
{
    if (!anchor) {
        return;
    }

    auto* popup = new QLabel(QStringLiteral("Copied"), nullptr,
                             Qt::ToolTip | Qt::FramelessWindowHint);
    popup->setAttribute(Qt::WA_DeleteOnClose);
    popup->setAttribute(Qt::WA_ShowWithoutActivating);
    AetherSDR::ThemeManager::instance().applyStyleSheet(popup, "QLabel { background: {{color.background.0}}; border: 1px solid {{color.background.2}};"
        " border-radius: 4px; color: {{color.text.primary}}; font-size: 11px;"
        " font-weight: bold; padding: 4px 8px; }");
    popup->adjustSize();

    const QPoint globalCenter = anchor->mapToGlobal(anchor->rect().center());
    const QSize popupSize = popup->sizeHint();
    QPoint pos(globalCenter.x() - popupSize.width() / 2,
               globalCenter.y() - anchor->height() / 2 - popupSize.height() - 6);

    QRect screenRect;
    if (auto* screen = anchor->screen()) {
        screenRect = screen->availableGeometry();
    } else if (auto* primary = QGuiApplication::primaryScreen()) {
        screenRect = primary->availableGeometry();
    }
    if (screenRect.isValid()) {
        pos.setX(std::clamp(pos.x(), screenRect.left(),
                            screenRect.right() - popupSize.width()));
        if (pos.y() < screenRect.top()) {
            pos.setY(globalCenter.y() + anchor->height() / 2 + 6);
        }
    }

    popup->move(pos);
    popup->show();
    QTimer::singleShot(1000, popup, &QLabel::close);
}

static QWidget* makeInfoField(const QString& labelText, QWidget* valueWidget,
                              int labelWidth = kInfoLeftLabelWidth)
{
    auto* wrapper = new QWidget;
    auto* layout = new QHBoxLayout(wrapper);
    layout->setContentsMargins(0, 0, 0, 0);
    layout->setSpacing(8);

    auto* label = new QLabel(labelText);
    label->setStyleSheet(kLabelStyle);
    label->setFixedWidth(labelWidth);
    layout->addWidget(label);
    QSizePolicy policy = valueWidget->sizePolicy();
    if (policy.horizontalPolicy() != QSizePolicy::Fixed) {
        policy.setHorizontalPolicy(QSizePolicy::Expanding);
        valueWidget->setSizePolicy(policy);
    }
    layout->addWidget(valueWidget, 1);

    return wrapper;
}

static QWidget* makeCopyableInfoField(const QString& fieldName, const QString& labelText,
                                      QLabel* valueLabel, int labelWidth = kInfoLeftLabelWidth)
{
    auto* wrapper = new QWidget;
    auto* layout = new QHBoxLayout(wrapper);
    layout->setContentsMargins(0, 0, 0, 0);
    layout->setSpacing(8);

    auto* label = new QLabel(labelText);
    label->setStyleSheet(kLabelStyle);
    label->setFixedWidth(labelWidth);
    layout->addWidget(label);

    valueLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
    valueLabel->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred);
    layout->addWidget(valueLabel, 1);
    layout->addWidget(new CopyValueButton(fieldName, [valueLabel] {
        return valueLabel->text();
    }, wrapper));

    return wrapper;
}

static constexpr const char* kSuppressAudioDeviceNotificationsKey =
    "SuppressAudioDeviceNotifications";

static QString normalizedOscillatorValue(QString value)
{
    value = value.trimmed().toLower();
    return value == "ext" ? QStringLiteral("external") : value;
}

static QString oscillatorSourceLabel(const QString& value)
{
    const QString normalized = normalizedOscillatorValue(value);
    if (normalized == "auto") return QStringLiteral("Auto");
    if (normalized == "external") return QStringLiteral("External 10 MHz");
    if (normalized == "gpsdo") return QStringLiteral("GPSDO");
    if (normalized == "tcxo") return QStringLiteral("TCXO");
    return value.trimmed().isEmpty() ? QStringLiteral("Unknown") : value.toUpper();
}

static QString oscillatorStatusText(const RadioModel* model)
{
    const QString setting = normalizedOscillatorValue(model->oscSetting());
    const QString state = normalizedOscillatorValue(model->oscState());
    if (state.isEmpty())
        return QStringLiteral("Waiting for oscillator status");

    QString text;
    if (setting == "auto" && state != "auto") {
        text = QStringLiteral("Auto -> %1").arg(oscillatorSourceLabel(state));
    } else if (!setting.isEmpty() && setting != state && state != "auto") {
        text = QStringLiteral("%1 -> %2")
                   .arg(oscillatorSourceLabel(setting), oscillatorSourceLabel(state));
    } else {
        text = oscillatorSourceLabel(state);
    }

    text += model->oscLocked() ? QStringLiteral(" Locked")
                               : QStringLiteral(" Unlocked");
    if (state == "external" && !model->extPresent())
        text += QStringLiteral(" (not detected)");
    return text;
}

static QString oscillatorStatusColor(const RadioModel* model)
{
    if (normalizedOscillatorValue(model->oscState()).isEmpty())
        return QStringLiteral("#8aa8c0");
    return model->oscLocked() ? QStringLiteral("#00c040")
                              : QStringLiteral("#c04040");
}

static void addOscillatorChoice(QComboBox* combo, const QString& label,
                                const QString& value)
{
    if (combo->findData(value) < 0)
        combo->addItem(label, value);
}

static void refreshOscillatorSourceCombo(QComboBox* combo, const RadioModel* model,
                                         const QString& preferred = {})
{
    const QString current = normalizedOscillatorValue(
        preferred.isEmpty() ? combo->currentData().toString() : preferred);
    const QString setting = normalizedOscillatorValue(model->oscSetting());
    const QString state = normalizedOscillatorValue(model->oscState());
    const bool hasOscillatorStatus = !state.isEmpty();

    auto shouldKeep = [&](const QString& value) {
        return current == value || setting == value || state == value;
    };

    combo->clear();
    addOscillatorChoice(combo, QStringLiteral("Auto"), QStringLiteral("auto"));
    if (hasOscillatorStatus || model->tcxoPresent() || shouldKeep(QStringLiteral("tcxo")))
        addOscillatorChoice(combo, QStringLiteral("TCXO"), QStringLiteral("tcxo"));
    if (model->gpsdoPresent() || shouldKeep(QStringLiteral("gpsdo")))
        addOscillatorChoice(combo, QStringLiteral("GPSDO"), QStringLiteral("gpsdo"));
    if (hasOscillatorStatus || model->extPresent() || shouldKeep(QStringLiteral("external")))
        addOscillatorChoice(combo, QStringLiteral("External 10 MHz"),
                            QStringLiteral("external"));

    const QString desired = setting.isEmpty() ? current : setting;
    int idx = combo->findData(desired);
    if (idx < 0) idx = combo->findData(current);
    if (idx < 0) idx = combo->findData(QStringLiteral("auto"));
    if (idx >= 0) combo->setCurrentIndex(idx);
}

#ifdef HAVE_SERIALPORT
// Populates a serial-port combo (real ports discovered via QSerialPortInfo,
// plus a trailing "Custom..." sentinel) and selects the entry matching
// savedPort. If none of the discovered ports match — the saved port isn't
// currently plugged in, or it's a non-standard path (e.g. /dev/ttyUSB0 on
// Linux, a symlinked TTY) — falls back to "Custom..." with customEdit
// pre-filled, so the saved value is never silently dropped. Returns true if
// the fallback (Custom) was selected. Shared by the CW/keying Port
// Configuration group and the ACOM Peripherals row, which independently
// re-implemented this match/fallback logic with a subtly different
// isCustom computation before this was factored out.
static bool populateSerialPortCombo(QComboBox* combo, QLineEdit* customEdit,
                                    const QString& savedPort)
{
    for (const auto& info : QSerialPortInfo::availablePorts())
        combo->addItem(QString("%1 — %2").arg(info.portName(), info.description()),
                        info.portName());
    combo->addItem("Custom...", QStringLiteral("__custom__"));

    bool isCustom = !savedPort.isEmpty();
    for (int i = 0; i < combo->count() - 1; ++i) {
        if (combo->itemData(i).toString() == savedPort) {
            combo->setCurrentIndex(i);
            isCustom = false;
            break;
        }
    }
    if (isCustom) {
        combo->setCurrentIndex(combo->count() - 1);
        if (customEdit) customEdit->setText(savedPort);
    }
    return isCustom;
}
#endif

RadioSetupDialog::RadioSetupDialog(RadioModel* model, AudioEngine* audio,
                                   TgxlConnection* tgxl, PgxlConnection* pgxl,
                                   AntennaGeniusModel* ag,
                                   KiwiSdrManager* kiwiSdrManager,
                                   AcomConnection* acom,
                                   QWidget* parent)
    : PersistentDialog(QStringLiteral("Radio Setup"),
                       QStringLiteral("RadioSetupDialogGeometry"), parent),
      m_model(model), m_audio(audio),
      m_tgxl(tgxl), m_pgxl(pgxl), m_ag(ag),
      m_kiwiSdrManager(kiwiSdrManager), m_acom(acom)
{
    theme::setContainer(this, QStringLiteral("dialog/radioSetup"));
    setMinimumSize(960, 680);
    AetherSDR::ThemeManager::instance().applyStyleSheet(this, "QDialog { background: {{color.background.0}}; }");

    auto* layout = new QVBoxLayout(bodyWidget());

    auto* search = new QLineEdit;
    search->setObjectName(QStringLiteral("radioSetupSearch"));
    search->setPlaceholderText(QStringLiteral("Search settings (%1)")
        .arg(QKeySequence(QKeySequence::Find).toString(QKeySequence::NativeText)));
    search->setClearButtonEnabled(true);
    search->setAccessibleName(QStringLiteral("Search Radio Setup settings"));
    search->setAccessibleDescription(
        QStringLiteral("Type a feature, device, or setting name to filter the navigation list."));
    search->setMinimumHeight(38);
    AetherSDR::ThemeManager::instance().applyStyleSheet(search,
        "QLineEdit { background: {{color.background.1}}; color: {{color.text.primary}}; "
        "border: 2px solid {{color.background.2}}; border-radius: 6px; padding: 7px 10px; font-size: 13px; }"
        "QLineEdit:focus { border-color: {{color.accent.bright}}; }");
    layout->addWidget(search);

    auto* content = new QSplitter(Qt::Horizontal);
    content->setChildrenCollapsible(false);

    m_navigation = new QTreeWidget;
    m_navigation->setObjectName(QStringLiteral("radioSetupNavigation"));
    m_navigation->setHeaderHidden(true);
    m_navigation->setRootIsDecorated(false);
    m_navigation->setIndentation(14);
    m_navigation->setUniformRowHeights(false);
    m_navigation->setMinimumWidth(235);
    m_navigation->setMaximumWidth(340);
    m_navigation->setAccessibleName(QStringLiteral("Radio Setup categories"));
    m_navigation->setAccessibleDescription(
        QStringLiteral("Use the arrow keys to move between categories and settings pages."));
    AetherSDR::ThemeManager::instance().applyStyleSheet(m_navigation,
        "QTreeWidget { background: {{color.background.1}}; color: {{color.text.primary}}; "
        "border: 1px solid {{color.background.2}}; border-radius: 6px; padding: 6px; outline: none; }"
        "QTreeWidget::branch { image: none; border-image: none; background: transparent; }"
        "QTreeWidget::item { min-height: 36px; padding: 3px 8px; border-radius: 4px; }"
        "QTreeWidget::item:selected { background: {{color.accent.bright}}; color: {{color.background.0}}; }"
        "QTreeWidget::item:hover:!selected { background: {{color.background.2}}; }");
    content->addWidget(m_navigation);

    auto* pageHost = new QWidget;
    auto* pageLayout = new QVBoxLayout(pageHost);
    pageLayout->setContentsMargins(16, 4, 4, 4);
    pageLayout->setSpacing(10);
    m_pageTitle = new QLabel;
    m_pageTitle->setAccessibleName(QStringLiteral("Current settings page"));
    AetherSDR::ThemeManager::instance().applyStyleSheet(m_pageTitle,
        "QLabel { color: {{color.text.primary}}; font-size: 20px; font-weight: 600; padding: 2px 0 8px 0; }");
    pageLayout->addWidget(m_pageTitle);
    m_pages = new QStackedWidget;
    m_pages->setObjectName(QStringLiteral("radioSetupPages"));
    m_pages->setAccessibleName(QStringLiteral("Settings page content"));
    pageLayout->addWidget(m_pages, 1);
    content->addWidget(pageHost);
    content->setStretchFactor(0, 0);
    content->setStretchFactor(1, 1);
    content->setSizes({260, 700});

    auto addCategory = [this](const QString& name) {
        auto* item = new QTreeWidgetItem(m_navigation, {name});
        item->setFlags(Qt::ItemIsEnabled);
        QFont font = item->font(0);
        font.setBold(true);
        item->setFont(0, font);
        return item;
    };

    auto* radioCategory = addCategory(QStringLiteral("RADIO"));
    auto* signalCategory = addCategory(QStringLiteral("RECEIVE & TRANSMIT"));
    auto* hardwareCategory = addCategory(QStringLiteral("CONTROLLERS & HARDWARE"));
    auto* onlineCategory = addCategory(QStringLiteral("ONLINE & APPEARANCE"));

    auto addPage = [this](QTreeWidgetItem* category, const QString& name,
                         const QString& keywords, std::function<QWidget*()> builder,
                         bool eager = false) {
        QWidget* placeholder = eager ? wrapTabInScrollArea(builder()) : new QWidget;
        const int index = m_pages->addWidget(placeholder);
        auto* item = new QTreeWidgetItem(category, {name});
        item->setData(0, Qt::UserRole, index);
        item->setData(0, Qt::UserRole + 1, keywords);
        item->setToolTip(0, keywords);
        m_pageIndexes.insert(name, index);
        m_pageItems.insert(index, item);
        if (!eager) {
            m_deferredBuilders[index] = std::move(builder);
        }
        return item;
    };

    // Build only the default (Radio) tab eagerly; defer the rest until first
    // selected.  This avoids hardware-probing calls (QSerialPortInfo,
    // QMediaDevices) during construction, which crash on some Wayland/Qt 6.11
    // configurations (#1776).
    QTreeWidgetItem* firstItem = addPage(radioCategory, QStringLiteral("Radio"),
        QStringLiteral("identity nickname callsign firmware license model region remote power"),
        [this] { return buildRadioTab(); }, true);
    addPage(radioCategory, QStringLiteral("Network"),
        QStringLiteral("ip address dhcp static ethernet connection network"),
        [this] { return buildNetworkTab(); });
    addPage(radioCategory, QStringLiteral("GPS"),
        QStringLiteral("gpsdo satellite location oscillator time"), [this] { return buildGpsTab(); });
    addPage(signalCategory, QStringLiteral("Audio"),
        QStringLiteral("speaker microphone device sample rate latency sound dax"), [this] { return buildAudioTab(); });
    addPage(signalCategory, QStringLiteral("Transmit"),
        QStringLiteral("tx transmit rf power tune ptt band settings"), [this] { return buildTxTab(); });
    addPage(signalCategory, QStringLiteral("Phone & CW"),
        QStringLiteral("phone cw keyer break-in sidetone microphone voice"), [this] { return buildPhoneCwTab(); });
    addPage(signalCategory, QStringLiteral("Receive"),
        QStringLiteral("rx receive calibration rf gain preamp"), [this] { return buildRxTab(); });
    addPage(signalCategory, QStringLiteral("Filters"),
        QStringLiteral("filter bandwidth low high cut mode"), [this] { return buildFiltersTab(); });
    addPage(hardwareCategory, QStringLiteral("Antennas"),
        QStringLiteral("antenna names ant1 ant2 rx in transverter"), [this] { return buildAntennaNamesTab(); });
    addPage(hardwareCategory, QStringLiteral("Transverters"),
        QStringLiteral("xvtr transverter if frequency offset power"), [this] { return buildXvtrTab(); });
    // External APD tab (#2186) — only present on radios that report
    // `apd configurable=1` (FLEX-8x00 series with SmartSDR 4.2.18+).
    QTreeWidgetItem* apdItem = addPage(hardwareCategory, QStringLiteral("APD"),
        QStringLiteral("adaptive predistortion amplifier sampler linearization"), [this] { return buildApdTab(); });
    m_apdPageIndex = m_pageIndexes.value(QStringLiteral("APD"));
    apdItem->setHidden(!m_model->transmitModel().apdConfigurable());
    connect(&m_model->transmitModel(), &TransmitModel::apdStateChanged,
            this, [this, apdItem] {
        apdItem->setHidden(!m_model->transmitModel().apdConfigurable());
    });
    addPage(hardwareCategory, QStringLiteral("USB Cables"),
        QStringLiteral("usb cable gpio bit bcd amplifier tuner accessory"), [this] { return buildUsbCablesTab(); });
    addPage(hardwareCategory, QStringLiteral("Peripherals"),
        QStringLiteral("controllers amplifier tuner antenna genius pgxl tgxl manual ip"), [this] { return buildPeripheralsTab(); });
    addPage(onlineCategory, QStringLiteral("Appearance & Behavior"),
        QStringLiteral("themes colors display font vision contrast click wheel ui enhancements"), [this] { return buildUiEnhancementsTab(); });
    addPage(onlineCategory, QStringLiteral("SmartLink"),
        QStringLiteral("remote internet certificate security pin wan"), [this] { return buildSmartLinkTab(); });
    addPage(onlineCategory, QStringLiteral("QRZ & Callsigns"),
        QStringLiteral("qrz callsign lookup spots contact online account"), [this] { return buildQrzTab(); });
#ifdef HAVE_SERIALPORT
    addPage(hardwareCategory, QStringLiteral("Serial & Controllers"),
        QStringLiteral("serial flexcontrol midi controller knob com port baud ptt cw"), [this] { return buildSerialTab(); });
#endif

    m_navigation->expandAll();
    connect(m_navigation, &QTreeWidget::currentItemChanged, this,
            [this](QTreeWidgetItem* current, QTreeWidgetItem* previous) {
        if (!current) {
            return;
        }
        if (!current->parent()) {
            QTreeWidgetItem* next = previous && m_navigation->itemAbove(current) == previous
                ? m_navigation->itemBelow(current)
                : m_navigation->itemAbove(current);
            // Arrowing up onto the topmost "RADIO" header has nothing above it,
            // so itemAbove() is null and the highlight would rest on the header.
            // Clamp to the first child page below instead (#4183).
            if (!next || !next->parent()) {
                next = m_navigation->itemBelow(current);
            }
            if (next && next->parent()) {
                m_navigation->setCurrentItem(next);
            }
            return;
        }
        const int index = current->data(0, Qt::UserRole).toInt();
        buildDeferredTab(index);
        m_pages->setCurrentIndex(index);
        m_pageTitle->setText(current->text(0));
    });
    connect(search, &QLineEdit::textChanged, this, [this](const QString& text) {
        const QString needle = text.trimmed();
        QTreeWidgetItem* firstVisible = nullptr;
        for (int i = 0; i < m_navigation->topLevelItemCount(); ++i) {
            QTreeWidgetItem* category = m_navigation->topLevelItem(i);
            bool anyVisible = false;
            for (int j = 0; j < category->childCount(); ++j) {
                QTreeWidgetItem* item = category->child(j);
                const QString haystack = item->text(0) + QStringLiteral(" ")
                    + item->data(0, Qt::UserRole + 1).toString();
                const bool matches = needle.isEmpty()
                    || haystack.contains(needle, Qt::CaseInsensitive);
                const bool apdRow = item == m_pageItems.value(m_apdPageIndex);
                if (!apdRow || m_model->transmitModel().apdConfigurable()) {
                    item->setHidden(!matches);
                }
                anyVisible = anyVisible || !item->isHidden();
                if (!item->isHidden() && !firstVisible) {
                    firstVisible = item;
                }
            }
            category->setHidden(!anyVisible);
            category->setExpanded(true);
        }
        // Stash the first match but do NOT make it current here: selecting it
        // fires currentItemChanged → buildDeferredTab, which would eagerly
        // construct and hardware-probe deferred pages (Audio, Serial,
        // Peripherals) on every keystroke — the probe-on-navigate deferral
        // #1776 exists to avoid. Enter commits the highlight instead (#4183).
        // With an empty needle every page "matches", so leave the stash null —
        // Enter with no query typed is then a no-op rather than jumping to (and
        // building) the first page.
        m_searchFirstMatch = needle.isEmpty() ? nullptr : firstVisible;
    });
    connect(search, &QLineEdit::returnPressed, this, [this] {
        if (m_searchFirstMatch && !m_searchFirstMatch->isHidden()) {
            m_navigation->setCurrentItem(m_searchFirstMatch);
        }
    });
    auto* findAction = new QAction(this);
    findAction->setShortcut(QKeySequence::Find);
    findAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
    connect(findAction, &QAction::triggered, search, [search] {
        search->setFocus();
        search->selectAll();
    });
    addAction(findAction);
    m_navigation->setCurrentItem(firstItem);
    layout->addWidget(content, 1);

    auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close);
    AetherSDR::ThemeManager::instance().applyStyleSheet(buttons, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
        "border-radius: 3px; color: {{color.text.primary}}; padding: 4px 16px; }"
        "QPushButton:hover { background: {{color.background.1}}; }");
    connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::close);
    layout->addWidget(buttons);
}

void RadioSetupDialog::closeEvent(QCloseEvent* event)
{
    // Persist any uncommitted "user cleared IP" edits in the Peripherals
    // tab before the base class flushes geometry to AppSettings.
    for (const auto& saver : m_peripheralRowSavers)
        saver();
    PersistentDialog::closeEvent(event);
}

// ── Radio tab ─────────────────────────────────────────────────────────────────

QWidget* RadioSetupDialog::buildRadioTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Toggle button style: green when on, red when off
    static const QString kToggleStyle =
        "QPushButton { background: #1a2a3a; border: 1px solid #304050; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; "
        "padding: 3px 10px; }"
        "QPushButton:checked { background: #1a5030; color: #00e060; "
        "border: 1px solid #20a040; }";

    auto makeToggle = [](bool checked) {
        auto* btn = new QPushButton(checked ? "Enabled" : "Disabled");
        btn->setCheckable(true);
        btn->setChecked(checked);
        btn->setStyleSheet(kToggleStyle);
        return btn;
    };

    // Radio Information group
    {
        auto* group = new QGroupBox("Radio Information");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);
        grid->setColumnStretch(0, 1);
        grid->setColumnStretch(1, 1);

        m_serialLabel = new QLabel(radioSerialNumber(m_model));
        m_serialLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("Radio Serial Number"),
                                              QStringLiteral("Serial:"),
                                              m_serialLabel),
                        0, 0);

        m_regionLabel = new QLabel(m_model->region().isEmpty() ? "USA" : m_model->region());
        AetherSDR::ThemeManager::instance().applyStyleSheet(m_regionLabel, "QLabel { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.accent.bright}}; font-size: 11px; font-weight: bold; "
            "padding: 3px 10px; }");
        m_regionLabel->setAlignment(Qt::AlignCenter);
        grid->addWidget(makeInfoField(QStringLiteral("Region:"), m_regionLabel,
                                      kInfoRightLabelWidth),
                        0, 1);

        m_hwVersionLabel = new QLabel(prefixedVersion(m_model->version()));
        m_hwVersionLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("HW Version"),
                                              QStringLiteral("HW Version:"),
                                              m_hwVersionLabel),
                        1, 0);

        m_remoteOnBtn = makeToggle(m_model->remoteOnEnabled());
        connect(m_remoteOnBtn, &QPushButton::toggled, this, [this](bool on) {
            m_remoteOnBtn->setText(on ? "Enabled" : "Disabled");
            m_model->setRemoteOnEnabled(on);
        });
        grid->addWidget(makeInfoField(QStringLiteral("Remote On:"), m_remoteOnBtn,
                                      kInfoRightLabelWidth),
                        1, 1);

        m_optionsLabel = new QLabel(radioOptionsText(m_model));
        m_optionsLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("Options"),
                                              QStringLiteral("Options:"),
                                              m_optionsLabel),
                        2, 0);

        // FlexControl support isn't a user-facing setting — it just reflects
        // whether AetherSDR currently holds control of the radio via the
        // FlexRadio API, so it's a status label (like Region:/HW Version:
        // above), not a checkable button. It used to be a makeToggle(true)
        // QPushButton with no toggled handler wired up (hardcoded true, no
        // connection to isConnected() either) — clicking it could visually
        // uncheck to the "off" gray style while the text stayed stuck on
        // "Enabled", and it kept saying "Enabled" even with no radio
        // connected at all. Now it tracks isConnected() live, the same way
        // rebootBtn does a few lines above.
        auto* fcLbl = new QLabel;
        auto updateFcLbl = [fcLbl](bool connected) {
            fcLbl->setText(connected ? "Enabled" : "Disabled");
            AetherSDR::ThemeManager::instance().applyStyleSheet(fcLbl, connected
                ? "QLabel { background: #1a5030; border: 1px solid #20a040; "
                  "border-radius: 3px; color: {{color.accent.success}}; font-size: 11px; font-weight: bold; "
                  "padding: 3px 10px; }"
                : "QLabel { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
                  "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
                  "padding: 3px 10px; }");
        };
        updateFcLbl(m_model->isConnected());
        fcLbl->setAlignment(Qt::AlignCenter);
        connect(m_model, &RadioModel::connectionStateChanged, this, updateFcLbl);
        grid->addWidget(makeInfoField(QStringLiteral("FlexControl:"), fcLbl,
                                      kInfoRightLabelWidth),
                        2, 1);

        auto* mfBtn = makeToggle(m_model->multiFlexEnabled());
        connect(mfBtn, &QPushButton::toggled, this, [this, mfBtn](bool on) {
            mfBtn->setText(on ? "Enabled" : "Disabled");
            m_model->setMultiFlexEnabled(on);
        });
        grid->addWidget(makeInfoField(QStringLiteral("multiFLEX:"), mfBtn,
                                      kInfoRightLabelWidth),
                        3, 1);

        auto* rebootBtn = new QPushButton(QStringLiteral("Reboot Radio"));
        AetherSDR::ThemeManager::instance().applyStyleSheet(rebootBtn,
            "QPushButton { background: #3a1a1a; color: #ffb080; border: 1px solid #6e3030;"
            " border-radius: 3px; font-size: 11px; font-weight: bold; padding: 3px 10px; }"
            "QPushButton:hover { background: #4a2020; }"
            "QPushButton:disabled { background: {{color.button.danger.background.disabled}}; color: {{color.button.danger.foreground.disabled}}; border-color: {{color.button.danger.border.disabled}}; }");
        // Only enable when actually connected; subscribe so disconnect/reconnect
        // disables/re-enables the button without the user having to reopen the
        // dialog. rebootRadio() also early-returns on disconnected, but the
        // disabled state makes the affordance discoverable rather than silent.
        // F3 (#4448): also gate on the backend supporting a client reboot — HL2
        // is RX-only with no reboot command, and offering it would send a
        // meaningless command and trigger a forced disconnect.
        rebootBtn->setEnabled(m_model->isConnected() && m_model->backendCapabilities().canReboot);
        connect(m_model, &RadioModel::connectionStateChanged, rebootBtn,
                [this, rebootBtn](bool connected) {
            rebootBtn->setEnabled(connected && m_model->backendCapabilities().canReboot);
        });
        connect(rebootBtn, &QPushButton::clicked, this, [this] {
            const bool wan = m_model->isWan();
            const QString body = wan
                ? QStringLiteral("Reboot the connected radio now?\n\n"
                                 "AetherSDR will disconnect. SmartLink/WAN sessions "
                                 "do not auto-reconnect today — you will need to "
                                 "reconnect manually once the radio finishes booting.")
                : QStringLiteral("Reboot the connected radio now?\n\n"
                                 "AetherSDR will disconnect and automatically reconnect "
                                 "once the radio finishes booting.");
            const auto ret = QMessageBox::warning(
                this,
                QStringLiteral("Reboot Radio"),
                body,
                QMessageBox::Ok | QMessageBox::Cancel,
                QMessageBox::Cancel);
            if (ret == QMessageBox::Ok) {
                m_model->rebootRadio();
                close();
            }
        });
        grid->addWidget(makeInfoField(QStringLiteral("Reboot:"), rebootBtn,
                                      kInfoLeftLabelWidth),
                        3, 0);

        connect(m_model, &RadioModel::infoChanged, this, [this] {
            if (m_serialLabel) {
                m_serialLabel->setText(radioSerialNumber(m_model));
            }
            if (m_hwVersionLabel) {
                m_hwVersionLabel->setText(prefixedVersion(m_model->version()));
            }
            if (m_optionsLabel) {
                m_optionsLabel->setText(radioOptionsText(m_model));
            }
        });

        for (auto* lbl : group->findChildren<QLabel*>()) {
            if (lbl->styleSheet().isEmpty())
                lbl->setStyleSheet(kLabelStyle);
        }

        vbox->addWidget(group);
    }

    // Radio Identification group
    {
        auto* group = new QGroupBox("Radio Identification");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);
        grid->setColumnStretch(0, 1);
        grid->setColumnStretch(1, 1);

        m_modelLabel = new QLabel(displayOrDash(m_model->model()));
        m_modelLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("Model"),
                                              QStringLiteral("Model:"),
                                              m_modelLabel),
                        0, 0);

        // Initial nickname text. For a non-Flex radio the name lives in
        // AppSettings (keyed by serial/MAC), not on the radio, so read it back
        // from there — otherwise the field would show the model default even
        // after the operator set a custom name. Flex keeps its existing
        // model-sourced behaviour. This must gate on exactly the same
        // "is it Flex?" test as the editingFinished handler below: gating the
        // read on family=="hl2" while the write covers every non-Flex family
        // would persist a name for e.g. kiwi that the field then never shows.
        QString initialNickname = m_model->nickname().isEmpty()
            ? m_model->name() : m_model->nickname();
        {
            const RadioInfo info = m_model->lastRadioInfo();
            if (!hl2::Hl2Discovery::nicknameLivesOnRadio(info))
                initialNickname = hl2::Hl2Discovery::effectiveNickname(
                    info.family, info.serial,
                    info.model.isEmpty() ? m_model->name() : info.model);
        }
        m_nicknameEdit = new QLineEdit(initialNickname);
        m_nicknameEdit->setStyleSheet(kEditStyle);
        grid->addWidget(makeInfoField(QStringLiteral("Nickname:"), m_nicknameEdit,
                                      kInfoRightLabelWidth),
                        0, 1);

        m_callsignEdit = new QLineEdit(m_model->callsign());
        m_callsignEdit->setStyleSheet(kEditStyle);
        // Named for the screen reader and for the automation bridge, which
        // resolves controls by objectName / class / accessibleName. Both of
        // these fields were anonymous QLineEdits among many, so neither could
        // be driven in a test nor announced to a screen reader.
        m_callsignEdit->setAccessibleName(tr("Station callsign"));
        m_callsignEdit->setAccessibleDescription(
            tr("Your callsign, used for PSK Reporter, WSPR and spotting"));
        m_nicknameEdit->setAccessibleName(tr("Radio nickname"));
        grid->addWidget(makeInfoField(QStringLiteral("Callsign:"), m_callsignEdit),
                        1, 0);

        connect(m_nicknameEdit, &QLineEdit::editingFinished, this, [this] {
            const RadioInfo info = m_model->lastRadioInfo();
            // Only FlexRadio has an on-radio name store ("radio name" command).
            // Every other family (HL2, the sim demo, any future non-Flex backend)
            // has no wire to store a name, so the "radio name" command is a silent
            // no-op there. For those, persist the operator's nickname client-side,
            // keyed by the radio's stable serial, so discovery shows it in the
            // picker on the next sweep and RadioSetup reads it back on reopen.
            if (hl2::Hl2Discovery::nicknameLivesOnRadio(info)) {
                m_model->sendCommand("radio name " + m_nicknameEdit->text());
            } else {
                // Commits eagerly inside; don't rely on the shutdown save.
                hl2::Hl2Discovery::setNickname(info.family, info.serial,
                                               m_nicknameEdit->text());
            }
        });
        connect(m_callsignEdit, &QLineEdit::editingFinished, this, [this] {
            // Persist client-side on EVERY family, then additionally write the
            // radio's own copy when there is one to write.
            //
            // This used to be the sendCommand alone. On anything but a Flex that
            // is text nobody is listening for: the edit was accepted, went
            // nowhere, and the field read back blank on reopen — while PSK
            // Reporter, the WSPR beacon and QRZ own-callsign lookup all behaved
            // as if the station had no identity. See RadioModel::callsign().
            //
            // Order matters. setStationCallsign() emits callsignChanged, and
            // RadioModel::callsign() prefers the radio's value, so on a Flex the
            // signal must not fire before the radio has been told — otherwise a
            // corrected callsign would publish the OLD radio value and listeners
            // would restart against it. Send first, persist second.
            const QString entered = m_callsignEdit->text().trimmed().toUpper();
            // Empty is "no change", never "erase". Before the station-callsign
            // setting existed, blanking this field sent `radio callsign ` with
            // an empty argument and was otherwise harmless; now it would ALSO
            // wipe a persisted setting that PSK Reporter, the WSPR beacon and
            // QRZ lookup all read. A silent wipe of persisted state is not an
            // acceptable cost for a stray keystroke. (PR #4537 review.)
            if (entered.isEmpty()) {
                m_callsignEdit->setText(m_model->callsign());
                return;
            }
            if (m_model->usesFlexCommandPlane()) {
                m_model->sendCommand("radio callsign " + entered);
            }
            m_model->setStationCallsign(entered);
            m_callsignEdit->setText(entered);
        });

        connect(m_model, &RadioModel::infoChanged, this, [this] {
            if (m_modelLabel) {
                m_modelLabel->setText(displayOrDash(m_model->model()));
            }
        });

        QString stationVal = AppSettings::instance().value("StationName", "").toString();
        auto* stationEdit = new QLineEdit(
            stationVal.isEmpty() ? QSysInfo::machineHostName() : stationVal);
        stationEdit->setStyleSheet(kEditStyle);
        stationEdit->setToolTip("Identifies this client to other Multi-Flex stations.\n"
                                "Defaults to OS hostname if empty.");
        grid->addWidget(makeInfoField(QStringLiteral("Station Name:"), stationEdit,
                                      kInfoRightLabelWidth),
                        1, 1);
        connect(stationEdit, &QLineEdit::editingFinished, this, [this, stationEdit] {
            auto& s = AppSettings::instance();
            s.setValue("StationName", stationEdit->text());
            s.save();
            m_model->sendCommand("client station " + stationEdit->text());
        });

        for (auto* lbl : group->findChildren<QLabel*>()) {
            if (lbl->styleSheet().isEmpty())
                lbl->setStyleSheet(kLabelStyle);
        }

        vbox->addWidget(group);
    }

    // License Info group (matches SmartSDR Radio Setup → License Info section)
    {
        auto* group = new QGroupBox("License Info");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);
        grid->setColumnStretch(0, 1);
        grid->setColumnStretch(1, 1);

        // Row 0: Subscription | Expiration
        m_licSubscriptionLabel = new QLabel(
            m_model->licenseSubscription().isEmpty() ? "—" : m_model->licenseSubscription());
        m_licSubscriptionLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("Subscription"),
                                              QStringLiteral("Subscription:"),
                                              m_licSubscriptionLabel),
                        0, 0);

        m_licExpirationLabel = new QLabel(
            m_model->licenseExpirationDate().isEmpty() ? "—" : m_model->licenseExpirationDate());
        m_licExpirationLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("Expiration"),
                                              QStringLiteral("Expiration:"),
                                              m_licExpirationLabel,
                                              kInfoRightLabelWidth),
                        0, 1);

        // Row 1: Radio ID | Licensed version
        m_licRadioIdLabel = new QLabel(
            m_model->licenseRadioId().isEmpty() ? "—" : m_model->licenseRadioId());
        m_licRadioIdLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("Radio ID"),
                                              QStringLiteral("Radio ID:"),
                                              m_licRadioIdLabel),
                        1, 0);

        m_licMaxVersionLabel = new QLabel(
            m_model->licenseMaxVersion().isEmpty() ? "—" : m_model->licenseMaxVersion());
        m_licMaxVersionLabel->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableInfoField(QStringLiteral("Licensed version"),
                                              QStringLiteral("Licensed version:"),
                                              m_licMaxVersionLabel,
                                              kInfoRightLabelWidth),
                        1, 1);

        for (auto* lbl : group->findChildren<QLabel*>()) {
            if (lbl->styleSheet().isEmpty())
                lbl->setStyleSheet(kLabelStyle);
        }

        // Update labels live if license status arrives after dialog opens
        connect(m_model, &RadioModel::infoChanged, this, [this] {
            if (!m_model->licenseSubscription().isEmpty())
                m_licSubscriptionLabel->setText(m_model->licenseSubscription());
            if (!m_model->licenseExpirationDate().isEmpty())
                m_licExpirationLabel->setText(m_model->licenseExpirationDate());
            if (!m_model->licenseRadioId().isEmpty())
                m_licRadioIdLabel->setText(m_model->licenseRadioId());
            if (!m_model->licenseMaxVersion().isEmpty())
                m_licMaxVersionLabel->setText(m_model->licenseMaxVersion());
        });

        vbox->addWidget(group);
    }

    // Firmware Update group
    {
        auto* group = new QGroupBox("Firmware Update");
        group->setStyleSheet(kGroupStyle);
        auto* vlay = new QVBoxLayout(group);
        vlay->setSpacing(6);

        // Current version row
        auto* infoRow = new QHBoxLayout;
        infoRow->addWidget(new QLabel("FW Version:"));
        auto* curFw = new QLabel(displayOrDash(m_model->softwareVersion()));
        curFw->setStyleSheet(kValueStyle);
        infoRow->addWidget(makeCopyableValueLabel(QStringLiteral("FW Version"), curFw), 1);
        vlay->addLayout(infoRow);

        connect(m_model, &RadioModel::infoChanged, this, [this, curFw] {
            curFw->setText(displayOrDash(m_model->softwareVersion()));
        });

        // Progress bar
        m_fwProgress = new QProgressBar;
        m_fwProgress->setRange(0, 100);
        m_fwProgress->setValue(0);
        m_fwProgress->setTextVisible(true);
        m_fwProgress->setFixedHeight(20);
        AetherSDR::ThemeManager::instance().applyStyleSheet(m_fwProgress, "QProgressBar { text-align: center; font-size: 11px; color: {{color.text.primary}};"
            " background: {{color.background.1}}; border: 1px solid #2e4e6e; border-radius: 3px; }"
            "QProgressBar::chunk { background: {{color.accent}}; }");
        m_fwProgress->hide();
        vlay->addWidget(m_fwProgress);

        // Status label (multi-line)
        m_fwStatusLabel = new QLabel("");
        m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }");
        m_fwStatusLabel->setWordWrap(true);
        vlay->addWidget(m_fwStatusLabel);

        // Button row
        auto* btnRow = new QHBoxLayout;
        auto* checkBtn = new QPushButton("Check for Update");
        AetherSDR::ThemeManager::instance().applyStyleSheet(checkBtn, "QPushButton { background: {{color.background.1}}; color: {{color.text.primary}}; border: 1px solid #2e4e6e;"
            " border-radius: 3px; padding: 4px 8px; }"
            "QPushButton:hover { background: {{color.background.1}}; }");
        btnRow->addWidget(checkBtn);

        auto* browseBtn = new QPushButton("Select Installer...");
        browseBtn->setStyleSheet(checkBtn->styleSheet());
        btnRow->addWidget(browseBtn);

        m_fwUploadBtn = new QPushButton("Upload Firmware");
        m_fwUploadBtn->setEnabled(false);
        AetherSDR::ThemeManager::instance().applyStyleSheet(m_fwUploadBtn, "QPushButton { background: #1a3a1a; color: #80e080; border: 1px solid #2e6e2e;"
            " border-radius: 3px; padding: 4px 8px; }"
            "QPushButton:hover { background: #2a4a2a; }"
            "QPushButton:disabled { background: {{color.button.background.disabled}}; color: {{color.button.foreground.disabled}}; border-color: {{color.button.border.disabled}}; }");
        btnRow->addWidget(m_fwUploadBtn);
        vlay->addLayout(btnRow);

        // ── Stager wiring ─────────────────────────────────────────────
        m_stager = new FirmwareStager(this);

        connect(checkBtn, &QPushButton::clicked, this, [this, checkBtn] {
            checkBtn->setEnabled(false);
            m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }");
            m_stager->checkForUpdate(m_model->softwareVersion());
        });

        connect(m_stager, &FirmwareStager::updateCheckComplete, this,
            [this, checkBtn](const QString& latest, bool avail) {
            checkBtn->setEnabled(true);
            if (avail) {
                m_fwStatusLabel->setStyleSheet("QLabel { color: #f0c040; font-size: 10px; }");
                m_fwStatusLabel->setText(QString(
                    "Update available: v%1\n"
                    "Download the SmartSDR installer from flexradio.com,\n"
                    "then click 'Select Installer...' to stage it.").arg(latest));
            } else {
                m_fwStatusLabel->setStyleSheet("QLabel { color: #80e080; font-size: 10px; }");
                m_fwStatusLabel->setText("Firmware is up to date (v" + latest + ").");
            }
        });

        connect(m_stager, &FirmwareStager::updateCheckFailed, this,
            [this, checkBtn](const QString& err) {
            checkBtn->setEnabled(true);
            m_fwStatusLabel->setStyleSheet("QLabel { color: #e08080; font-size: 10px; }");
            m_fwStatusLabel->setText(err);
        });

        connect(m_stager, &FirmwareStager::stageProgress, this,
            [this](int pct, const QString& status) {
            m_fwProgress->setValue(pct);
            m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }");
            m_fwStatusLabel->setText(status);
        });

        connect(m_stager, &FirmwareStager::stageComplete, this,
            [this](const QString& path, const QString&) {
            m_fwFilePath = path;
            m_fwUploadBtn->setEnabled(true);
            m_fwStatusLabel->setStyleSheet("QLabel { color: #80e080; font-size: 10px; }");
        });

        connect(m_stager, &FirmwareStager::stageFailed, this,
            [this](const QString& err) {
            m_fwProgress->hide();
            m_fwStatusLabel->setStyleSheet("QLabel { color: #e08080; font-size: 10px; }");
            m_fwStatusLabel->setText(err);
        });

        // ── Browse / select installer manually ────────────────────────
        // Accepts the SmartSDR installer the user has already downloaded
        // from FlexRadio (.msi for v4.2+, .exe for older releases) or a
        // pre-extracted .ssdr file. The stager auto-detects which.
        connect(browseBtn, &QPushButton::clicked, this, [this] {
            const QString path = QFileDialog::getOpenFileName(
                this, "Select SmartSDR Installer or Firmware File", QString(),
                "SmartSDR installer or firmware (*.msi *.exe *.ssdr);;"
                "MSI installer (*.msi);;"
                "EXE installer (*.exe);;"
                "Extracted firmware (*.ssdr);;"
                "All files (*)");
            if (path.isEmpty()) return;

            m_fwFilePath.clear();
            m_fwUploadBtn->setEnabled(false);
            m_fwProgress->show();
            m_fwProgress->setValue(0);
            m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }");
            m_fwStatusLabel->setText("Preparing firmware from " + QFileInfo(path).fileName() + "...");

            // The stager emits stageProgress / stageComplete / stageFailed.
            // Existing slots wire to those (set above) so we just kick it off.
            m_stager->stageFromLocalFile(path,
                FirmwareStager::modelToFamily(m_model->model()));
        });

        // ── Upload ────────────────────────────────────────────────────
        connect(m_fwUploadBtn, &QPushButton::clicked, this, [this] {
            if (m_fwFilePath.isEmpty()) return;

            const auto reply = QMessageBox::warning(this, "Firmware Update",
                QString("Upload %1 to %2?\n\n"
                        "The radio will reboot after the update.\n"
                        "Do not disconnect during the upload.")
                    .arg(QFileInfo(m_fwFilePath).fileName(), m_model->model()),
                QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
            if (reply != QMessageBox::Ok) return;

            if (!m_uploader)
                m_uploader = new FirmwareUploader(m_model, this);

            m_fwProgress->show();
            m_fwProgress->setValue(0);
            m_fwUploadBtn->setEnabled(false);
            m_fwStatusLabel->setStyleSheet("QLabel { color: #6888a0; font-size: 10px; }");

            connect(m_uploader, &FirmwareUploader::progressChanged, this,
                [this](int pct, const QString& status) {
                    m_fwProgress->setValue(pct);
                    m_fwStatusLabel->setText(status);
                });
            connect(m_uploader, &FirmwareUploader::finished, this,
                [this](bool ok, const QString& msg) {
                    m_fwStatusLabel->setText(msg);
                    m_fwUploadBtn->setEnabled(!ok);
                    if (ok) {
                        m_fwProgress->setValue(100);
                        m_fwStatusLabel->setStyleSheet("QLabel { color: #80e080; font-size: 10px; }");
                    } else {
                        m_fwProgress->hide();
                        m_fwStatusLabel->setStyleSheet("QLabel { color: #e08080; font-size: 10px; }");
                    }
                });

            m_uploader->upload(m_fwFilePath);
        });

        for (auto* lbl : group->findChildren<QLabel*>()) {
            if (lbl->styleSheet().isEmpty())
                lbl->setStyleSheet(kLabelStyle);
        }

        vbox->addWidget(group);
    }

    // Firmware disclaimer
    auto* disclaimer = new QLabel(
        "⚠ CAUTION: Firmware update is currently a highly experimental feature. "
        "Use at your own risk. At this time we still recommend updating "
        "via the SmartSDR Windows application.");
    disclaimer->setWordWrap(true);
    disclaimer->setStyleSheet(
        "QLabel { color: #c08040; font-size: 11px; font-style: italic;"
        " padding: 4px 8px; }");
    vbox->addWidget(disclaimer);

    vbox->addStretch(1);
    return page;
}


QWidget* RadioSetupDialog::buildNetworkTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    // Network group
    {
        auto* group = new QGroupBox("Network");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);
        grid->setColumnStretch(1, 1);
        grid->setColumnStretch(3, 1);

        grid->addWidget(new QLabel("IP Address:"), 0, 0);
        auto* ipLbl = new QLabel(displayOrDash(m_model->ip()));
        ipLbl->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableValueLabel(QStringLiteral("IP Address"), ipLbl), 0, 1);

        grid->addWidget(new QLabel("Subnet Mask:"), 0, 2);
        auto* maskLbl = new QLabel(displayOrDash(m_model->netmask()));
        maskLbl->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableValueLabel(QStringLiteral("Subnet Mask"), maskLbl), 0, 3);

        grid->addWidget(new QLabel("MAC Address:"), 1, 0);
        auto* macLbl = new QLabel(displayOrDash(m_model->mac()));
        macLbl->setStyleSheet(kValueStyle);
        grid->addWidget(makeCopyableValueLabel(QStringLiteral("MAC Address"), macLbl), 1, 1);

        connect(m_model, &RadioModel::infoChanged, this, [this, ipLbl, maskLbl, macLbl] {
            ipLbl->setText(displayOrDash(m_model->ip()));
            maskLbl->setText(displayOrDash(m_model->netmask()));
            macLbl->setText(displayOrDash(m_model->mac()));
        });

        for (auto* lbl : group->findChildren<QLabel*>())
            if (lbl->styleSheet().isEmpty()) lbl->setStyleSheet(kLabelStyle);

        vbox->addWidget(group);
    }

    // Advanced group
    {
        auto* group = new QGroupBox("Advanced");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        grid->addWidget(new QLabel("Enforce Private IP Connections:"), 0, 0);
        auto* enforceBtn = new QPushButton(m_model->enforcePrivateIp() ? "Enabled" : "Disabled");
        enforceBtn->setCheckable(true);
        enforceBtn->setChecked(m_model->enforcePrivateIp());
        AetherSDR::ThemeManager::instance().applyStyleSheet(enforceBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
            "padding: 3px 10px; }"
            "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; "
            "border: 1px solid #20a040; }");
        connect(enforceBtn, &QPushButton::toggled, this, [this, enforceBtn](bool on) {
            enforceBtn->setText(on ? "Enabled" : "Disabled");
            m_model->sendCommand(
                QString("radio set enforce_private_ip_connections=%1").arg(on ? 1 : 0));
        });
        grid->addWidget(enforceBtn, 0, 1);

        // 128-bit hex token generator — plenty for a local same-user secret.
        auto genToken = []() -> QString {
            quint64 a = QRandomGenerator::global()->generate64();
            quint64 b = QRandomGenerator::global()->generate64();
            return QStringLiteral("%1%2")
                .arg(a, 16, 16, QLatin1Char('0'))
                .arg(b, 16, 16, QLatin1Char('0'));
        };
        // The token field is created here (before the toggle) so the toggle's
        // enable handler can auto-fill it — enabling the bridge without a
        // token would leave it open, which defeats the point. The token lives
        // in the OS secret store and reads ASYNCHRONOUSLY; tokenLoaded gates
        // the auto-mint so a toggle fired before the read lands can't clobber
        // an existing token.
        auto* tokenEdit = new QLineEdit;
        tokenEdit->setReadOnly(true);
        tokenEdit->setPlaceholderText("(loading…)");
        tokenEdit->setToolTip(
            "Paste this into your AI assistant's MCP server config as the\n"
            "AETHER_MCP_TOKEN environment variable. Only a client holding\n"
            "this token can drive the radio. Stored in your OS secret store.");
        AetherSDR::ThemeManager::instance().applyStyleSheet(tokenEdit,
            "QLineEdit { background: {{color.background.0}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-family: monospace; "
            "font-size: 11px; padding: 3px 6px; }");
        auto tokenLoaded = std::make_shared<bool>(false);
        AutomationBridgeSettings::loadToken(this,
            [tokenEdit, tokenLoaded](const QString& tok) {
                tokenEdit->setText(tok);
                tokenEdit->setPlaceholderText(
                    "(none — enable the bridge or click Rotate)");
                *tokenLoaded = true;
            });

        // Agent automation bridge (#3646) — the endpoint MCP clients (AI
        // coding assistants) connect to for validating changes against the
        // running app. Off by default; the operator opts in here. The
        // AETHER_AUTOMATION launch env var still force-enables it regardless
        // of this toggle (headless/CI path), so we reflect either source.
        {
            grid->addWidget(new QLabel("Agent Automation (MCP):"), 1, 0);
            const bool bridgeOn = AutomationBridgeSettings::enabled()
                || qEnvironmentVariableIsSet("AETHER_AUTOMATION");
            auto* mcpBtn = new QPushButton(bridgeOn ? "Enabled" : "Disabled");
            mcpBtn->setCheckable(true);
            mcpBtn->setChecked(bridgeOn);
            AetherSDR::ThemeManager::instance().applyStyleSheet(mcpBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
                "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
                "padding: 3px 10px; }"
                "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; "
                "border: 1px solid #20a040; }");
            mcpBtn->setToolTip(
                "Enable the in-app automation bridge so an AI coding assistant "
                "(via the MCP server, tools/aether_mcp.py) can introspect and\n"
                "drive this app to validate changes. Off by default. Transmit-"
                "keying controls stay blocked unless the app is launched with\n"
                "AETHER_AUTOMATION_ALLOW_TX. See docs/automation-bridge.md.");
            // Env-var force-enable wins and can't be turned off from the UI —
            // make that visible rather than letting a toggle silently no-op.
            if (qEnvironmentVariableIsSet("AETHER_AUTOMATION")) {
                mcpBtn->setEnabled(false);
                mcpBtn->setToolTip(mcpBtn->toolTip()
                    + "\n\nForced on by the AETHER_AUTOMATION launch environment variable.");
            }
            connect(mcpBtn, &QPushButton::toggled, this,
                    [this, mcpBtn, tokenEdit, genToken, tokenLoaded](bool on) {
                mcpBtn->setText(on ? "Enabled" : "Disabled");
                AutomationBridgeSettings::setEnabled(on);
                // Enabling with no token yet → mint one so the bridge is never
                // exposed without auth. Only when the async token read has
                // landed (tokenLoaded) so we can't clobber an existing token.
                if (on && *tokenLoaded && tokenEdit->text().isEmpty()) {
                    const QString tok = genToken();
                    tokenEdit->setText(tok);
                    AutomationBridgeSettings::saveToken(tok);
                    QGuiApplication::clipboard()->setText(tok);
                    emit automationBridgeTokenRotated(tok);
                }
                emit automationBridgeToggled(on);
            });
            grid->addWidget(mcpBtn, 1, 1);
        }

        // Access token row — display the shared secret with Copy + Rotate.
        // Rotate makes a new token and applies it immediately, locking out any
        // client still using the old one.
        {
            grid->addWidget(new QLabel("Access Token:"), 2, 0);
            auto* tokenRow = new QWidget;
            auto* tokLay = new QHBoxLayout(tokenRow);
            tokLay->setContentsMargins(0, 0, 0, 0);
            tokLay->setSpacing(6);

            auto* copyBtn = new QPushButton("Copy");
            auto* rotateBtn = new QPushButton("Rotate");
            for (auto* b : {copyBtn, rotateBtn})
                AetherSDR::ThemeManager::instance().applyStyleSheet(b,
                    "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
                    "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; "
                    "padding: 3px 10px; }");
            copyBtn->setToolTip("Copy the token to the clipboard.");
            rotateBtn->setToolTip(
                "Generate a new token and apply it immediately. Any client still\n"
                "using the old token is locked out until you update its config.");

            connect(copyBtn, &QPushButton::clicked, this, [tokenEdit]() {
                if (!tokenEdit->text().isEmpty())
                    QGuiApplication::clipboard()->setText(tokenEdit->text());
            });
            connect(rotateBtn, &QPushButton::clicked, this,
                    [this, tokenEdit, genToken]() {
                const QString tok = genToken();
                tokenEdit->setText(tok);
                AutomationBridgeSettings::saveToken(tok);
                QGuiApplication::clipboard()->setText(tok);
                emit automationBridgeTokenRotated(tok);
            });

            tokLay->addWidget(tokenEdit, 1);
            tokLay->addWidget(copyBtn);
            tokLay->addWidget(rotateBtn);
            grid->addWidget(tokenRow, 2, 1);
        }

        // Allow TX via MCP — the transmit-keying guard. Off by default; the
        // bridge refuses MOX/PTT/TUNE/ATU/CWX unless this (or the
        // AETHER_AUTOMATION_ALLOW_TX env var) is set. Checking the box the
        // first time raises a confirmation with the operator-responsibility
        // warning; once confirmed the choice persists and is not re-prompted.
        {
            grid->addWidget(new QLabel("Allow TX via MCP:"), 3, 0);
            auto* txCheck = new QCheckBox("Enable transmit control");
            const bool envForcesTx = qEnvironmentVariableIsSet("AETHER_AUTOMATION_ALLOW_TX");
            const bool envBlocksTx = qEnvironmentVariableIsSet("AETHER_AUTOMATION_NO_TX");
            txCheck->setChecked(!envBlocksTx
                                && (AutomationBridgeSettings::txAllowed() || envForcesTx));
            txCheck->setToolTip(
                "Let an MCP client key the transmitter (MOX/PTT/TUNE/ATU/CWX).\n"
                "OFF by default — the bridge blocks all transmit-keying otherwise.\n"
                "Bridge-originated TX is limited by a force-unkey watchdog. You are\n"
                "responsible for anything transmitted. See docs/automation-bridge.md.");
            AetherSDR::ThemeManager::instance().applyStyleSheet(txCheck,
                "QCheckBox { color: {{color.text.primary}}; font-size: 11px; }"
                "QCheckBox::indicator { width: 14px; height: 14px; }");
            if (envBlocksTx) {
                txCheck->setEnabled(false);
                txCheck->setToolTip(txCheck->toolTip()
                    + "\n\nPinned off by the AETHER_AUTOMATION_NO_TX launch variable.");
            } else if (envForcesTx) {
                txCheck->setEnabled(false);
                txCheck->setToolTip(txCheck->toolTip()
                    + "\n\nForced on by the AETHER_AUTOMATION_ALLOW_TX launch variable.");
            }
            connect(txCheck, &QCheckBox::toggled, this, [this, txCheck](bool on) {
                if (on && !AutomationBridgeSettings::txAck()) {
                    // First-time enable → confirm. Operator must acknowledge.
                    QMessageBox box(this);
                    box.setIcon(QMessageBox::Warning);
                    box.setWindowTitle("Allow TX via MCP?");
                    box.setText("Allow an AI assistant / MCP client to key the transmitter?");
                    box.setInformativeText(
                        "This lets any MCP client holding the access token drive "
                        "transmit-keying controls — MOX/PTT, TUNE, the ATU, and CWX "
                        "send — on your radio. Automated software will be able to put "
                        "a signal on the air.\n\n"
                        "A force-unkey watchdog limits bridge-originated TX, but it is "
                        "a backstop, not a guarantee. You, the operator, are "
                        "ultimately responsible for all transmissions from your station "
                        "— including their content, timing, frequency, power, and "
                        "compliance with your license and local regulations.\n\n"
                        "Only enable this if you understand and accept that "
                        "responsibility. You can turn it off again at any time.");
                    auto* confirm = box.addButton("Confirm — allow TX", QMessageBox::AcceptRole);
                    box.addButton("Cancel", QMessageBox::RejectRole);
                    box.setDefaultButton(qobject_cast<QPushButton*>(box.buttons().value(1)));
                    box.exec();
                    if (box.clickedButton() != confirm) {
                        // Cancelled — revert without persisting or emitting.
                        QSignalBlocker blocker(txCheck);
                        txCheck->setChecked(false);
                        return;
                    }
                    // Confirmed — remember the acknowledgement so we never
                    // prompt again on a future enable.
                    AutomationBridgeSettings::setTxAck(true);
                }
                AutomationBridgeSettings::setTxAllowed(on);
                emit automationBridgeTxAllowedChanged(on);
            });
            grid->addWidget(txCheck, 3, 1);
        }

        // Observe only — the read-only gate (#4188 area 6). When checked, the
        // bridge refuses every mutating verb (set/invoke/connect/tune/capture…)
        // and answers only pure-introspection reads. Enforced in the bridge, so
        // no MCP client can flip it off. Lets the operator start the app, arm
        // observe-only, then start the MCP server for a look-but-don't-touch
        // session. An env override (AETHER_AUTOMATION_READONLY) pins it for
        // headless/CI runs.
        {
            grid->addWidget(new QLabel("Observe only:"), 4, 0);
            auto* roCheck = new QCheckBox("Read-only (block all driving)");
            roCheck->setObjectName(QStringLiteral("automationReadOnlyCheck"));
            const bool envForcesRo = qEnvironmentVariableIsSet("AETHER_AUTOMATION_READONLY");
            roCheck->setChecked(AutomationBridgeSettings::readOnly() || envForcesRo);
            roCheck->setToolTip(
                "Make the bridge observe-only: MCP clients can read state\n"
                "(ping/whoami/get/dumpTree/grab, read-only log and streams\n"
                "actions, floors, and hitTest)\n"
                "but every mutating verb is refused. Enforced in the app, so a\n"
                "client cannot bypass it. Toggle takes effect immediately on the\n"
                "running bridge. See docs/automation-bridge.md.");
            AetherSDR::ThemeManager::instance().applyStyleSheet(roCheck,
                "QCheckBox { color: {{color.text.primary}}; font-size: 11px; }"
                "QCheckBox::indicator { width: 14px; height: 14px; }");
            if (envForcesRo) {
                roCheck->setEnabled(false);
                roCheck->setToolTip(roCheck->toolTip()
                    + "\n\nForced on by the AETHER_AUTOMATION_READONLY launch variable.");
            }
            connect(roCheck, &QCheckBox::toggled, this, [this](bool on) {
                AutomationBridgeSettings::setReadOnly(on);
                emit automationBridgeReadOnlyChanged(on);
            });
            grid->addWidget(roCheck, 4, 1);
        }

        grid->addWidget(new QLabel("Network MTU:"), 5, 0);
        auto* mtuSpin = new QSpinBox;
        mtuSpin->setRange(576, 9000);
        mtuSpin->setValue(AppSettings::instance().value("NetworkMtu", "1450").toInt());
        mtuSpin->setSuffix(" bytes");
        mtuSpin->setToolTip("Maximum Transmission Unit for VITA-49 UDP packets.\nDefault: 1450 (compatible with most VPN/SD-WAN tunnels).");
        connect(mtuSpin, QOverload<int>::of(&QSpinBox::valueChanged), this, [this](int val) {
            m_model->sendCommand(
                QString("client set enforce_network_mtu=1 network_mtu=%1").arg(val));
            AppSettings::instance().setValue("NetworkMtu", QString::number(val));
            AppSettings::instance().save();
        });
        grid->addWidget(mtuSpin, 5, 1);

        // VITA-49 UDP receive buffer (SO_RCVBUF). Snap-to-preset slider; the
        // kernel clamps the grant at net.core.rmem_max, so we show the granted
        // size live (PanadapterStream::receiveBufferApplied) — the slider never
        // claims more than the system actually gives. (#3810)
        static const int kRcvBufPresets[] = {
            256 * 1024, 512 * 1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024
        };
        constexpr int kRcvBufPresetCount = 5;
        auto fmtBytes = [](int b) -> QString {
            if (b >= 1024 * 1024)
                return QStringLiteral("%1 MB").arg(
                    QString::number(b / (1024.0 * 1024.0), 'g', 3));
            return QStringLiteral("%1 KB").arg(b / 1024);
        };

        grid->addWidget(new QLabel("VITA-49 RX buffer:"), 6, 0);
        auto* bufRow = new QWidget;
        auto* bufLay = new QHBoxLayout(bufRow);
        bufLay->setContentsMargins(0, 0, 0, 0);
        bufLay->setSpacing(8);
        auto* bufSlider = new QSlider(Qt::Horizontal);
        bufSlider->setRange(0, kRcvBufPresetCount - 1);
        bufSlider->setSingleStep(1);
        bufSlider->setPageStep(1);
        bufSlider->setTickPosition(QSlider::TicksBelow);
        bufSlider->setTickInterval(1);
        bufSlider->setToolTip(
            "Kernel receive buffer for the VITA-49 stream socket.\n"
            "Larger absorbs panadapter/waterfall bursts so they aren't dropped\n"
            "(dropped packets look like network-stat dips). Default 4 MB.\n"
            "The system caps this at net.core.rmem_max — see the granted size.");
        // Initial position: smallest preset >= the persisted request.
        {
            const int cur = AetherSDR::NetworkSettings::vitaReceiveBufferBytes();
            int idx = kRcvBufPresetCount - 1;
            for (int i = 0; i < kRcvBufPresetCount; ++i)
                if (kRcvBufPresets[i] >= cur) { idx = i; break; }
            bufSlider->setValue(idx);
        }
        auto* bufValLabel = new QLabel(fmtBytes(kRcvBufPresets[bufSlider->value()]));
        bufValLabel->setMinimumWidth(48);
        bufLay->addWidget(bufSlider, 1);
        bufLay->addWidget(bufValLabel);
        grid->addWidget(bufRow, 6, 1);

        auto* bufGrantedLabel = new QLabel;
        if (m_model && m_model->panStream()) {
            const int g = m_model->panStream()->grantedReceiveBufferBytes();
            bufGrantedLabel->setText(g > 0 ? QString("granted: %1").arg(fmtBytes(g))
                                           : QStringLiteral("granted: — (applies on connect)"));
        }
        grid->addWidget(bufGrantedLabel, 7, 1);

        connect(bufSlider, &QSlider::valueChanged, this,
                [this, bufValLabel, fmtBytes](int idx) {
            const int bytes = kRcvBufPresets[std::clamp(idx, 0, kRcvBufPresetCount - 1)];
            bufValLabel->setText(fmtBytes(bytes));
            AetherSDR::NetworkSettings::setVitaReceiveBufferBytes(bytes);
            // Re-apply live on the network worker thread (the socket lives there).
            if (m_model && m_model->panStream()) {
                QMetaObject::invokeMethod(
                    m_model->panStream(),
                    [stream = m_model->panStream(), bytes]() {
                        stream->setReceiveBufferSizeBytes(bytes);
                    });
            }
        });
        // Live granted-size feedback (cross-thread → queued).
        if (m_model && m_model->panStream()) {
            connect(m_model->panStream(), &PanadapterStream::receiveBufferApplied, this,
                    [bufGrantedLabel, fmtBytes](int requested, int granted) {
                QString t = QString("granted: %1").arg(fmtBytes(granted));
                if (granted < requested)
                    t += QStringLiteral("  (capped by net.core.rmem_max)");
                bufGrantedLabel->setText(t);
            });
        }

        for (auto* lbl : group->findChildren<QLabel*>())
            if (lbl->styleSheet().isEmpty()) lbl->setStyleSheet(kLabelStyle);

        vbox->addWidget(group);
    }

    // DHCP / Static IP group
    vbox->addWidget(buildIpConfigGroup());

    vbox->addStretch(1);
    return page;
}

// Extracted to reduce lambda nesting depth in buildNetworkTab()
// (avoids GCC 13 internal compiler error on Ubuntu 24.04)
QGroupBox* RadioSetupDialog::buildIpConfigGroup()
{
    auto* group = new QGroupBox("IP Configuration");
    group->setStyleSheet(kGroupStyle);
    auto* gvbox = new QVBoxLayout(group);
    gvbox->setSpacing(6);

    auto* btnRow = new QHBoxLayout;
    btnRow->setSpacing(4);

    const bool isStatic = m_model->hasStaticIp();

    auto* dhcpBtn = new QPushButton("DHCP");
    dhcpBtn->setCheckable(true);
    dhcpBtn->setChecked(!isStatic);
    AetherSDR::ThemeManager::instance().applyStyleSheet(dhcpBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
        "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
        "padding: 4px 16px; }"
        "QPushButton:checked { background: {{color.background.2}}; color: {{color.text.primary}}; "
        "border: 1px solid {{color.accent.dim}}; }");
    btnRow->addWidget(dhcpBtn);

    auto* staticBtn = new QPushButton("Static");
    staticBtn->setCheckable(true);
    staticBtn->setChecked(isStatic);
    staticBtn->setStyleSheet(dhcpBtn->styleSheet());
    btnRow->addWidget(staticBtn);

    btnRow->addStretch(1);
    gvbox->addLayout(btnRow);

    auto* fieldsGrid = new QGridLayout;
    fieldsGrid->setSpacing(4);

    fieldsGrid->addWidget(new QLabel("IP Address:"), 0, 0);
    auto* staticIp = new QLineEdit(isStatic ? m_model->staticIp() : m_model->ip());
    staticIp->setStyleSheet(kEditStyle);
    staticIp->setEnabled(isStatic);
    fieldsGrid->addWidget(staticIp, 0, 1);

    fieldsGrid->addWidget(new QLabel("Mask:"), 1, 0);
    auto* staticMask = new QLineEdit(isStatic ? m_model->staticNetmask() : m_model->netmask());
    staticMask->setStyleSheet(kEditStyle);
    staticMask->setEnabled(isStatic);
    fieldsGrid->addWidget(staticMask, 1, 1);

    fieldsGrid->addWidget(new QLabel("Gateway:"), 2, 0);
    auto* staticGw = new QLineEdit(isStatic ? m_model->staticGateway() : m_model->gateway());
    staticGw->setStyleSheet(kEditStyle);
    staticGw->setEnabled(isStatic);
    fieldsGrid->addWidget(staticGw, 2, 1);

    for (auto* lbl : group->findChildren<QLabel*>())
        if (lbl->styleSheet().isEmpty()) lbl->setStyleSheet(kLabelStyle);

    gvbox->addLayout(fieldsGrid);

    auto* applyBtn = new QPushButton("Apply");
    applyBtn->setEnabled(false);
    AetherSDR::ThemeManager::instance().applyStyleSheet(applyBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
        "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
        "padding: 4px 16px; }"
        "QPushButton:hover { background: {{color.background.1}}; }");
    gvbox->addWidget(applyBtn, 0, Qt::AlignLeft);

    connect(dhcpBtn, &QPushButton::clicked, this,
            [dhcpBtn, staticBtn, staticIp, staticMask, staticGw, applyBtn] {
        dhcpBtn->setChecked(true);
        staticBtn->setChecked(false);
        staticIp->setEnabled(false);
        staticMask->setEnabled(false);
        staticGw->setEnabled(false);
        applyBtn->setEnabled(true);
    });
    connect(staticBtn, &QPushButton::clicked, this,
            [dhcpBtn, staticBtn, staticIp, staticMask, staticGw, applyBtn] {
        dhcpBtn->setChecked(false);
        staticBtn->setChecked(true);
        staticIp->setEnabled(true);
        staticMask->setEnabled(true);
        staticGw->setEnabled(true);
        applyBtn->setEnabled(true);
    });
    connect(applyBtn, &QPushButton::clicked, this,
            [this, dhcpBtn, staticIp, staticMask, staticGw, applyBtn] {
        if (dhcpBtn->isChecked()) {
            m_model->sendCommand("radio static_net_params reset");
            qDebug() << "RadioSetupDialog: network set to DHCP";
        } else {
            const QString cmd = QString("radio static_net_params ip=%1 gateway=%2 netmask=%3")
                .arg(staticIp->text(), staticGw->text(), staticMask->text());
            m_model->sendCommand(cmd);
            qDebug() << "RadioSetupDialog: static IP applied" << cmd;
        }
        applyBtn->setEnabled(false);
    });

    return group;
}
QWidget* RadioSetupDialog::buildGpsTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    // GPS installed status
    {
        const bool installed = (m_model->gpsStatus() != "Not Present"
                                && !m_model->gpsStatus().isEmpty());
        auto* statusLbl = new QLabel(installed ? "GPS is installed" : "GPS is not installed");
        statusLbl->setStyleSheet(installed
            ? "QLabel { color: #00c040; font-size: 16px; font-weight: bold; }"
            : "QLabel { color: #c04040; font-size: 16px; font-weight: bold; }");
        statusLbl->setAlignment(Qt::AlignCenter);
        vbox->addWidget(statusLbl);
        vbox->addSpacing(16);
    }

    // GPS data grid
    {
        auto* grid = new QGridLayout;
        grid->setSpacing(8);

        auto addField = [&](int row, int col, const QString& label, const QString& value) {
            auto* lbl = new QLabel(label);
            lbl->setStyleSheet(kLabelStyle);
            grid->addWidget(lbl, row, col * 2);
            auto* val = new QLabel(value);
            val->setStyleSheet(kValueStyle);
            grid->addWidget(val, row, col * 2 + 1);
        };

        addField(0, 0, "Latitude:",     m_model->gpsLat());
        addField(0, 1, "Longitude:",    m_model->gpsLon());
        addField(1, 0, "Grid Square:",  m_model->gpsGrid());
        addField(1, 1, "Altitude:",     m_model->gpsAltitude());
        addField(2, 0, "Sat Tracked:",  QString::number(m_model->gpsTracked()));
        addField(2, 1, "Sat Visible:",  QString::number(m_model->gpsVisible()));
        addField(3, 0, "Speed:",        m_model->gpsSpeed());
        addField(3, 1, "Freq Error:",   m_model->gpsFreqError());
        addField(4, 0, "Status:",       m_model->gpsStatus());
        addField(4, 1, "UTC Time:",     m_model->gpsTime());

        vbox->addLayout(grid);
    }

    vbox->addStretch(1);
    return page;
}
QWidget* RadioSetupDialog::buildTxTab()
{
    auto& tx = m_model->transmitModel();
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    // Timings group
    {
        auto* group = new QGroupBox("Timings (in ms)");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto addTimingField = [&](int row, int col, const QString& label, int value) {
            auto* lbl = new QLabel(label);
            lbl->setStyleSheet(kLabelStyle);
            grid->addWidget(lbl, row, col * 2);
            auto* edit = new QLineEdit(QString::number(value));
            edit->setStyleSheet(kEditStyle);
            edit->setFixedWidth(60);
            grid->addWidget(edit, row, col * 2 + 1);
            return edit;
        };

        // Scale factor lets the same helper drive both 1:1 ms fields and
        // the seconds-displayed timeout field which the radio still
        // expects in ms (FlexLib Radio.cs:7463 — "in milliseconds").
        auto connectTimingField = [&](QLineEdit* edit, const QString& key, int scale = 1) {
            connect(edit, &QLineEdit::editingFinished, this, [this, edit, key, scale] {
                int val = qMax(0, edit->text().toInt());
                edit->setText(QString::number(val));
                m_model->sendCommand(QString("interlock set %1=%2").arg(key).arg(val * scale));
            });
        };

        auto* accTxEdit   = addTimingField(0, 0, "ACC TX:",       tx.accTxDelay());
        auto* txDelayEdit = addTimingField(0, 1, "TX Delay:",      tx.txDelay());
        auto* tx1Edit     = addTimingField(1, 0, "RCA TX1:",       tx.tx1Delay());
        // Timeout stored on the radio in milliseconds (FlexLib Radio.cs:7463);
        // display in whole seconds for readability — minutes lose too much
        // resolution for short-cycle TOT settings.
        auto* timeoutEdit = addTimingField(1, 1, "Timeout (sec):", tx.interlockTimeout() / 1000);
        auto* tx2Edit     = addTimingField(2, 0, "RCA TX2:",       tx.tx2Delay());

        connectTimingField(accTxEdit,   "acc_tx_delay");
        connectTimingField(txDelayEdit, "tx_delay");
        connectTimingField(tx1Edit,     "tx1_delay");
        connectTimingField(timeoutEdit, "timeout", 1000);
        connectTimingField(tx2Edit,     "tx2_delay");

        // TX Profile dropdown (below Timeout, right column)
        auto* profCmb = new QComboBox;
        profCmb->addItems(tx.profileList());
        profCmb->setCurrentText(tx.activeProfile());
        AetherSDR::applyComboStyle(profCmb);
        grid->addWidget(profCmb, 2, 2, 1, 2);
        connect(profCmb, &QComboBox::currentTextChanged, this, [this](const QString& name) {
            m_model->transmitModel().loadProfile(name);
        });

        auto* tx3Edit = addTimingField(3, 0, "RCA TX3:", tx.tx3Delay());
        connectTimingField(tx3Edit, "tx3_delay");

        // TX Band Settings button
        auto* bandSetBtn = new QPushButton("TX Band Settings");
        AetherSDR::ThemeManager::instance().applyStyleSheet(bandSetBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
            "padding: 4px 12px; }"
            "QPushButton:hover { background: {{color.background.1}}; }");
        connect(bandSetBtn, &QPushButton::clicked, this, [this] {
            emit txBandSettingsRequested();
        });
        grid->addWidget(bandSetBtn, 3, 2, 1, 2);

        for (auto* lbl : group->findChildren<QLabel*>())
            if (lbl->styleSheet().isEmpty()) lbl->setStyleSheet(kLabelStyle);

        vbox->addWidget(group);
    }

    // Interlocks group
    {
        auto* group = new QGroupBox("Interlocks - TX REQ");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* rcaLbl = new QLabel("RCA:");
        rcaLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(rcaLbl, 0, 0);
        auto* rcaCmb = new QComboBox;
        rcaCmb->addItems({"Active Low", "Active High"});
        rcaCmb->setCurrentIndex(tx.rcaTxReqPolarity());
        AetherSDR::applyComboStyle(rcaCmb);
        grid->addWidget(rcaCmb, 0, 1);

        auto* accLbl = new QLabel("Accessory:");
        accLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(accLbl, 0, 2);
        auto* accCmb = new QComboBox;
        accCmb->addItems({"Active Low", "Active High"});
        accCmb->setCurrentIndex(tx.accTxReqPolarity());
        AetherSDR::applyComboStyle(accCmb);
        grid->addWidget(accCmb, 0, 3);

        vbox->addWidget(group);
    }

    // Max Power / Show TX in Waterfall / Slice-TX Follow
    //
    // Tune Mode (single_tone / two_tone) used to live here too but was
    // removed — it persisted "Two Tone" across restarts as if it were a
    // normal operating mode, which surprised users who hit the regular
    // Tune button later and got an unexpected 2-tone test.  Tune Mode is
    // now a transient one-shot, surfaced via the TUNE button's right-
    // click menu in TxApplet ("Mono Tone" / "Two Tone").  Picking either
    // sets the radio's tune_mode for the next tune cycle only; nothing
    // is written to AppSettings.
    {
        auto* grid = new QGridLayout;
        grid->setSpacing(6);

        auto* mpLbl = new QLabel("Max Power:");
        mpLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(mpLbl, 0, 0);
        auto* mpRow = new QHBoxLayout;
        auto* mpEdit = new QLineEdit(QString::number(tx.maxPowerLevel()));
        mpEdit->setStyleSheet(kEditStyle);
        mpEdit->setFixedWidth(50);
        mpRow->addWidget(mpEdit);
        auto* mpUnit = new QLabel("%");
        mpUnit->setStyleSheet(kLabelStyle);
        mpRow->addWidget(mpUnit);
        mpRow->addStretch(1);
        grid->addLayout(mpRow, 0, 1);

        connect(mpEdit, &QLineEdit::editingFinished, this, [this, mpEdit] {
            int val = qBound(0, mpEdit->text().toInt(), 100);
            mpEdit->setText(QString::number(val));
            m_model->sendCommand(
                QString("transmit set max_power_level=%1").arg(val));
        });

        auto* swLbl = new QLabel("Show TX in Waterfall:");
        swLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(swLbl, 1, 0);
        auto* swBtn = new QPushButton(tx.showTxInWaterfall() ? "Enabled" : "Disabled");
        swBtn->setCheckable(true);
        swBtn->setChecked(tx.showTxInWaterfall());
        AetherSDR::ThemeManager::instance().applyStyleSheet(swBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
            "padding: 3px 10px; }"
            "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; "
            "border: 1px solid #20a040; }");
        connect(swBtn, &QPushButton::toggled, this, [this, swBtn](bool on) {
            swBtn->setText(on ? "Enabled" : "Disabled");
            m_model->sendCommand(
                QString("transmit set show_tx_in_waterfall=%1").arg(on ? 1 : 0));
        });
        grid->addWidget(swBtn, 1, 1);

        // Slice–TX Follow Mode (#441, #1351) — mutually exclusive toggles
        auto* followLbl = new QLabel("Slice/TX Follow:");
        followLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(followLbl, 2, 0);

        const QString kFollowBtnStyle =
            "QPushButton { background: #1a2a3a; border: 1px solid #304050; "
            "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; "
            "padding: 3px 8px; }"
            "QPushButton:checked { background: #1a5030; color: #00e060; "
            "border: 1px solid #20a040; }";

        bool txFollows = AppSettings::instance().value("TxFollowsActiveSlice", "False").toString() == "True";
        auto* tfBtn = new QPushButton("TX Follows Active Slice");
        tfBtn->setCheckable(true);
        tfBtn->setChecked(txFollows);
        tfBtn->setToolTip("TX follows the active slice.\nDisabled during Split operation.");
        tfBtn->setStyleSheet(kFollowBtnStyle);

        bool activeFollows = AppSettings::instance().value("ActiveFollowsTxSlice", "False").toString() == "True";
        auto* afBtn = new QPushButton("Active Slice Follows TX");
        afBtn->setCheckable(true);
        afBtn->setChecked(activeFollows);
        afBtn->setToolTip("Switch active slice when TX moves externally\n(e.g. WSJT-X or CAT command).");
        afBtn->setStyleSheet(kFollowBtnStyle);

        auto* followRow = new QHBoxLayout;
        followRow->setSpacing(6);
        followRow->addWidget(tfBtn);
        followRow->addWidget(afBtn);
        followRow->addStretch(1);
        grid->addLayout(followRow, 2, 1);

        // Mutual exclusion: enabling one disables the other
        connect(tfBtn, &QPushButton::toggled, this, [tfBtn, afBtn](bool on) {
            Q_UNUSED(tfBtn);
            auto& s = AppSettings::instance();
            s.setValue("TxFollowsActiveSlice", on ? "True" : "False");
            if (on && afBtn->isChecked()) {
                afBtn->setChecked(false);
            }
            s.save();
        });
        connect(afBtn, &QPushButton::toggled, this, [tfBtn, afBtn](bool on) {
            Q_UNUSED(afBtn);
            auto& s = AppSettings::instance();
            s.setValue("ActiveFollowsTxSlice", on ? "True" : "False");
            if (on && tfBtn->isChecked()) {
                tfBtn->setChecked(false);
            }
            s.save();
        });

        vbox->addLayout(grid);
    }

    vbox->addStretch(1);
    return page;
}
QWidget* RadioSetupDialog::buildPhoneCwTab()
{
    auto& tx = m_model->transmitModel();
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    static const QString kTogBtnStyle =
        "QPushButton { background: #1a2a3a; border: 1px solid #304050; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; "
        "padding: 3px 10px; }"
        "QPushButton:checked { background: #0070c0; color: #ffffff; "
        "border: 1px solid #0090e0; }";

    auto mkTogBtn = [&](const QString& text, bool checked) {
        auto* btn = new QPushButton(text);
        btn->setCheckable(true);
        btn->setChecked(checked);
        btn->setStyleSheet(kTogBtnStyle);
        return btn;
    };

    // Microphone group
    {
        auto* group = new QGroupBox("Microphone");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setHorizontalSpacing(8);
        grid->setVerticalSpacing(6);
        grid->setColumnStretch(2, 1);

        constexpr int kMicControlButtonWidth = 104;
        auto addMicRow = [&](int row, const QString& labelText, QPushButton* button) {
            auto* label = new QLabel(labelText);
            label->setStyleSheet(kLabelStyle);
            label->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
            button->setMinimumWidth(kMicControlButtonWidth);
            button->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
            grid->addWidget(label, row, 0);
            grid->addWidget(button, row, 1);
        };

        auto* biasBtn = mkTogBtn("BIAS", tx.micBias());
        connect(biasBtn, &QPushButton::toggled, this, [this](bool on) {
            m_model->transmitModel().setMicBias(on);
        });
        auto* boostBtn = mkTogBtn("+20dB", tx.micBoost());
        connect(boostBtn, &QPushButton::toggled, this, [this](bool on) {
            m_model->transmitModel().setMicBoost(on);
        });
        auto* metBtn = mkTogBtn(tx.metInRx() ? "Enabled" : "Disabled", tx.metInRx());
        connect(metBtn, &QPushButton::toggled, this, [this, metBtn](bool on) {
            metBtn->setText(on ? "Enabled" : "Disabled");
            m_model->sendCommand(QString("transmit set met_in_rx=%1").arg(on ? 1 : 0));
        });

        addMicRow(0, "Mic Bias Voltage:", biasBtn);
        addMicRow(1, "Mic +20 dB Boost:", boostBtn);
        addMicRow(2, "Level Meter During Receive:", metBtn);

        vbox->addWidget(group);
    }

    // CW group
    {
        auto* group = new QGroupBox("CW");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        // Iambic: Enabled | A | B
        auto* iamLbl = new QLabel("Iambic:");
        iamLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(iamLbl, 0, 0);
        auto* iamBtn = mkTogBtn(tx.cwIambic() ? "Enabled" : "Disabled", tx.cwIambic());
        connect(iamBtn, &QPushButton::toggled, this, [this, iamBtn](bool on) {
            iamBtn->setText(on ? "Enabled" : "Disabled");
            m_model->sendCommand(QString("cw iambic %1").arg(on ? 1 : 0));
        });
        grid->addWidget(iamBtn, 0, 1);
        auto* modeA = mkTogBtn("A", tx.cwIambicMode() == 0);
        auto* modeB = mkTogBtn("B", tx.cwIambicMode() == 1);
        connect(modeA, &QPushButton::clicked, this, [this, modeA, modeB] {
            modeA->setChecked(true); modeB->setChecked(false);
            m_model->sendCommand("cw mode 0");
        });
        connect(modeB, &QPushButton::clicked, this, [this, modeA, modeB] {
            modeA->setChecked(false); modeB->setChecked(true);
            m_model->sendCommand("cw mode 1");
        });
        grid->addWidget(modeA, 0, 2);
        grid->addWidget(modeB, 0, 3);

        // Swap: Dot/Dash button
        auto* swapLbl = new QLabel("Swap:");
        swapLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(swapLbl, 0, 4);
        auto* swapBtn = mkTogBtn("Dot/Dash", tx.cwSwapPaddles());
        connect(swapBtn, &QPushButton::toggled, this, [this](bool on) {
            m_model->sendCommand(QString("cw swap %1").arg(on ? 1 : 0));
        });
        grid->addWidget(swapBtn, 0, 5);

        // Sideband: CWU | CWL
        auto* sbLbl = new QLabel("Sideband:");
        sbLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(sbLbl, 1, 0);
        auto* cwuBtn = mkTogBtn("CWU", !tx.cwlEnabled());
        auto* cwlBtn = mkTogBtn("CWL", tx.cwlEnabled());
        connect(cwuBtn, &QPushButton::clicked, this, [this, cwuBtn, cwlBtn] {
            cwuBtn->setChecked(true); cwlBtn->setChecked(false);
            m_model->sendCommand("cw cwl_enabled 0");
        });
        connect(cwlBtn, &QPushButton::clicked, this, [this, cwuBtn, cwlBtn] {
            cwuBtn->setChecked(false); cwlBtn->setChecked(true);
            m_model->sendCommand("cw cwl_enabled 1");
        });
        grid->addWidget(cwuBtn, 1, 1);
        grid->addWidget(cwlBtn, 1, 2);

        // CWX: Sync
        auto* cwxLbl = new QLabel("CWX:");
        cwxLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(cwxLbl, 1, 4);
        auto* syncBtn = mkTogBtn("Sync", tx.syncCwx());
        connect(syncBtn, &QPushButton::toggled, this, [this](bool on) {
            m_model->sendCommand(QString("cw synccwx %1").arg(on ? 1 : 0));
        });
        grid->addWidget(syncBtn, 1, 5);

        // CW Decode — independent RX / TX toggles (#2417).  RX keeps the
        // legacy behaviour of decoding the received CW slice; TX decodes
        // the operator's own keying via the client-side sidetone, useful
        // as a self-training tool for paddle / bug timing.  MainWindow
        // re-evaluates run state and the AudioEngine TX-decode tap on
        // dialog close via refreshCwDecodeState().
        auto* decodeLbl = new QLabel("Decode:");
        decodeLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(decodeLbl, 2, 4);
        auto* rxDecodeBtn = mkTogBtn("RX", CwDecodeSettings::rxEnabled());
        auto* txDecodeBtn = mkTogBtn("TX", CwDecodeSettings::txEnabled());
        connect(rxDecodeBtn, &QPushButton::toggled, this, [](bool on) {
            CwDecodeSettings::setRxEnabled(on);
        });
        connect(txDecodeBtn, &QPushButton::toggled, this, [](bool on) {
            CwDecodeSettings::setTxEnabled(on);
        });
        grid->addWidget(rxDecodeBtn, 2, 5);
        grid->addWidget(txDecodeBtn, 2, 6);



        vbox->addWidget(group);
    }

    // Digital group
    {
        auto* group = new QGroupBox("Digital");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* markLbl = new QLabel("RTTY Mark Default:");
        markLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(markLbl, 0, 0);
        auto* markEdit = new QLineEdit(QString::number(m_model->rttyMarkDefault()));
        markEdit->setStyleSheet(kEditStyle);
        markEdit->setFixedWidth(60);
        connect(markEdit, &QLineEdit::editingFinished, this, [this, markEdit] {
            m_model->sendCommand(
                "radio set rtty_mark_default=" + markEdit->text());
        });
        grid->addWidget(markEdit, 0, 1);

        vbox->addWidget(group);
    }

    vbox->addStretch(1);
    return page;
}
QWidget* RadioSetupDialog::buildRxTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    static const QString kTogStyle =
        "QPushButton { background: #1a2a3a; border: 1px solid #304050; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; "
        "padding: 3px 10px; }"
        "QPushButton:checked { background: #1a5030; color: #00e060; "
        "border: 1px solid #20a040; }";

    // Frequency Offset group
    {
        auto* group = new QGroupBox("Frequency Offset");
        group->setStyleSheet(kGroupStyle);
        auto* gvb = new QVBoxLayout(group);
        gvb->setSpacing(4);

        auto* lbl = new QLabel(m_model->gpsdoPresent()
            ? "GPSDO installed. Manual frequency offset calibration available."
            : "Manual frequency offset calibration available.");
        lbl->setStyleSheet(m_model->gpsdoPresent()
            ? "QLabel { color: #00c040; font-size: 12px; }"
            : "QLabel { color: #c0a000; font-size: 12px; }");
        lbl->setWordWrap(true);
        gvb->addWidget(lbl);

        auto* offsetGrid = new QGridLayout;
        offsetGrid->setSpacing(6);

        // Cal Frequency row
        auto* calLbl = new QLabel("Cal Frequency (MHz):");
        calLbl->setStyleSheet(kLabelStyle);
        offsetGrid->addWidget(calLbl, 0, 0);
        auto* calEdit = new QLineEdit(QString::number(m_model->calFreqMhz(), 'f', 6));
        calEdit->setStyleSheet(kEditStyle);
        calEdit->setFixedWidth(100);
        connect(calEdit, &QLineEdit::editingFinished, this, [this, calEdit] {
            m_model->sendCommand(
                "radio set cal_freq=" + calEdit->text());
        });
        offsetGrid->addWidget(calEdit, 0, 1);

        auto* startBtn = new QPushButton("Start");
        startBtn->setStyleSheet(kTogStyle);
        startBtn->setFixedWidth(60);
        auto* calStatus = new QLabel;
        AetherSDR::ThemeManager::instance().applyStyleSheet(calStatus, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }");
        calStatus->setMinimumWidth(130);

        auto calibrationActive = std::make_shared<bool>(false);
        auto pllRunningSeen = std::make_shared<bool>(false);
        auto calibrationRun = std::make_shared<int>(0);
        auto setCalStatus = [calStatus](const QString& text, const QString& color) {
            calStatus->setText(text);
            calStatus->setStyleSheet(
                QStringLiteral("QLabel { color: %1; font-size: 11px; }").arg(color));
        };

        connect(startBtn, &QPushButton::clicked, this,
                [this, calEdit, startBtn, calStatus, calibrationActive, pllRunningSeen,
                 calibrationRun, setCalStatus] {
            const QString calFreq = calEdit->text().trimmed();
            if (calFreq.isEmpty()) {
                setCalStatus("Enter cal frequency", "#e0a050");
                return;
            }

            const int runId = ++(*calibrationRun);
            *calibrationActive = true;
            *pllRunningSeen = false;
            startBtn->setEnabled(false);
            startBtn->setText("Busy");
            setCalStatus("Starting...", "#8aa8c0");

            qCDebug(lcProtocol) << "RadioSetupDialog: frequency calibration requested"
                                 << "cal_freq=" << calFreq
                                 << "reset_freq_error_ppb=0"
                                 << "run_id=" << runId;
            m_model->sendCommand("radio set cal_freq=" + calFreq);
            m_model->sendCommand("radio set freq_error_ppb=0");

            QPointer<RadioSetupDialog> dialog(this);
            QPointer<QPushButton> startGuard(startBtn);
            QPointer<QLabel> statusGuard(calStatus);
            // FlexLib's StartOffsetEnabled=false path starts calibration with radio pll_start.
            m_model->sendCmdPublic("radio pll_start",
                [dialog, startGuard, statusGuard, calibrationActive, calibrationRun, runId](
                    int code, const QString& body) {
                if (!dialog)
                    return;
                QMetaObject::invokeMethod(dialog, [dialog, startGuard, statusGuard,
                                                   calibrationActive, calibrationRun, runId,
                                                   code, body] {
                    if (!dialog || !startGuard || !statusGuard)
                        return;
                    if (!*calibrationActive || *calibrationRun != runId) {
                        qCDebug(lcProtocol)
                            << "RadioSetupDialog: ignoring radio pll_start response for inactive calibration"
                            << "run_id=" << runId
                            << "current_run_id=" << *calibrationRun
                            << "code" << code << "body:" << body;
                        return;
                    }
                    if (code == 0) {
                        statusGuard->setText("Calibrating...");
                        AetherSDR::ThemeManager::instance().applyStyleSheet(statusGuard, "QLabel { color: {{color.accent.bright}}; font-size: 11px; }");
                        qCDebug(lcProtocol)
                            << "RadioSetupDialog: radio pll_start accepted";
                        return;
                    }

                    *calibrationActive = false;
                    startGuard->setEnabled(true);
                    startGuard->setText("Start");
                    statusGuard->setText(QString("Error 0x%1")
                        .arg(code, 0, 16).toUpper());
                    statusGuard->setStyleSheet(
                        "QLabel { color: #ff7070; font-size: 11px; }");
                    qCWarning(lcProtocol)
                        << "RadioSetupDialog: radio pll_start failed: code"
                        << Qt::hex << code << "body:" << body;
                });
            });

            QTimer::singleShot(20000, this,
                [startGuard, statusGuard, calibrationActive, calibrationRun, runId] {
                if (!startGuard || !statusGuard || !*calibrationActive
                    || *calibrationRun != runId)
                    return;
                *calibrationActive = false;
                startGuard->setEnabled(true);
                startGuard->setText("Start");
                statusGuard->setText("No response");
                statusGuard->setStyleSheet(
                    "QLabel { color: #ff7070; font-size: 11px; }");
                qCWarning(lcProtocol)
                    << "RadioSetupDialog: frequency calibration timed out waiting for pll_done=1"
                    << "run_id=" << runId;
            });
        });
        offsetGrid->addWidget(startBtn, 0, 2);
        offsetGrid->addWidget(calStatus, 0, 3);

        // Freq Error PPB row
        auto* ppbLbl = new QLabel("Freq Offset (ppb):");
        ppbLbl->setStyleSheet(kLabelStyle);
        offsetGrid->addWidget(ppbLbl, 1, 0);
        auto* ppbEdit = new QLineEdit(QString::number(m_model->freqErrorPpb()));
        ppbEdit->setStyleSheet(kEditStyle);
        ppbEdit->setFixedWidth(80);
        connect(ppbEdit, &QLineEdit::editingFinished, this, [this, ppbEdit] {
            m_model->sendCommand(
                "radio set freq_error_ppb=" + ppbEdit->text());
        });
        offsetGrid->addWidget(ppbEdit, 1, 1);
        auto* ppbUnitLbl = new QLabel("ppb");
        ppbUnitLbl->setStyleSheet(kLabelStyle);
        offsetGrid->addWidget(ppbUnitLbl, 1, 2);
        offsetGrid->setColumnStretch(3, 1);
        gvb->addLayout(offsetGrid);

        connect(m_model, &RadioModel::infoChanged, this, [this, calEdit, ppbEdit] {
            if (!calEdit->hasFocus())
                calEdit->setText(QString::number(m_model->calFreqMhz(), 'f', 6));
            if (!ppbEdit->hasFocus())
                ppbEdit->setText(QString::number(m_model->freqErrorPpb()));
        });

        connect(m_model, &RadioModel::statusReceived, this,
                [startBtn, calStatus, calibrationActive, pllRunningSeen](
                    const QString& object, const QMap<QString, QString>& kvs) {
            if (object != QStringLiteral("radio") || !kvs.contains(QStringLiteral("pll_done")))
                return;

            const QString pllDone = kvs.value(QStringLiteral("pll_done"));
            const QString freqError = kvs.value(QStringLiteral("freq_error_ppb"));
            const QString calFreq = kvs.value(QStringLiteral("cal_freq"));
            qCDebug(lcProtocol)
                << "RadioSetupDialog: frequency calibration status"
                << "pll_done=" << pllDone
                << "freq_error_ppb=" << freqError
                << "cal_freq=" << calFreq
                << "active=" << *calibrationActive
                << "running_seen=" << *pllRunningSeen;

            if (!*calibrationActive)
                return;

            if (pllDone == QStringLiteral("0")) {
                *pllRunningSeen = true;
                calStatus->setText("Calibrating...");
                AetherSDR::ThemeManager::instance().applyStyleSheet(calStatus, "QLabel { color: {{color.accent.bright}}; font-size: 11px; }");
                return;
            }

            if (pllDone == QStringLiteral("1") && !*pllRunningSeen) {
                qCDebug(lcProtocol)
                    << "RadioSetupDialog: ignoring pll_done=1 before pll_done=0 for active calibration"
                    << "freq_error_ppb=" << freqError
                    << "cal_freq=" << calFreq;
                return;
            }

            if (pllDone == QStringLiteral("1")) {
                *calibrationActive = false;
                startBtn->setEnabled(true);
                startBtn->setText("Start");
                calStatus->setText(freqError.isEmpty()
                    ? QStringLiteral("Complete")
                    : QStringLiteral("Complete (%1 ppb)").arg(freqError));
                AetherSDR::ThemeManager::instance().applyStyleSheet(calStatus, "QLabel { color: {{color.accent.success}}; font-size: 11px; }");
                qCDebug(lcProtocol)
                    << "RadioSetupDialog: frequency calibration completed"
                    << "freq_error_ppb=" << freqError
                    << "cal_freq=" << calFreq;
            }
        });

        vbox->addWidget(group);
    }

    // 10 MHz Reference group
    {
        auto* group = new QGroupBox("10 MHz Reference");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* srcLbl = new QLabel("Source:");
        srcLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(srcLbl, 0, 0);

        auto* srcCmb = new QComboBox;
        AetherSDR::applyComboStyle(srcCmb);
        refreshOscillatorSourceCombo(srcCmb, m_model);
        connect(srcCmb, &QComboBox::currentIndexChanged, this, [this, srcCmb](int i) {
            m_model->sendCommand(
                "radio oscillator " + srcCmb->itemData(i).toString());
        });
        grid->addWidget(srcCmb, 0, 1);

        // Lock status
        auto* lockLbl = new QLabel(oscillatorStatusText(m_model));
        lockLbl->setStyleSheet(QStringLiteral(
            "QLabel { color: %1; font-size: 12px; font-weight: bold; }")
            .arg(oscillatorStatusColor(m_model)));
        grid->addWidget(lockLbl, 0, 2);

        // Live-update oscillator status when radio state changes (#967)
        connect(m_model, &RadioModel::oscillatorChanged, this, [this, srcCmb, lockLbl] {
            lockLbl->setText(oscillatorStatusText(m_model));
            lockLbl->setStyleSheet(QStringLiteral(
                "QLabel { color: %1; font-size: 12px; font-weight: bold; }")
                .arg(oscillatorStatusColor(m_model)));

            const QString current = normalizedOscillatorValue(srcCmb->currentData().toString());
            QSignalBlocker blocker(srcCmb);
            refreshOscillatorSourceCombo(srcCmb, m_model, current);
        });

        vbox->addWidget(group);
    }

    // General RX settings
    {
        auto* grid = new QGridLayout;
        grid->setSpacing(6);

        auto addToggle = [&](int row, const QString& label, bool checked,
                              const QString& cmd) {
            auto* lbl = new QLabel(label);
            lbl->setStyleSheet(kLabelStyle);
            grid->addWidget(lbl, row, 0);
            auto* btn = new QPushButton(checked ? "Enabled" : "Disabled");
            btn->setCheckable(true);
            btn->setChecked(checked);
            btn->setStyleSheet(kTogStyle);
            connect(btn, &QPushButton::toggled, this, [this, cmd, btn](bool on) {
                btn->setText(on ? "Enabled" : "Disabled");
                m_model->sendCommand(
                    QString("%1=%2").arg(cmd).arg(on ? 1 : 0));
            });
            grid->addWidget(btn, row, 1);
        };

        addToggle(0, "Mute local audio when remote:", m_model->muteLocalWhenRemote(),
                  "radio set mute_local_audio_when_remote");
        addToggle(1, "Binaural audio:", m_model->binauralRx(),
                  "radio set binaural_rx");

        vbox->addLayout(grid);
    }

    vbox->addStretch(1);
    return page;
}
// ── Audio tab ────────────────────────────────────────────────────────────────

QWidget* RadioSetupDialog::buildAudioTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);

    // ── Radio Audio Outputs ──────────────────────────────────────────────
    auto* outGroup = new QGroupBox("Radio Audio Outputs");
    outGroup->setStyleSheet(kGroupStyle);
    auto* outLayout = new QVBoxLayout(outGroup);

    // Line Out
    auto* lineoutRow = new QHBoxLayout;
    auto* lineoutLabel = new QLabel("Line Out:");
    lineoutLabel->setStyleSheet(kLabelStyle);
    lineoutLabel->setFixedWidth(90);
    auto* lineoutSlider = new GuardedSlider(Qt::Horizontal);
    lineoutSlider->setRange(0, 100);
    lineoutSlider->setValue(m_model->lineoutGain());
    auto* lineoutValue = new QLabel(QString::number(m_model->lineoutGain()));
    lineoutValue->setStyleSheet(kValueStyle);
    lineoutValue->setFixedWidth(30);
    auto* lineoutMute = new QPushButton("Mute");
    lineoutMute->setCheckable(true);
    lineoutMute->setChecked(m_model->lineoutMute());
    lineoutMute->setFixedWidth(50);
    AetherSDR::ThemeManager::instance().applyStyleSheet(lineoutMute, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
        "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 2px; }"
        "QPushButton:checked { background: #8b0000; color: {{color.text.primary}}; }");
    lineoutRow->addWidget(lineoutLabel);
    lineoutRow->addWidget(lineoutSlider, 1);
    lineoutRow->addWidget(lineoutValue);
    lineoutRow->addWidget(lineoutMute);
    outLayout->addLayout(lineoutRow);

    connect(lineoutSlider, &QSlider::valueChanged, this, [this, lineoutValue](int v) {
        lineoutValue->setText(QString::number(v));
        m_model->setLineoutGain(v);
    });
    connect(lineoutMute, &QPushButton::toggled, m_model, &RadioModel::setLineoutMute);

    // Headphone
    auto* hpRow = new QHBoxLayout;
    auto* hpLabel = new QLabel("Headphone:");
    hpLabel->setStyleSheet(kLabelStyle);
    hpLabel->setFixedWidth(90);
    auto* hpSlider = new GuardedSlider(Qt::Horizontal);
    hpSlider->setRange(0, 100);
    hpSlider->setValue(m_model->headphoneGain());
    auto* hpValue = new QLabel(QString::number(m_model->headphoneGain()));
    hpValue->setStyleSheet(kValueStyle);
    hpValue->setFixedWidth(30);
    auto* hpMute = new QPushButton("Mute");
    hpMute->setCheckable(true);
    hpMute->setChecked(m_model->headphoneMute());
    hpMute->setFixedWidth(50);
    AetherSDR::ThemeManager::instance().applyStyleSheet(hpMute, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
        "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 2px; }"
        "QPushButton:checked { background: #8b0000; color: {{color.text.primary}}; }");
    hpRow->addWidget(hpLabel);
    hpRow->addWidget(hpSlider, 1);
    hpRow->addWidget(hpValue);
    hpRow->addWidget(hpMute);
    outLayout->addLayout(hpRow);

    connect(hpSlider, &QSlider::valueChanged, this, [this, hpValue](int v) {
        hpValue->setText(QString::number(v));
        m_model->setHeadphoneGain(v);
    });
    connect(hpMute, &QPushButton::toggled, m_model, &RadioModel::setHeadphoneMute);

    // Front Speaker (mute only) — only on M-suffix models with built-in speaker
    // M-suffix models have a built-in front speaker (6400M, 6600M, 8400M, 8600M, AU-510M, AU-520M)
    bool hasFrontSpeaker = m_model->model().endsWith("M", Qt::CaseInsensitive);
    if (hasFrontSpeaker) {
        auto* spkRow = new QHBoxLayout;
        auto* spkLabel = new QLabel("Front Speaker:");
        spkLabel->setStyleSheet(kLabelStyle);
        spkLabel->setFixedWidth(90);
        auto* spkMute = new QPushButton("Mute");
        spkMute->setCheckable(true);
        spkMute->setChecked(m_model->frontSpeakerMute());
        spkMute->setFixedWidth(50);
        AetherSDR::ThemeManager::instance().applyStyleSheet(spkMute, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 2px; }"
            "QPushButton:checked { background: #8b0000; color: {{color.text.primary}}; }");
        spkRow->addWidget(spkLabel);
        spkRow->addStretch(1);
        spkRow->addWidget(spkMute);
        outLayout->addLayout(spkRow);
        connect(spkMute, &QPushButton::toggled, m_model, &RadioModel::setFrontSpeakerMute);
    }

    // Update from radio status
    connect(m_model, &RadioModel::audioOutputChanged, this,
            [this, lineoutSlider, lineoutValue, lineoutMute,
             hpSlider, hpValue, hpMute] {
        QSignalBlocker b1(lineoutSlider), b2(lineoutMute),
                       b3(hpSlider), b4(hpMute);
        lineoutSlider->setValue(m_model->lineoutGain());
        lineoutValue->setText(QString::number(m_model->lineoutGain()));
        lineoutMute->setChecked(m_model->lineoutMute());
        hpSlider->setValue(m_model->headphoneGain());
        hpValue->setText(QString::number(m_model->headphoneGain()));
        hpMute->setChecked(m_model->headphoneMute());
    });

    vbox->addWidget(outGroup);

    // ── Audio Compression ────────────────────────────────────────────────
    {
        auto* compGroup = new QGroupBox("Audio Compression (SmartLink)");
        compGroup->setStyleSheet(kGroupStyle);
        auto* compLayout = new QHBoxLayout(compGroup);
        compLayout->setSpacing(4);

        QString current = AppSettings::instance().value("AudioCompression", "None").toString();

        const QString btnStyle =
            "QPushButton { background: #1a2a3a; color: #c8d8e8; border: 1px solid #304050; "
            "border-radius: 3px; padding: 2px 10px; font-size: 11px; }"
            "QPushButton:checked { background: #00607a; color: #e0f0ff; border-color: #00b4d8; }";

        auto* autoBtn = new QPushButton("Auto");
        autoBtn->setCheckable(true); autoBtn->setChecked(current == "Auto");
        autoBtn->setStyleSheet(btnStyle);
        auto* noneBtn = new QPushButton("Uncompressed");
        noneBtn->setCheckable(true); noneBtn->setChecked(current == "None");
        noneBtn->setStyleSheet(btnStyle);
        auto* opusBtn = new QPushButton("Opus");
        opusBtn->setCheckable(true); opusBtn->setChecked(current == "Opus");
        opusBtn->setStyleSheet(btnStyle);

        auto setComp = [autoBtn, noneBtn, opusBtn](const QString& val) {
            QSignalBlocker b1(autoBtn), b2(noneBtn), b3(opusBtn);
            autoBtn->setChecked(val == "Auto");
            noneBtn->setChecked(val == "None");
            opusBtn->setChecked(val == "Opus");
            auto& s = AppSettings::instance();
            s.setValue("AudioCompression", val);
            s.save();
        };

        connect(autoBtn, &QPushButton::clicked, this, [setComp]() { setComp("Auto"); });
        connect(noneBtn, &QPushButton::clicked, this, [setComp]() { setComp("None"); });
        connect(opusBtn, &QPushButton::clicked, this, [setComp]() { setComp("Opus"); });

        compLayout->addWidget(autoBtn);
        compLayout->addWidget(noneBtn);
        compLayout->addWidget(opusBtn);
        compLayout->addStretch();

        auto* hint = new QLabel("Auto = Opus on SmartLink, uncompressed on LAN");
        AetherSDR::ThemeManager::instance().applyStyleSheet(hint, "QLabel { color: {{color.text.label}}; font-size: 10px; }");
        compLayout->addWidget(hint);

        vbox->addWidget(compGroup);
    }

    // ── Packet-Loss Concealment ─────────────────────────────────────────
    // Fades dropped VITA-49 audio packets to silence (uncompressed) or
    // calls libopus native PLC (Opus) instead of splicing the next packet
    // directly. Cuts the broadband click on lossy WAN/SmartLink. (#2731)
    {
        auto* plcCheck = new QCheckBox(
            "Smooth packet loss (conceal dropped audio packets)");
        AetherSDR::ThemeManager::instance().applyStyleSheet(plcCheck,
            "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }"
            + kCheckBoxIndicator);
        plcCheck->setToolTip(
            "When the radio's audio stream loses a UDP packet, fade the gap\n"
            "to silence (uncompressed) or synthesize a perceptually smooth\n"
            "fill with libopus PLC (Opus) instead of splicing the next packet\n"
            "directly. Reduces the high-pitch click that the splice produces\n"
            "on lossy WAN/SmartLink links. Capped at ~80 ms before the audio\n"
            "drops to clean silence.");
        plcCheck->setChecked(
            AppSettings::instance()
                .value("AudioPacketLossConcealment", "True").toString() == "True");
        connect(plcCheck, &QCheckBox::toggled, this, [this](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("AudioPacketLossConcealment", on ? "True" : "False");
            s.save();
            // PanadapterStream lives on the network worker thread (#502);
            // route the toggle through QueuedConnection so the atomic and
            // map mutations happen on the owning thread.
            if (m_model && m_model->panStream()) {
                QMetaObject::invokeMethod(
                    m_model->panStream(),
                    [stream = m_model->panStream(), on]() {
                        stream->setPacketLossConcealment(on);
                    },
                    Qt::QueuedConnection);
            }
        });
        vbox->addWidget(plcCheck);
    }

    // ── Prevent Sleep ───────────────────────────────────────────────────
    {
        auto* sleepCheck = new QCheckBox("Prevent system sleep while connected");
        AetherSDR::ThemeManager::instance().applyStyleSheet(sleepCheck,
            "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }"
            + kCheckBoxIndicator);
        sleepCheck->setToolTip("Hold a system power assertion to prevent idle sleep\n"
                               "while connected to a radio. Keeps TCP/UDP/audio\n"
                               "streams alive during long sessions.");
        sleepCheck->setChecked(
            AppSettings::instance().value("InhibitSleepWhileConnected", "False").toString() == "True");
        connect(sleepCheck, &QCheckBox::toggled, this, [](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("InhibitSleepWhileConnected", on ? "True" : "False");
            s.save();
        });
        vbox->addWidget(sleepCheck);
    }

    // ── PC Audio Devices ────────────────────────────────────────────────
    auto* pcGroup = new QGroupBox("PC Audio Devices");
    pcGroup->setStyleSheet(kGroupStyle);
    auto* pcLayout = new QVBoxLayout(pcGroup);

    // Input device
    auto* inRow = new QHBoxLayout;
    auto* inLabel = new QLabel("Input:");
    inLabel->setStyleSheet(kLabelStyle);
    inLabel->setFixedWidth(90);
    auto* inCombo = new QComboBox;
    AetherSDR::applyComboStyle(inCombo);
    const auto inDevices = QMediaDevices::audioInputs();
    for (const auto& dev : inDevices)
        inCombo->addItem(dev.description(), dev.id());
    const auto curIn = m_audio ? m_audio->inputDevice() : QAudioDevice();
    const auto selIn = curIn.isNull() ? QMediaDevices::defaultAudioInput() : curIn;
    int inIdx = inCombo->findData(selIn.id());
    if (inIdx >= 0) inCombo->setCurrentIndex(inIdx);
    inRow->addWidget(inLabel);
    inRow->addWidget(inCombo, 1);
    pcLayout->addLayout(inRow);

    // Output device
    auto* outRow = new QHBoxLayout;
    auto* outLabel = new QLabel("Output:");
    outLabel->setStyleSheet(kLabelStyle);
    outLabel->setFixedWidth(90);
    auto* outCombo = new QComboBox;
    AetherSDR::applyComboStyle(outCombo);
    const auto outDevices = QMediaDevices::audioOutputs();
    for (const auto& dev : outDevices)
        outCombo->addItem(dev.description(), dev.id());
    // Select current device (or system default)
    const auto curOut = m_audio ? m_audio->outputDevice() : QAudioDevice();
    const auto selOut = curOut.isNull() ? QMediaDevices::defaultAudioOutput() : curOut;
    int outIdx = outCombo->findData(selOut.id());
    if (outIdx >= 0) outCombo->setCurrentIndex(outIdx);
    outRow->addWidget(outLabel);
    outRow->addWidget(outCombo, 1);
    pcLayout->addLayout(outRow);

    auto* promptCheck = new QCheckBox("Prompt on Audio Device Changes");
    AetherSDR::ThemeManager::instance().applyStyleSheet(promptCheck,
        "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }"
        + kCheckBoxIndicator);
    promptCheck->setToolTip("Show the Audio Device Detected dialog when a new PC audio device appears.");
    const bool suppressAudioDeviceNotifications =
        AppSettings::instance()
            .value(kSuppressAudioDeviceNotificationsKey, "False")
            .toString() == "True";
    promptCheck->setChecked(!suppressAudioDeviceNotifications);
    connect(promptCheck, &QCheckBox::toggled, this, [](bool on) {
        auto& s = AppSettings::instance();
        s.setValue(kSuppressAudioDeviceNotificationsKey, on ? "False" : "True");
        s.save();
    });
    pcLayout->addWidget(promptCheck);

    // Wire device changes to AudioEngine
    if (m_audio) {
        // Route through QueuedConnection so setInputDevice/setOutputDevice
        // execute on the audio worker thread, preventing use-after-free on
        // macOS CoreAudio when switching devices from the GUI thread (#1114).
        connect(inCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
                this, [this, inDevices](int idx) {
            if (idx >= 0 && idx < inDevices.size()) {
                const QAudioDevice dev = inDevices[idx];
                QMetaObject::invokeMethod(m_audio, [this, dev]() {
                    m_audio->setInputDevice(dev);
                }, Qt::QueuedConnection);
            }
        });
        connect(outCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
                this, [this, outDevices](int idx) {
            if (idx >= 0 && idx < outDevices.size()) {
                const QAudioDevice dev = outDevices[idx];
                QMetaObject::invokeMethod(m_audio, [this, dev]() {
                    m_audio->setOutputDevice(dev);
                }, Qt::QueuedConnection);
            }
        });
    }

    // Audio Boost toggle
    {
        auto* boostRow = new QHBoxLayout;
        auto* boostLabel = new QLabel("Audio Boost:");
        boostLabel->setStyleSheet(kLabelStyle);
        boostLabel->setFixedWidth(90);
        bool boostOn = AppSettings::instance().value("AudioBoost", "False").toString() == "True";
        auto* boostBtn = new QPushButton(boostOn ? "Enabled" : "Disabled");
        boostBtn->setCheckable(true);
        boostBtn->setChecked(boostOn);
        boostBtn->setToolTip("Apply 50% software gain boost to PC audio output.\n"
                             "Compensates for lower levels with AGC-controlled audio.");
        AetherSDR::ThemeManager::instance().applyStyleSheet(boostBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
            "padding: 3px 10px; }"
            "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; "
            "border: 1px solid #20a040; }");
        connect(boostBtn, &QPushButton::toggled, this, [this, boostBtn](bool on) {
            boostBtn->setText(on ? "Enabled" : "Disabled");
            auto& s = AppSettings::instance();
            s.setValue("AudioBoost", on ? "True" : "False");
            s.save();
            if (m_audio) {
                QMetaObject::invokeMethod(m_audio, [this, on]() {
                    m_audio->setRxBoost(on);
                }, Qt::QueuedConnection);
            }
        });
        boostRow->addWidget(boostLabel);
        boostRow->addWidget(boostBtn);
        boostRow->addStretch(1);
        pcLayout->addLayout(boostRow);
    }

    // Audio Buffer (ms)
    {
        auto* bufRow = new QHBoxLayout;
        auto* bufLabel = new QLabel("Audio Buffer:");
        bufLabel->setStyleSheet(kLabelStyle);
        bufLabel->setFixedWidth(90);
        int bufMs = AppSettings::instance().value("AudioBufferMs", "100").toInt();
        auto* bufEdit = new QLineEdit(QString::number(bufMs));
        bufEdit->setStyleSheet(kEditStyle);
        bufEdit->setFixedWidth(50);
        auto* bufUnit = new QLabel("ms");
        bufUnit->setStyleSheet(kLabelStyle);
        auto* bufHint = new QLabel("(50–1000, increase for VPN/SmartLink jitter)");
        AetherSDR::ThemeManager::instance().applyStyleSheet(bufHint, "QLabel { color: {{color.background.3}}; font-size: 10px; }");
        connect(bufEdit, &QLineEdit::editingFinished, this, [this, bufEdit] {
            int val = qBound(50, bufEdit->text().toInt(), 1000);
            bufEdit->setText(QString::number(val));
            auto& s = AppSettings::instance();
            s.setValue("AudioBufferMs", QString::number(val));
            s.save();
            if (m_audio) {
                QMetaObject::invokeMethod(m_audio, [this, val]() {
                    m_audio->setRxBufferCapMs(val);
                }, Qt::QueuedConnection);
            }
        });
        bufRow->addWidget(bufLabel);
        bufRow->addWidget(bufEdit);
        bufRow->addWidget(bufUnit);
        bufRow->addWidget(bufHint);
        bufRow->addStretch(1);
        pcLayout->addLayout(bufRow);
    }

    vbox->addWidget(pcGroup);

    // ── Recording ───────────────────────────────────────────────────────
    {
        auto* recGroup = new QGroupBox("Recording");
        recGroup->setStyleSheet(kGroupStyle);
        auto* recLayout = new QVBoxLayout(recGroup);

        auto& settings = AppSettings::instance();

        // Mode: Radio Side vs Client Side
        auto* modeRow = new QHBoxLayout;
        auto* modeLabel = new QLabel("Record Mode:");
        modeLabel->setStyleSheet(kLabelStyle);
        modeLabel->setFixedWidth(90);
        modeRow->addWidget(modeLabel);

        const QString modeBtnStyle =
            "QPushButton { background: #1a2a3a; color: #c8d8e8; border: 1px solid #304050; "
            "border-radius: 3px; padding: 2px 10px; font-size: 11px; }"
            "QPushButton:checked { background: #00607a; color: #e0f0ff; border-color: #00b4d8; }";

        auto* radioSideBtn = new QPushButton("Radio Side");
        radioSideBtn->setCheckable(true);
        radioSideBtn->setStyleSheet(modeBtnStyle);
        auto* clientSideBtn = new QPushButton("Client Side");
        clientSideBtn->setCheckable(true);
        clientSideBtn->setStyleSheet(modeBtnStyle);

        bool clientSide = settings.value("RecordingMode", "Client").toString() == "Client";
        radioSideBtn->setChecked(!clientSide);
        clientSideBtn->setChecked(clientSide);

        connect(radioSideBtn, &QPushButton::clicked, this, [radioSideBtn, clientSideBtn]() {
            QSignalBlocker b(clientSideBtn);
            radioSideBtn->setChecked(true);
            clientSideBtn->setChecked(false);
            auto& s = AppSettings::instance();
            s.setValue("RecordingMode", "Radio");
            s.save();
        });
        connect(clientSideBtn, &QPushButton::clicked, this, [radioSideBtn, clientSideBtn]() {
            QSignalBlocker b(radioSideBtn);
            clientSideBtn->setChecked(true);
            radioSideBtn->setChecked(false);
            auto& s = AppSettings::instance();
            s.setValue("RecordingMode", "Client");
            s.save();
        });

        modeRow->addWidget(radioSideBtn);
        modeRow->addWidget(clientSideBtn);
        modeRow->addStretch();
        recLayout->addLayout(modeRow);

        // Recording directory (client-side only)
        auto* dirRow = new QHBoxLayout;
        auto* dirLabel = new QLabel("Save to:");
        dirLabel->setStyleSheet(kLabelStyle);
        dirLabel->setFixedWidth(90);
        auto* dirEdit = new QLineEdit;
        dirEdit->setText(settings.value("QsoRecordingDir",
            QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation)
            + "/AetherSDR/Recordings").toString());
        AetherSDR::ThemeManager::instance().applyStyleSheet(dirEdit, "QLineEdit { background: {{color.background.1}}; color: {{color.text.primary}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; padding: 2px 4px; font-size: 11px; }");
        auto* browseBtn = new QPushButton("...");
        browseBtn->setFixedWidth(30);
        browseBtn->setStyleSheet(modeBtnStyle);
        connect(browseBtn, &QPushButton::clicked, this, [this, dirEdit]() {
            QString dir = QFileDialog::getExistingDirectory(this, "Select Recording Directory",
                                                            dirEdit->text());
            if (!dir.isEmpty()) {
                dirEdit->setText(dir);
                auto& s = AppSettings::instance();
                s.setValue("QsoRecordingDir", dir);
                s.save();
            }
        });
        connect(dirEdit, &QLineEdit::editingFinished, this, [dirEdit]() {
            auto& s = AppSettings::instance();
            s.setValue("QsoRecordingDir", dirEdit->text());
            s.save();
        });
        dirRow->addWidget(dirLabel);
        dirRow->addWidget(dirEdit, 1);
        dirRow->addWidget(browseBtn);
        recLayout->addLayout(dirRow);

        // Auto-record on TX
        auto* autoRow = new QHBoxLayout;
        auto* autoCheck = new QCheckBox("Auto-record on TX");
        AetherSDR::ThemeManager::instance().applyStyleSheet(autoCheck,
            "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }"
            + kCheckBoxIndicator);
        autoCheck->setChecked(settings.value("QsoRecordingAutoRecord", "False").toString() == "True");
        connect(autoCheck, &QCheckBox::toggled, this, [](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("QsoRecordingAutoRecord", on ? "True" : "False");
            s.save();
        });
        autoRow->addWidget(autoCheck);

        // Idle timeout
        auto* timeoutLabel = new QLabel("Idle timeout:");
        AetherSDR::ThemeManager::instance().applyStyleSheet(timeoutLabel, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }");
        auto* timeoutSpin = new QSpinBox;
        timeoutSpin->setRange(10, 3600);
        timeoutSpin->setSuffix(" sec");
        timeoutSpin->setValue(settings.value("QsoRecordingIdleTimeout", "120").toInt());
        AetherSDR::ThemeManager::instance().applyStyleSheet(timeoutSpin, "QSpinBox { background: {{color.background.1}}; color: {{color.text.primary}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; padding: 2px; font-size: 11px; }");
        connect(timeoutSpin, QOverload<int>::of(&QSpinBox::valueChanged), this, [](int v) {
            auto& s = AppSettings::instance();
            s.setValue("QsoRecordingIdleTimeout", QString::number(v));
            s.save();
        });
        autoRow->addStretch();
        autoRow->addWidget(timeoutLabel);
        autoRow->addWidget(timeoutSpin);
        recLayout->addLayout(autoRow);

        vbox->addWidget(recGroup);
    }

    vbox->addStretch(1);
    return page;
}

// ── Filters tab ─────────────────────────────────────────────────────────────

QWidget* RadioSetupDialog::buildFiltersTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    static const QString kAutoBtn =
        "QPushButton { background: #1a2a3a; border: 1px solid #304050; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; "
        "padding: 3px 10px; }"
        "QPushButton:checked { background: #0070c0; color: #ffffff; "
        "border: 1px solid #0090e0; }";

    // Filter sharpness sliders — taller groove + wider handle than the
    // canonical slider for the radio-setup dialog's heavier visual style.
    // Sizes kept site-local (deliberate emphasis); colours routed through
    // color.slider.* so live theme switching works and per-applet
    // overrides could retint these in future without per-call-site work.
    static const QString kFilterSlider = QStringLiteral(
        "QSlider::groove:horizontal { background: {{color.slider.background}}; height: 6px; border-radius: 3px; }"
        "QSlider::handle:horizontal { background: {{color.slider.handle}}; width: 14px; "
        "margin: -5px 0; border-radius: 7px; }");

    // Filter Options group
    {
        auto* group = new QGroupBox("Filter Options");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(8);

        // Column headers
        auto* lowLbl = new QLabel("Low Latency");
        lowLbl->setStyleSheet(kLabelStyle);
        lowLbl->setAlignment(Qt::AlignCenter);
        grid->addWidget(lowLbl, 0, 1);
        auto* sharpLbl = new QLabel("Sharp Filters");
        sharpLbl->setStyleSheet(kLabelStyle);
        sharpLbl->setAlignment(Qt::AlignCenter);
        grid->addWidget(sharpLbl, 0, 2);

        struct FilterRow {
            const char* label;
            const char* modeCmd;   // voice, cw, digital
            int level;
            bool autoOn;
        };
        FilterRow rows[] = {
            {"Voice:",   "voice",   m_model->filterSharpnessVoice(),   m_model->filterSharpnessVoiceAuto()},
            {"CW:",      "cw",      m_model->filterSharpnessCw(),      m_model->filterSharpnessCwAuto()},
            {"Digital:", "digital", m_model->filterSharpnessDigital(), m_model->filterSharpnessDigitalAuto()},
        };

        for (int i = 0; i < 3; ++i) {
            auto& r = rows[i];
            int row = i + 1;

            auto* lbl = new QLabel(r.label);
            lbl->setStyleSheet(kLabelStyle);
            grid->addWidget(lbl, row, 0);

            auto* slider = new GuardedSlider(Qt::Horizontal);
            slider->setRange(0, 3);
            slider->setValue(r.level);
            AetherSDR::ThemeManager::instance().applyStyleSheet(slider, kFilterSlider);
            slider->setEnabled(!r.autoOn);
            grid->addWidget(slider, row, 1, 1, 2);

            auto* autoBtn = new QPushButton("Auto");
            autoBtn->setCheckable(true);
            autoBtn->setChecked(r.autoOn);
            autoBtn->setStyleSheet(kAutoBtn);
            grid->addWidget(autoBtn, row, 3);

            QString mode = QString::fromLatin1(r.modeCmd);
            connect(slider, &QSlider::valueChanged, this, [this, mode](int v) {
                m_model->sendCommand(
                    QString("radio filter_sharpness %1 level=%2").arg(mode).arg(v));
            });
            connect(autoBtn, &QPushButton::toggled, this, [this, slider, mode](bool on) {
                slider->setEnabled(!on);
                m_model->sendCommand(
                    QString("radio filter_sharpness %1 auto_level=%2").arg(mode).arg(on ? 1 : 0));
            });
        }

        vbox->addWidget(group);
    }

    // Low Latency Digital checkbox
    {
        auto* group = new QGroupBox;
        group->setStyleSheet(kGroupStyle);
        auto* hb = new QHBoxLayout(group);

        auto* chk = new QCheckBox("Use Low Latency Filters for Digital Modes");
        chk->setChecked(m_model->lowLatencyDigital());
        AetherSDR::ThemeManager::instance().applyStyleSheet(chk,
            "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }"
            + kCheckBoxIndicator);
        connect(chk, &QCheckBox::toggled, this, [this](bool on) {
            m_model->sendCommand(
                QString("radio set low_latency_digital_modes=%1").arg(on ? 1 : 0));
        });
        hb->addWidget(chk);

        vbox->addWidget(group);
    }

    vbox->addStretch(1);
    return page;
}
QWidget* RadioSetupDialog::buildXvtrTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    // Sub-tabs: one per XVTR + a "+" tab to add new
    auto* xvtrTabs = new QTabWidget;
    AetherSDR::ThemeManager::instance().applyStyleSheet(xvtrTabs, "QTabWidget::pane { border: 1px solid {{color.background.2}}; background: {{color.background.0}}; }"
        "QTabBar::tab { background: {{color.background.1}}; color: {{color.text.secondary}}; "
        "border: 1px solid {{color.background.2}}; padding: 3px 10px; margin-right: 2px; }"
        "QTabBar::tab:selected { background: {{color.background.0}}; color: {{color.text.primary}}; "
        "border-bottom-color: {{color.background.0}}; }");

    auto buildXvtrPage = [this, xvtrTabs](int idx, const RadioModel::XvtrInfo& x) {
        auto* pg = new QWidget;
        auto* grid = new QGridLayout(pg);
        grid->setSpacing(6);

        auto addField = [&](int row, int col, const QString& label, const QString& value,
                             bool editable = true) -> QLineEdit* {
            auto* lbl = new QLabel(label);
            lbl->setStyleSheet(kLabelStyle);
            grid->addWidget(lbl, row, col * 2);
            auto* edit = new QLineEdit(value);
            edit->setStyleSheet(kEditStyle);
            edit->setFixedWidth(100);
            edit->setReadOnly(!editable);
            grid->addWidget(edit, row, col * 2 + 1);
            return edit;
        };

        auto* nameEdit   = addField(0, 0, "Name:", x.name);
        auto* validLbl   = new QLabel(x.isValid ? "Valid" : "Invalid");
        validLbl->setStyleSheet(x.isValid
            ? "QLabel { color: #00c040; font-size: 12px; font-weight: bold; }"
            : "QLabel { color: #c04040; font-size: 12px; font-weight: bold; }");
        grid->addWidget(validLbl, 0, 3);

        auto* rfEdit     = addField(1, 0, "RF Freq (MHz):", QString::number(x.rfFreq, 'f', 3));
        auto* ifEdit     = addField(1, 1, "IF Freq (MHz):", QString::number(x.ifFreq, 'f', 3));
        auto* loEdit     = addField(2, 0, "LO Freq (MHz):", QString::number(x.rfFreq - x.ifFreq, 'f', 3), false);
        auto* errEdit    = addField(2, 1, "LO Error (MHz):", QString::number(x.loError, 'f', 6));
        auto* rxGainEdit = addField(3, 0, "RX Gain (dB):", QString::number(x.rxGain, 'f', 1));

        // RX Only toggle
        auto* rxOnlyLbl = new QLabel("RX Only:");
        rxOnlyLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(rxOnlyLbl, 3, 2);
        auto* rxOnlyBtn = new QPushButton(x.rxOnly ? "Enabled" : "Disabled");
        rxOnlyBtn->setCheckable(true);
        rxOnlyBtn->setChecked(x.rxOnly);
        AetherSDR::ThemeManager::instance().applyStyleSheet(rxOnlyBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; font-weight: bold; "
            "padding: 3px 10px; }"
            "QPushButton:checked { background: #1a5030; color: {{color.accent.success}}; "
            "border: 1px solid #20a040; }");
        connect(rxOnlyBtn, &QPushButton::toggled, this, [this, idx, rxOnlyBtn](bool on) {
            rxOnlyBtn->setText(on ? "Enabled" : "Disabled");
            m_model->sendCommand(
                QString("xvtr set %1 rx_only=%2").arg(idx).arg(on ? 1 : 0));
        });
        grid->addWidget(rxOnlyBtn, 3, 3);

        auto* maxPwrEdit = addField(4, 0, "Max Power (dBm):", QString::number(x.maxPower, 'f', 1));

        // Remove button
        auto* removeBtn = new QPushButton("Remove");
        removeBtn->setStyleSheet(
            "QPushButton { background: #3a1a1a; border: 1px solid #504040; "
            "border-radius: 3px; color: #ff6060; font-size: 11px; font-weight: bold; "
            "padding: 4px 16px; }"
            "QPushButton:hover { background: #502020; }");
        connect(removeBtn, &QPushButton::clicked, pg, [this, idx, xvtrTabs, pg] {
            m_model->sendCommand(QString("xvtr remove %1").arg(idx));
            int tabIdx = xvtrTabs->indexOf(pg);
            if (tabIdx >= 0) xvtrTabs->removeTab(tabIdx);
        });
        grid->addWidget(removeBtn, 4, 3);

        auto maxPowerRange = [this, ifEdit] {
            return XvtrPolicy::maxPowerRangeFor(ifEdit->text().toDouble(), m_model->model());
        };
        auto* maxPwrValidator = new QDoubleValidator(maxPwrEdit);
        maxPwrValidator->setDecimals(2);
        maxPwrValidator->setNotation(QDoubleValidator::StandardNotation);
        auto updateMaxPowerValidator = [maxPwrValidator, maxPowerRange] {
            const XvtrPolicy::MaxPowerRange range = maxPowerRange();
            maxPwrValidator->setRange(range.minimumDbm, range.maximumDbm, 2);
        };
        updateMaxPowerValidator();
        maxPwrEdit->setValidator(maxPwrValidator);
        auto submitMaxPower = [this, maxPwrEdit, ifEdit, idx](bool sendWhenUnchanged) {
            bool ok = false;
            const double requested = maxPwrEdit->text().toDouble(&ok);
            if (!ok) {
                return;
            }

            const double clamped = XvtrPolicy::clampMaxPowerDbm(
                requested, ifEdit->text().toDouble(), m_model->model());
            maxPwrEdit->setText(QString::number(clamped, 'f', 2));
            if (!sendWhenUnchanged && qFuzzyCompare(requested + 1.0, clamped + 1.0)) {
                return;
            }

            m_model->sendCommand(
                QString("xvtr set %1 max_power=%2")
                    .arg(idx)
                    .arg(QString::number(clamped, 'f', 2)));
        };

        // Wire editable fields
        connect(nameEdit, &QLineEdit::editingFinished, this, [this, nameEdit, idx] {
            m_model->sendCommand(
                QString("xvtr set %1 name=%2").arg(idx).arg(nameEdit->text()));
        });
        auto updateLo = [rfEdit, ifEdit, loEdit] {
            double rf = rfEdit->text().toDouble();
            double ifF = ifEdit->text().toDouble();
            loEdit->setText(QString::number(rf - ifF, 'f', 3));
        };
        connect(rfEdit, &QLineEdit::editingFinished, this, [this, rfEdit, idx, updateLo] {
            m_model->sendCommand(
                QString("xvtr set %1 rf_freq=%2").arg(idx).arg(rfEdit->text()));
            updateLo();
        });
        connect(ifEdit, &QLineEdit::editingFinished, this,
                [this, ifEdit, idx, updateLo, updateMaxPowerValidator, submitMaxPower] {
            m_model->sendCommand(
                QString("xvtr set %1 if_freq=%2").arg(idx).arg(ifEdit->text()));
            updateLo();
            updateMaxPowerValidator();
            submitMaxPower(false);
        });
        connect(errEdit, &QLineEdit::editingFinished, this, [this, errEdit, idx] {
            m_model->sendCommand(
                QString("xvtr set %1 lo_error=%2").arg(idx).arg(errEdit->text()));
        });
        connect(rxGainEdit, &QLineEdit::editingFinished, this, [this, rxGainEdit, idx] {
            m_model->sendCommand(
                QString("xvtr set %1 rx_gain=%2").arg(idx).arg(rxGainEdit->text()));
        });
        connect(maxPwrEdit, &QLineEdit::editingFinished, this, [submitMaxPower] {
            submitMaxPower(true);
        });

        return pg;
    };

    // Add existing XVTR pages
    const auto& xvtrs = m_model->xvtrList();
    for (auto it = xvtrs.constBegin(); it != xvtrs.constEnd(); ++it) {
        xvtrTabs->addTab(buildXvtrPage(it.key(), it.value()),
                          it.value().name.isEmpty() ? QString::number(it.key()) : it.value().name);
    }

    // "+" tab to add new
    auto* addPage = new QWidget;
    auto* addVb = new QVBoxLayout(addPage);
    auto* addBtn = new QPushButton("Create New Transverter");
    AetherSDR::ThemeManager::instance().applyStyleSheet(addBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
        "border-radius: 3px; color: {{color.text.primary}}; font-size: 12px; font-weight: bold; "
        "padding: 8px 20px; }"
        "QPushButton:hover { background: {{color.background.1}}; }");
    connect(addBtn, &QPushButton::clicked, this, [this, xvtrTabs, buildXvtrPage] {
        m_model->sendCmdPublic("xvtr create",
            [this, xvtrTabs, buildXvtrPage](int code, const QString& /* body */) {
                if (code != 0) return;
                // Wait briefly for the radio's status update to arrive
                QTimer::singleShot(300, this, [this, xvtrTabs, buildXvtrPage] {
                    // Find the newest XVTR that doesn't have a tab yet
                    const auto& xvtrs = m_model->xvtrList();
                    for (auto it = xvtrs.constBegin(); it != xvtrs.constEnd(); ++it) {
                        // Check if we already have a tab for this index
                        bool found = false;
                        for (int t = 0; t < xvtrTabs->count() - 1; ++t) {
                            if (xvtrTabs->tabText(t) == it.value().name ||
                                xvtrTabs->tabText(t) == QString::number(it.key()))
                                found = true;
                        }
                        if (!found) {
                            QString tabName = it.value().name.isEmpty()
                                ? QString("New") : it.value().name;
                            int insertIdx = xvtrTabs->count() - 1; // before "+"
                            xvtrTabs->insertTab(insertIdx,
                                buildXvtrPage(it.key(), it.value()), tabName);
                            xvtrTabs->setCurrentIndex(insertIdx);
                        }
                    }
                });
            });
    });
    addVb->addWidget(addBtn, 0, Qt::AlignCenter);
    addVb->addStretch(1);
    xvtrTabs->addTab(addPage, "+");

    vbox->addWidget(xvtrTabs);
    vbox->addStretch(1);
    return page;
}

QWidget* RadioSetupDialog::buildAntennaNamesTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    auto* group = new QGroupBox("Antenna Names");
    group->setStyleSheet(kGroupStyle);
    auto* groupLayout = new QVBoxLayout(group);
    groupLayout->setSpacing(6);

    auto* rowsWidget = new QWidget;
    rowsWidget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
    auto* grid = new QGridLayout(rowsWidget);
    grid->setContentsMargins(8, 2, 8, 2);
    grid->setHorizontalSpacing(8);
    grid->setVerticalSpacing(4);
    grid->setAlignment(Qt::AlignTop);
    grid->setColumnStretch(1, 1);
    grid->setColumnStretch(2, 1);

    auto* scroll = new QScrollArea;
    scroll->setWidgetResizable(true);
    scroll->setFrameShape(QFrame::NoFrame);
    scroll->setStyleSheet("QScrollArea { background: transparent; border: none; }");
    scroll->setWidget(rowsWidget);
    groupLayout->addWidget(scroll);
    vbox->addWidget(group, 1);

    QVBoxLayout* kiwiRowsLayout = nullptr;

#ifdef HAVE_KEYCHAIN
    const QString kiwiPasswordHelpText =
        QStringLiteral("Configure receive-only KiwiSDR servers. Passwords "
                       "are saved separately for each receiver in the "
                       "operating system credential store when available. "
                       "The status below each password confirms the result.");
    const QString kiwiPasswordDescription =
        QStringLiteral("Optional password for this KiwiSDR receiver. Type "
                       "over the current value to replace it; the storage "
                       "status below confirms whether the operating system "
                       "credential store accepted it.");
#else
    const QString kiwiPasswordHelpText =
        QStringLiteral("Configure receive-only KiwiSDR servers. This build "
                       "keeps passwords only for the current session.");
    const QString kiwiPasswordDescription =
        QStringLiteral("Optional password for this KiwiSDR receiver. This "
                       "build keeps it only for the current session.");
#endif

    if (m_kiwiSdrManager) {
        auto* kiwiGroup = new QGroupBox("KiwiSDR RX Antennas");
        kiwiGroup->setStyleSheet(kGroupStyle);
        auto* kiwiLayout = new QVBoxLayout(kiwiGroup);
        kiwiLayout->setSpacing(6);

        auto* kiwiHelp = new QLabel(
            kiwiPasswordHelpText);
        kiwiHelp->setWordWrap(true);
        kiwiHelp->setAccessibleName("KiwiSDR receiver configuration help");
        AetherSDR::ThemeManager::instance().applyStyleSheet(
            kiwiHelp,
            "QLabel { color: {{color.text.secondary}}; font-size: 11px; "
            "padding: 0 4px 4px 4px; }");
        kiwiLayout->addWidget(kiwiHelp);

        auto* kiwiCredentialNotice = new QLabel;
        kiwiCredentialNotice->setWordWrap(true);
        kiwiCredentialNotice->setAccessibleName(
            "KiwiSDR credential storage notice");
        kiwiCredentialNotice->setVisible(false);
        AetherSDR::ThemeManager::instance().applyStyleSheet(
            kiwiCredentialNotice,
            "QLabel { color: {{color.accent.danger}}; font-size: 11px; "
            "padding: 4px; }");
        kiwiLayout->addWidget(kiwiCredentialNotice);
        connect(
            m_kiwiSdrManager,
            &KiwiSdrManager::profilePasswordPersistenceChanged,
            kiwiCredentialNotice,
            [kiwiCredentialNotice](
                const QString& id, KiwiSdrPasswordPersistenceState state,
                const QString& detail) {
                if (state != KiwiSdrPasswordPersistenceState::Error) {
                    if (kiwiCredentialNotice->property("profileId").toString()
                        == id) {
                        kiwiCredentialNotice->clear();
                        kiwiCredentialNotice->setVisible(false);
                    }
                    return;
                }
                kiwiCredentialNotice->setProperty("profileId", id);
                kiwiCredentialNotice->setText(detail);
                kiwiCredentialNotice->setAccessibleDescription(detail);
                kiwiCredentialNotice->setVisible(true);
            });

        auto* kiwiScroll = new QScrollArea;
        kiwiScroll->setWidgetResizable(true);
        kiwiScroll->setFrameShape(QFrame::NoFrame);
        kiwiScroll->setMinimumHeight(190);
        kiwiScroll->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::MinimumExpanding);
        kiwiScroll->setStyleSheet("QScrollArea { background: transparent; border: none; }");
        kiwiScroll->setAccessibleName("KiwiSDR RX antennas");
        kiwiScroll->setAccessibleDescription(
            "Configured KiwiSDR receive-only antenna endpoints.");

        auto* kiwiRowsWidget = new QWidget;
        kiwiRowsWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);
        kiwiRowsLayout = new QVBoxLayout(kiwiRowsWidget);
        kiwiRowsLayout->setContentsMargins(8, 4, 8, 4);
        kiwiRowsLayout->setSpacing(6);
        kiwiRowsLayout->setAlignment(Qt::AlignTop);
        kiwiScroll->setWidget(kiwiRowsWidget);
        kiwiLayout->addWidget(kiwiScroll);
        vbox->addWidget(kiwiGroup, 0);
    }

    // Antenna display names are intentionally local to AetherSDR. FlexLib exposes
    // canonical RX/TX antenna lists and rxant/txant setters, but no verified
    // writable display-name API; radio commands must keep using ANT1/XVTR/etc.
    auto refresh = std::make_shared<std::function<void()>>();
    auto scheduleRefresh = [this, refresh] {
        QTimer::singleShot(0, this, [refresh] {
            if (*refresh)
                (*refresh)();
        });
    };

    auto wireSlice = [this, scheduleRefresh](SliceModel* slice) {
        if (!slice)
            return;
        connect(slice, &SliceModel::rxAntennaChanged, this,
                [scheduleRefresh](const QString&) { scheduleRefresh(); });
        connect(slice, &SliceModel::txAntennaChanged, this,
                [scheduleRefresh](const QString&) { scheduleRefresh(); });
        connect(slice, &SliceModel::rxAntennaListChanged, this,
                [scheduleRefresh](const QStringList&) { scheduleRefresh(); });
        connect(slice, &SliceModel::txAntennaListChanged, this,
                [scheduleRefresh](const QStringList&) { scheduleRefresh(); });
    };

    *refresh = [this, grid] {
        while (QLayoutItem* item = grid->takeAt(0)) {
            if (QWidget* w = item->widget())
                w->deleteLater();
            delete item;
        }

        auto addHeader = [grid](const QString& text, int col) {
            auto* lbl = new QLabel(text);
            AetherSDR::ThemeManager::instance().applyStyleSheet(lbl, "QLabel { color: {{color.text.secondary}}; font-size: 11px; font-weight: bold; }");
            grid->addWidget(lbl, 0, col);
        };
        addHeader("Port", 0);
        addHeader("Custom name", 1);
        addHeader("Preview", 2);

        const QStringList tokens = m_model->knownAntennaTokens();
        if (tokens.isEmpty()) {
            auto* empty = new QLabel("Waiting for antenna ports from the radio.");
            AetherSDR::ThemeManager::instance().applyStyleSheet(empty, "QLabel { color: {{color.text.label}}; font-size: 12px; padding: 8px; }");
            grid->addWidget(empty, 1, 0, 1, 4);
            return;
        }

        int row = 1;
        for (const QString& token : tokens) {
            auto* port = new QLabel(token);
            port->setStyleSheet(kValueStyle);
            grid->addWidget(port, row, 0);

            auto* edit = new QLineEdit(m_model->antennaAlias(token));
            edit->setMaxLength(16);
            edit->setStyleSheet(kEditStyle);
            edit->setPlaceholderText(token);
            grid->addWidget(edit, row, 1);

            const bool disambiguate =
                m_model->antennaAliasNeedsDisambiguation(token, tokens);
            auto* preview = new QLabel(m_model->antennaDisplayName(token, disambiguate));
            preview->setStyleSheet(kLabelStyle);
            grid->addWidget(preview, row, 2);

            auto* clearBtn = new QPushButton("Clear");
            AetherSDR::ThemeManager::instance().applyStyleSheet(clearBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
                "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; "
                "font-weight: bold; padding: 3px 10px; }"
                "QPushButton:hover { background: {{color.background.1}}; }");
            grid->addWidget(clearBtn, row, 3);

            connect(edit, &QLineEdit::textChanged, this,
                    [preview, token](const QString& text) {
                const QString alias = text.trimmed();
                preview->setText(alias.isEmpty() ? token : alias);
            });
            connect(edit, &QLineEdit::editingFinished, this, [this, edit, token] {
                m_model->setAntennaAlias(token, edit->text());
            });
            connect(clearBtn, &QPushButton::clicked, this, [this, edit, token] {
                edit->clear();
                m_model->clearAntennaAlias(token);
            });
            ++row;
        }
    };

    for (SliceModel* slice : m_model->slices())
        wireSlice(slice);
    connect(m_model, &RadioModel::sliceAdded, this,
            [wireSlice, scheduleRefresh](SliceModel* slice) {
        wireSlice(slice);
        scheduleRefresh();
    });
    connect(m_model, &RadioModel::antListChanged, this,
            [scheduleRefresh](const QStringList&) { scheduleRefresh(); });
    connect(m_model, &RadioModel::antennaAliasesChanged, this, scheduleRefresh);

    auto refreshKiwi = std::make_shared<std::function<void()>>();
    if (m_kiwiSdrManager && kiwiRowsLayout) {
        auto* kiwiTelemetryRefreshTimer = new QTimer(this);
        kiwiTelemetryRefreshTimer->setSingleShot(true);
        kiwiTelemetryRefreshTimer->setInterval(150);
        connect(kiwiTelemetryRefreshTimer, &QTimer::timeout,
                this, [refreshKiwi] {
            if (*refreshKiwi) {
                (*refreshKiwi)();
            }
        });

        auto refreshKiwiNow = [refreshKiwi, kiwiTelemetryRefreshTimer] {
            kiwiTelemetryRefreshTimer->stop();
            if (*refreshKiwi) {
                (*refreshKiwi)();
            }
        };

        auto stateText = [this](const QString& id) {
            const KiwiSdrClient::State state = m_kiwiSdrManager->state(id);
            QString base;
            switch (state) {
            case KiwiSdrClient::State::Disconnected:
                base = QStringLiteral("Disconnected");
                break;
            case KiwiSdrClient::State::Connecting:
                base = QStringLiteral("Connecting");
                break;
            case KiwiSdrClient::State::Connected:
                base = QStringLiteral("Connected");
                break;
            case KiwiSdrClient::State::Busy:
                base = QStringLiteral("Busy");
                break;
            case KiwiSdrClient::State::Waiting:
                base = QStringLiteral("Waiting");
                break;
            case KiwiSdrClient::State::Camping:
                base = QStringLiteral("Monitoring");
                break;
            case KiwiSdrClient::State::CampDisconnected:
                base = QStringLiteral("Camp ended");
                break;
            case KiwiSdrClient::State::Error:
                base = QStringLiteral("Error");
                break;
            }
            const QString detail = m_kiwiSdrManager->stateDetail(id).trimmed();
            if (!detail.isEmpty()
                && state != KiwiSdrClient::State::Connected
                && state != KiwiSdrClient::State::Connecting) {
                base = QStringLiteral("%1\n%2").arg(base, detail);
            }
            const QString metadata = kiwiSetupMetadataSummary(
                m_kiwiSdrManager, id);
            if (!metadata.isEmpty()) {
                base = QStringLiteral("%1\n%2")
                           .arg(base.isEmpty()
                                    ? QStringLiteral("Disconnected")
                                    : base,
                                metadata);
            }
            return base.isEmpty() ? QStringLiteral("Disconnected") : base;
        };

        auto styleKiwiEdit = [](QLineEdit* edit) {
            edit->setStyleSheet(kEditStyle);
            edit->setMinimumHeight(24);
        };

        auto styleKiwiButton = [](QPushButton* button) {
            button->setStyleSheet(kKiwiActionButtonStyle);
            button->setMinimumWidth(96);
            button->setAutoDefault(false);
        };

        auto styleKiwiIconButton = [](QPushButton* button) {
            button->setStyleSheet(kKiwiIconButtonStyle);
            button->setFixedSize(30, 26);
            button->setAutoDefault(false);
        };

        *refreshKiwi = [this, kiwiRowsLayout, stateText, styleKiwiEdit,
                        styleKiwiButton, styleKiwiIconButton,
                        kiwiPasswordDescription] {
            while (QLayoutItem* item = kiwiRowsLayout->takeAt(0)) {
                if (QWidget* widget = item->widget()) {
                    widget->deleteLater();
                }
                delete item;
            }

            const QVector<KiwiSdrAntennaProfile> profiles =
                m_kiwiSdrManager->profiles();
            auto addSectionHeader = [kiwiRowsLayout](const QString& text) {
                auto* header = new QLabel(text);
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    header,
                    "QLabel { color: {{color.accent.bright}}; font-size: 11px; "
                    "font-weight: bold; padding: 4px 2px 1px 2px; }");
                kiwiRowsLayout->addWidget(header);
            };
            auto addFieldLabel = [](QGridLayout* layout, const QString& text,
                                    int column) {
                auto* label = new QLabel(text);
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    label,
                    "QLabel { color: {{color.text.secondary}}; font-size: 10px; "
                    "font-weight: bold; }");
                layout->addWidget(label, 1, column);
            };
            auto passwordPersistenceText = [](KiwiSdrPasswordPersistenceState state,
                                              const QString& detail) {
                switch (state) {
                case KiwiSdrPasswordPersistenceState::Loading:
                    return QStringLiteral("Loading secure password…");
                case KiwiSdrPasswordPersistenceState::NoPassword:
                    return QStringLiteral("No password stored");
                case KiwiSdrPasswordPersistenceState::Saving:
                    return QStringLiteral("Saving securely…");
                case KiwiSdrPasswordPersistenceState::Stored:
                    return QStringLiteral("Stored securely");
                case KiwiSdrPasswordPersistenceState::SessionOnly:
                    return QStringLiteral("Current session only");
                case KiwiSdrPasswordPersistenceState::Error:
                    return detail.isEmpty()
                        ? QStringLiteral("Password was not stored")
                        : detail;
                }
                return QString();
            };

            addSectionHeader("CONFIGURED RECEIVERS");
            if (profiles.isEmpty()) {
                auto* empty = new QLabel("No KiwiSDR receivers configured.");
                empty->setAccessibleName("No configured KiwiSDR receivers");
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    empty,
                    "QLabel { color: {{color.text.label}}; font-size: 12px; "
                    "padding: 8px; }");
                kiwiRowsLayout->addWidget(empty);
            }

            for (const KiwiSdrAntennaProfile& profile : profiles) {

                auto* rowFrame = new QFrame;
                rowFrame->setObjectName("kiwiAntennaRow");
                rowFrame->setAccessibleName(
                    QStringLiteral("KiwiSDR receiver %1").arg(profile.name));
                rowFrame->setStyleSheet(kKiwiRowStyle);
                auto* rowLayout = new QGridLayout(rowFrame);
                rowLayout->setContentsMargins(10, 8, 10, 8);
                rowLayout->setHorizontalSpacing(8);
                rowLayout->setVerticalSpacing(4);
                rowLayout->setColumnStretch(0, 1);
                rowLayout->setColumnStretch(1, 1);
                rowLayout->setColumnStretch(2, 1);

                auto* title = new QLabel(profile.name);
                title->setAccessibleName("KiwiSDR receiver name");
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    title,
                    "QLabel { color: {{color.text.primary}}; font-size: 14px; "
                    "font-weight: bold; }");
                rowLayout->addWidget(title, 0, 0, 1, 2);

                const KiwiSdrClient::State kiwiState =
                    m_kiwiSdrManager->state(profile.id);
                auto* status = new QLabel(stateText(profile.id));
                status->setAccessibleName("KiwiSDR antenna status");
                status->setAccessibleDescription(status->text());
                QString statusColor = QStringLiteral("{{color.text.secondary}}");
                if (kiwiState == KiwiSdrClient::State::Connected
                    || kiwiState == KiwiSdrClient::State::Camping) {
                    statusColor = QStringLiteral("{{color.accent.success}}");
                } else if (kiwiState == KiwiSdrClient::State::Error) {
                    statusColor = QStringLiteral("{{color.accent.danger}}");
                } else if (kiwiState == KiwiSdrClient::State::Connecting
                           || kiwiState == KiwiSdrClient::State::Waiting) {
                    statusColor = QStringLiteral("{{color.accent.bright}}");
                }
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    status,
                    QStringLiteral("QLabel { color: %1; font-size: 12px; "
                                   "padding-left: 6px; }")
                        .arg(statusColor));
                status->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
                status->setWordWrap(true);
                status->setToolTip(status->text());
                rowLayout->addWidget(status, 0, 2, 1, 2);

                addFieldLabel(rowLayout, "NAME", 0);
                addFieldLabel(rowLayout, "SERVER", 1);
                addFieldLabel(rowLayout, "PASSWORD", 2);

                auto* nameEdit = new QLineEdit(profile.name);
                nameEdit->setMaxLength(16);
                nameEdit->setAccessibleName("KiwiSDR antenna name");
                nameEdit->setAccessibleDescription(
                    "Required display name for this KiwiSDR receive antenna.");
                styleKiwiEdit(nameEdit);
                rowLayout->addWidget(nameEdit, 2, 0);

                auto* endpointEdit = new QLineEdit(profile.endpoint);
                endpointEdit->setAccessibleName("KiwiSDR server");
                endpointEdit->setAccessibleDescription(
                    "Hostname or hostname:port for this KiwiSDR endpoint.");
                styleKiwiEdit(endpointEdit);
                rowLayout->addWidget(endpointEdit, 2, 1);

                auto* passwordEdit = new QLineEdit(
                    m_kiwiSdrManager->profilePassword(profile.id));
                passwordEdit->setEchoMode(QLineEdit::Password);
                passwordEdit->setMaxLength(256);
                passwordEdit->setObjectName(
                    QStringLiteral("kiwiPassword_%1").arg(profile.id));
                passwordEdit->setAccessibleName("KiwiSDR password");
                passwordEdit->setAccessibleDescription(
                    kiwiPasswordDescription);
                if (!m_kiwiSdrManager->isProfilePasswordLoaded(profile.id)) {
                    passwordEdit->setPlaceholderText("Loading secure password…");
                    passwordEdit->setEnabled(false);
                }
                styleKiwiEdit(passwordEdit);
                rowLayout->addWidget(passwordEdit, 2, 2, 1, 2);

                const KiwiSdrPasswordPersistenceState persistenceState =
                    m_kiwiSdrManager->profilePasswordPersistenceState(
                        profile.id);
                auto* passwordStatus = new QLabel(passwordPersistenceText(
                    persistenceState,
                    m_kiwiSdrManager->profilePasswordPersistenceDetail(
                        profile.id)));
                passwordStatus->setObjectName(
                    QStringLiteral("kiwiPasswordStatus_%1").arg(profile.id));
                passwordStatus->setAccessibleName(
                    "KiwiSDR password storage status");
                passwordStatus->setAccessibleDescription(
                    passwordStatus->text());
                passwordStatus->setWordWrap(true);
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    passwordStatus,
                    persistenceState == KiwiSdrPasswordPersistenceState::Error
                        ? "QLabel { color: {{color.accent.danger}}; "
                          "font-size: 10px; padding: 0 2px; }"
                        : "QLabel { color: {{color.text.secondary}}; "
                          "font-size: 10px; padding: 0 2px; }");
                rowLayout->addWidget(passwordStatus, 3, 2, 1, 2);
                connect(
                    m_kiwiSdrManager,
                    &KiwiSdrManager::profilePasswordChanged,
                    passwordEdit,
                    [manager = m_kiwiSdrManager, profileId = profile.id,
                     passwordEdit](const QString& changedId) {
                        if (changedId != profileId) {
                            return;
                        }
                        const QSignalBlocker blocker(passwordEdit);
                        passwordEdit->setText(
                            manager->profilePassword(profileId));
                        passwordEdit->setPlaceholderText(QString());
                        passwordEdit->setEnabled(true);
                    });
                connect(
                    m_kiwiSdrManager,
                    &KiwiSdrManager::profilePasswordPersistenceChanged,
                    passwordStatus,
                    [profileId = profile.id, passwordStatus,
                     passwordPersistenceText](
                        const QString& changedId,
                        KiwiSdrPasswordPersistenceState state,
                        const QString& detail) {
                        if (changedId != profileId) {
                            return;
                        }
                        const QString text =
                            passwordPersistenceText(state, detail);
                        passwordStatus->setText(text);
                        passwordStatus->setAccessibleDescription(text);
                        AetherSDR::ThemeManager::instance().applyStyleSheet(
                            passwordStatus,
                            state == KiwiSdrPasswordPersistenceState::Error
                                ? "QLabel { color: {{color.accent.danger}}; "
                                  "font-size: 10px; padding: 0 2px; }"
                                : "QLabel { color: {{color.text.secondary}}; "
                                  "font-size: 10px; padding: 0 2px; }");
                    });

                auto* autoCheck = new QCheckBox;
                autoCheck->setText("Auto-connect");
                autoCheck->setChecked(profile.autoConnect);
                autoCheck->setAccessibleName("Auto connect KiwiSDR antenna");
                AetherSDR::ThemeManager::instance().applyStyleSheet(autoCheck,
                    "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }"
                    + kCheckBoxIndicator);
                rowLayout->addWidget(autoCheck, 4, 0, 1, 2, Qt::AlignLeft);

                auto* keepTxAudioCheck = new QCheckBox;
                keepTxAudioCheck->setText("Keep audio during TX");
                keepTxAudioCheck->setChecked(profile.keepAudioDuringTx);
                keepTxAudioCheck->setAccessibleName(
                    "Keep KiwiSDR audio during transmit");
                keepTxAudioCheck->setToolTip(
                    "Keep playing this receiver's audio while transmitting.\n"
                    "Off: its audio is silenced during TX and resumes "
                    "immediately at unkey.");
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    keepTxAudioCheck,
                    "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }"
                    + kCheckBoxIndicator);
                rowLayout->addWidget(keepTxAudioCheck, 5, 0, 1, 2,
                                     Qt::AlignLeft);

                auto* resumeDelayCheck = new QCheckBox;
                resumeDelayCheck->setText("Resume audio after TX delay");
                resumeDelayCheck->setChecked(profile.resumeAudioAfterTxDelay);
                resumeDelayCheck->setEnabled(!profile.keepAudioDuringTx);
                resumeDelayCheck->setAccessibleName(
                    "Resume KiwiSDR audio after transmit delay");
                resumeDelayCheck->setToolTip(
                    "After unkeying, wait out this receiver's stream delay "
                    "before unmuting, so you rejoin on audio received after "
                    "your transmission ended instead of hearing your own "
                    "delayed TX tail.\nNo effect while \"Keep audio during "
                    "TX\" is on.");
                AetherSDR::ThemeManager::instance().applyStyleSheet(
                    resumeDelayCheck,
                    "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }"
                    + kCheckBoxIndicator);
                rowLayout->addWidget(resumeDelayCheck, 6, 0, 1, 2,
                                     Qt::AlignLeft);

                const bool activeSession =
                    kiwiState == KiwiSdrClient::State::Connecting
                    || kiwiState == KiwiSdrClient::State::Waiting
                    || KiwiSdrClient::stateHasReceiveAudio(kiwiState);
                auto* connectButton =
                    new QPushButton(activeSession ? "Disconnect" : "Connect");
                connectButton->setAccessibleName(
                    activeSession ? "Disconnect KiwiSDR antenna"
                                  : "Connect KiwiSDR antenna");
                styleKiwiButton(connectButton);
                rowLayout->addWidget(connectButton, 4, 2);

                auto* removeButton = new QPushButton;
                removeButton->setIcon(style()->standardIcon(QStyle::SP_TrashIcon));
                removeButton->setToolTip("Remove");
                removeButton->setAccessibleName("Remove KiwiSDR antenna");
                styleKiwiIconButton(removeButton);
                rowLayout->addWidget(removeButton, 4, 3);
                kiwiRowsLayout->addWidget(rowFrame);

                auto updateProfile = [this, profile, nameEdit, endpointEdit,
                                      autoCheck, keepTxAudioCheck,
                                      resumeDelayCheck] {
                    const QString name = nameEdit->text().trimmed();
                    const QString endpoint =
                        KiwiSdrClient::normalizeEndpoint(endpointEdit->text());
                    if (name.isEmpty()) {
                        QSignalBlocker blocker(nameEdit);
                        nameEdit->setText(profile.name);
                        return;
                    }
                    if (endpoint.isEmpty()) {
                        QSignalBlocker blocker(endpointEdit);
                        endpointEdit->setText(profile.endpoint);
                        return;
                    }
                    if (endpointEdit->text() != endpoint) {
                        QSignalBlocker blocker(endpointEdit);
                        endpointEdit->setText(endpoint);
                    }
                    KiwiSdrAntennaProfile updated = profile;
                    updated.name = name;
                    updated.endpoint = endpoint;
                    updated.autoConnect = autoCheck->isChecked();
                    updated.keepAudioDuringTx = keepTxAudioCheck->isChecked();
                    updated.resumeAudioAfterTxDelay =
                        resumeDelayCheck->isChecked();
                    m_kiwiSdrManager->updateProfile(updated);
                };
                connect(nameEdit, &QLineEdit::editingFinished,
                        this, updateProfile);
                connect(nameEdit, &QLineEdit::returnPressed,
                        this, updateProfile);
                connect(endpointEdit, &QLineEdit::editingFinished,
                        this, updateProfile);
                connect(endpointEdit, &QLineEdit::returnPressed,
                        this, updateProfile);
                connect(passwordEdit, &QLineEdit::editingFinished,
                        this, [this, profile, passwordEdit] {
                    m_kiwiSdrManager->setProfilePassword(
                        profile.id, passwordEdit->text());
                });
                connect(autoCheck, &QCheckBox::toggled,
                        this, [updateProfile](bool) { updateProfile(); });
                connect(keepTxAudioCheck, &QCheckBox::toggled,
                        this, [updateProfile, resumeDelayCheck](bool on) {
                    resumeDelayCheck->setEnabled(!on);
                    updateProfile();
                });
                connect(resumeDelayCheck, &QCheckBox::toggled,
                        this, [updateProfile](bool) { updateProfile(); });
                connect(connectButton, &QPushButton::clicked,
                        this, [this, profile, activeSession] {
                    if (activeSession) {
                        m_kiwiSdrManager->disconnectProfile(profile.id);
                    } else {
                        m_kiwiSdrManager->connectProfile(profile.id);
                    }
                });
                connect(removeButton, &QPushButton::clicked,
                        this, [this, profile] {
                    m_kiwiSdrManager->removeProfile(profile.id);
                });
            }

            addSectionHeader("ADD RECEIVER");
            auto* rowFrame = new QFrame;
            rowFrame->setObjectName("kiwiAntennaRow");
            rowFrame->setStyleSheet(kKiwiRowStyle);
            auto* rowLayout = new QGridLayout(rowFrame);
            rowLayout->setContentsMargins(10, 8, 10, 8);
            rowLayout->setHorizontalSpacing(8);
            rowLayout->setVerticalSpacing(4);
            rowLayout->setColumnStretch(0, 1);
            rowLayout->setColumnStretch(1, 1);
            rowLayout->setColumnStretch(2, 1);

            addFieldLabel(rowLayout, "NAME", 0);
            addFieldLabel(rowLayout, "SERVER", 1);
            addFieldLabel(rowLayout, "PASSWORD", 2);

            auto* nameEdit = new QLineEdit;
            nameEdit->setMaxLength(16);
            nameEdit->setPlaceholderText("Custom Name");
            nameEdit->setAccessibleName("New KiwiSDR antenna name");
            nameEdit->setAccessibleDescription(
                "Required display name for the new KiwiSDR receive antenna.");
            styleKiwiEdit(nameEdit);
            rowLayout->addWidget(nameEdit, 2, 0);

            auto* endpointEdit = new QLineEdit;
            endpointEdit->setPlaceholderText("host:8073");
            endpointEdit->setAccessibleName("New KiwiSDR server");
            endpointEdit->setAccessibleDescription(
                "Hostname or hostname:port for the new KiwiSDR receive antenna.");
            styleKiwiEdit(endpointEdit);
            rowLayout->addWidget(endpointEdit, 2, 1);

            auto* passwordEdit = new QLineEdit;
            passwordEdit->setEchoMode(QLineEdit::Password);
            passwordEdit->setMaxLength(256);
            passwordEdit->setObjectName("newKiwiPassword");
            passwordEdit->setAccessibleName("New KiwiSDR password");
            passwordEdit->setAccessibleDescription(
                kiwiPasswordDescription);
            styleKiwiEdit(passwordEdit);
            rowLayout->addWidget(passwordEdit, 2, 2);

            auto* autoCheck = new QCheckBox;
            autoCheck->setText("Auto-connect");
            autoCheck->setAccessibleName("Auto connect new KiwiSDR antenna");
            AetherSDR::ThemeManager::instance().applyStyleSheet(autoCheck,
                "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }"
                + kCheckBoxIndicator);
            rowLayout->addWidget(autoCheck, 3, 0, Qt::AlignLeft);

            auto committed = std::make_shared<bool>(false);
            auto commitNewRow = [this, nameEdit, endpointEdit, passwordEdit,
                                 autoCheck, committed] {
                if (*committed) {
                    return;
                }
                const QString name = nameEdit->text().trimmed();
                const QString endpoint =
                    KiwiSdrClient::normalizeEndpoint(endpointEdit->text());
                if (name.isEmpty() || endpoint.isEmpty()) {
                    return;
                }
                *committed = true;
                const QString id = m_kiwiSdrManager->addProfile(name, endpoint);
                if (id.isEmpty()) {
                    *committed = false;
                    return;
                }
                m_kiwiSdrManager->setProfilePassword(id, passwordEdit->text());
                KiwiSdrAntennaProfile profile = m_kiwiSdrManager->profile(id);
                profile.autoConnect = autoCheck->isChecked();
                m_kiwiSdrManager->updateProfile(profile);
            };

            // Browse the public KiwiSDR directory to fill in a receiver. Only
            // API-permitting receivers are listed (web-only operators honored).
            // Picking one fills the fields; Add receiver commits the profile.
            auto* browseButton = new QPushButton("Browse public…");
            browseButton->setAccessibleName("Browse public KiwiSDR receivers");
            browseButton->setAccessibleDescription(
                "Choose from the public KiwiSDR directory; receivers whose "
                "operator disabled the external API are not shown.");
            styleKiwiButton(browseButton);
            rowLayout->addWidget(browseButton, 3, 1);
            connect(browseButton, &QPushButton::clicked, this,
                    [this, nameEdit, endpointEdit] {
                KiwiPublicReceiverPicker picker(this);
                if (picker.exec() == QDialog::Accepted
                    && !picker.selectedEndpoint().isEmpty()) {
                    endpointEdit->setText(picker.selectedEndpoint());
                    if (nameEdit->text().trimmed().isEmpty()) {
                        nameEdit->setText(picker.selectedName());
                    }
                }
            });

            auto* addButton = new QPushButton("Add receiver");
            addButton->setAccessibleName("Add KiwiSDR receiver");
            styleKiwiButton(addButton);
            rowLayout->addWidget(addButton, 3, 2);
            connect(addButton, &QPushButton::clicked, this, commitNewRow);
            kiwiRowsLayout->addWidget(rowFrame);

            connect(nameEdit, &QLineEdit::returnPressed,
                    this, commitNewRow);
            connect(endpointEdit, &QLineEdit::returnPressed,
                    this, commitNewRow);
            connect(passwordEdit, &QLineEdit::returnPressed,
                    this, commitNewRow);

            kiwiRowsLayout->addStretch(1);
        };

        connect(m_kiwiSdrManager, &KiwiSdrManager::profilesChanged,
                this, refreshKiwiNow);
        connect(m_kiwiSdrManager, &KiwiSdrManager::profileStateChanged,
                this, [refreshKiwiNow](const QString&,
                                        KiwiSdrClient::State,
                                        const QString&) {
            refreshKiwiNow();
        });
        connect(m_kiwiSdrManager, &KiwiSdrManager::profileTelemetryChanged,
                this, [kiwiTelemetryRefreshTimer](const QString&,
                                                  const KiwiSdrReceiverTelemetry&) {
            if (!kiwiTelemetryRefreshTimer->isActive()) {
                kiwiTelemetryRefreshTimer->start();
            }
        });
    }

    (*refresh)();
    if (refreshKiwi && *refreshKiwi) {
        (*refreshKiwi)();
    }
    return page;
}

// ── APD tab (External Adaptive Pre-Distortion) ──────────────────────────────
//
// Per-TX-antenna selection of the sample port the radio uses for APD
// adaptation.  INTERNAL samples inside the radio (legacy behaviour);
// RX_A/RX_B/XVTA/XVTB take a coupled feedback signal from one of the
// receive or transverter inputs — required to train APD against the
// real RF when transmitting through an external linear amplifier.
//
// Tab is added eagerly but kept hidden until the radio reports
// `apd configurable=1`.  Only the FLEX-8x00 series on SmartSDR 4.2.18+
// reports this; older firmware and 6000-series radios stay hidden.

QWidget* RadioSetupDialog::buildApdTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    // Model header — matches other tabs (e.g. Filters, TX)
    {
        auto* hdr = new QHBoxLayout;
        hdr->addStretch(1);
        auto* modelLbl = new QLabel(m_model->model());
        AetherSDR::ThemeManager::instance().applyStyleSheet(modelLbl, "QLabel { color: {{color.accent.bright}}; font-size: 20px; font-weight: bold; }");
        hdr->addWidget(modelLbl);
        vbox->addLayout(hdr);
    }

    auto& tx = m_model->transmitModel();

    // External Sampler group — 2-column grid: ANT1/XVTA on the top row,
    // ANT2/XVTB on the bottom row, then the Reset button on its own row.
    {
        auto* group = new QGroupBox("External Sampler (per TX ANT)");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(8);

        // Row, col-pair, antenna name → builds label + combo, hooks signals.
        auto buildRow = [&](int row, int colBase, const QString& ant) {
            auto* lbl = new QLabel(ant + ":");
            lbl->setStyleSheet(kLabelStyle);
            grid->addWidget(lbl, row, colBase);

            auto* combo = new QComboBox;
            const auto s = tx.apdSampler(ant);
            combo->addItems(s.available);
            combo->setCurrentText(s.selected);
            AetherSDR::applyComboStyle(combo);
            grid->addWidget(combo, row, colBase + 1);

            m_apdSamplerCombos.insert(ant, combo);

            connect(combo, &QComboBox::currentTextChanged, this,
                    [this, ant](const QString& port) {
                if (port.isEmpty()) return;
                m_model->transmitModel().setApdSamplerPort(ant, port);
            });
        };

        buildRow(0, 0, "ANT1");
        buildRow(0, 2, "XVTA");
        buildRow(1, 0, "ANT2");
        buildRow(1, 2, "XVTB");

        // Equalizer Reset button (row 2) — clears all per-antenna training.
        auto* resetLbl = new QLabel("Equalizer Reset:");
        resetLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(resetLbl, 2, 0);

        auto* resetBtn = new QPushButton("Reset");
        AetherSDR::ThemeManager::instance().applyStyleSheet(resetBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; "
            "font-weight: bold; padding: 3px 16px; }"
            "QPushButton:hover { background: {{color.background.1}}; }");
        connect(resetBtn, &QPushButton::clicked, this, [this] {
            m_model->transmitModel().resetApdEqualizer();
        });
        grid->addWidget(resetBtn, 2, 1, Qt::AlignLeft);

        vbox->addWidget(group);
    }

    // Live updates: when sampler status arrives later (e.g. ANT changes,
    // first-connection populate), refresh the relevant combo's options
    // without firing change-signals back to the radio.
    connect(&tx, &TransmitModel::apdSamplerChanged, this,
            &RadioSetupDialog::refreshApdSamplerCombo);

    vbox->addStretch(1);
    return page;
}

void RadioSetupDialog::refreshApdSamplerCombo(const QString& txAnt)
{
    auto* combo = m_apdSamplerCombos.value(txAnt);
    if (!combo) return;

    const auto s = m_model->transmitModel().apdSampler(txAnt);
    QSignalBlocker b(combo);
    combo->clear();
    combo->addItems(s.available);
    combo->setCurrentText(s.selected);
}

// ── USB Cables tab ───────────────────────────────────────────────────────────

QWidget* RadioSetupDialog::buildUsbCablesTab()
{
    auto* page = new QWidget;
    auto* hbox = new QHBoxLayout(page);
    hbox->setSpacing(6);

    auto* cableModel = &m_model->usbCableModel();

    // Style constants
    static const QString kCombo =
        "QComboBox { background: #1a2a3a; border: 1px solid #304050; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; padding: 2px 4px; }"
        "QComboBox::drop-down { border: none; }"
        "QComboBox QAbstractItemView { background: #1a2a3a; color: #c8d8e8; "
        "selection-background-color: #00b4d8; }";
    static const QString kEdit =
        "QLineEdit { background: #1a2a3a; border: 1px solid #304050; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; padding: 2px 4px; }";
    static const QString kSpin =
        "QSpinBox { background: #1a2a3a; border: 1px solid #304050; "
        "color: #c8d8e8; font-size: 11px; padding: 2px; }";
    // Token template (applied via applyStyleSheet) so the indicator is visible
    // in dark mode, matching the other checkboxes in this dialog. #c8d8e8 is
    // exactly {{color.text.primary}}, so the text colour is unchanged (#4012).
    static const QString kCheck =
        "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }"
        + kCheckBoxIndicator;

    // ── Left: cable list ────────────────────────────────────────────────
    auto* listGroup = new QGroupBox("Cables");
    listGroup->setStyleSheet(kGroupStyle);
    listGroup->setFixedWidth(180);
    auto* listLayout = new QVBoxLayout(listGroup);

    auto* cableList = new QListWidget;
    AetherSDR::ThemeManager::instance().applyStyleSheet(cableList, "QListWidget { background: {{color.background.0}}; color: {{color.text.primary}}; border: 1px solid {{color.background.1}}; "
        "font-size: 11px; }"
        "QListWidget::item { padding: 4px; }"
        "QListWidget::item:selected { background: {{color.accent}}; color: {{color.background.0}}; }");
    listLayout->addWidget(cableList);
    hbox->addWidget(listGroup);

    // ── Right: stacked property panels ──────────────────────────────────
    auto* stack = new QStackedWidget;

    // Page 0: No cable selected
    {
        auto* empty = new QWidget;
        auto* emptyLayout = new QVBoxLayout(empty);
        auto* lbl = new QLabel("No USB cables detected.\n\nPlug a USB-serial adapter\n"
                               "into the radio's rear USB port.");
        lbl->setAlignment(Qt::AlignCenter);
        lbl->setStyleSheet("QLabel { color: #606880; font-size: 12px; }");
        emptyLayout->addWidget(lbl);
        stack->addWidget(empty);  // index 0
    }

    // Helper: create source combo (shared across CAT, BCD, Bit)
    auto makeSourceCombo = []() {
        // GuardedComboBox: this combo lives in the scroll-wrapped USB Cables
        // tab and sends a destructive `source=` set on change, so a stray
        // wheel-scroll must not silently re-route a live cable's source.
        auto* combo = new GuardedComboBox;
        combo->addItems({"None", "TX Pan", "TX Slice", "Active Slice",
                         "TX Ant", "RX Ant", "Ordinal Slice"});
        combo->setStyleSheet(kCombo);
        combo->setAccessibleName("Cable source");
        combo->setAccessibleDescription(
            "Signal source routed to this cable");
        return combo;
    };
    // Map source display name → protocol value
    auto sourceToProto = [](const QString& display) -> QString {
        if (display == "TX Pan")        return "tx_pan";
        if (display == "TX Slice")      return "tx_slice";
        if (display == "Active Slice")  return "active_slice";
        if (display == "TX Ant")        return "tx_ant";
        if (display == "RX Ant")        return "rx_ant";
        if (display == "Ordinal Slice") return "ordinal_slice";
        return "None";
    };
    auto protoToSource = [](const QString& proto) -> int {
        if (proto == "tx_pan")        return 1;
        if (proto == "tx_slice")      return 2;
        if (proto == "active_slice")  return 3;
        if (proto == "tx_ant")        return 4;
        if (proto == "rx_ant")        return 5;
        if (proto == "ordinal_slice") return 6;
        return 0;  // None
    };

    struct SerialWidgets {
        QGroupBox* group{nullptr};
        QComboBox* speed{nullptr};
        QComboBox* data{nullptr};
        QComboBox* parity{nullptr};
        QComboBox* stop{nullptr};
        QComboBox* flow{nullptr};
    };

    // Cable Type combo: values match FlexLib's UsbCableType enum, listed here
    // in enum order as the single source of truth for the combo's item
    // order, the proto string sent on the wire, and the index recovered when
    // a status arrives — collapses what used to be three hand-synced lists
    // (combo item order, typeIndexToProto, protoToTypeIndex) into one.
    // "Invalid" is the unconfigured sentinel, not a menu entry. Selecting
    // BCD sends bare type=bcd; the existing bcdTypeCombo refines the specific
    // bcd/vbcd/bcd_vbcd sub-type afterward (matches FlexLib's own default-
    // then-refine behavior for this exact transition).
    struct CableTypeEntry { QString proto; QString label; };
    static const QVector<CableTypeEntry> kCableTypes = {
        {"cat",         "CAT"},
        {"bit",         "Bit"},
        {"bcd",         "BCD"},
        {"ldpa",        "LDPA"},
        {"passthrough", "Passthrough"},
    };
    // Helper: create Cable Type combo (shared across all pages). Guarded
    // against accidental wheel-scroll retyping a live cable (#570/#676-style
    // hazard — this combo's value change sends a destructive command).
    auto makeTypeCombo = []() {
        auto* combo = new GuardedComboBox;
        for (const auto& entry : kCableTypes) {
            combo->addItem(entry.label);
        }
        combo->setStyleSheet(kCombo);
        combo->setAccessibleName("Cable Type");
        combo->setAccessibleDescription(
            "Selects this cable's protocol type: CAT, Bit, BCD, LDPA, or Passthrough");
        return combo;
    };
    auto typeIndexToProto = [](int idx) -> QString {
        if (idx >= 0 && idx < kCableTypes.size()) {
            return kCableTypes[idx].proto;
        }
        return kCableTypes[0].proto;  // default: CAT
    };
    auto protoToTypeIndex = [](const QString& proto) -> int {
        // bcd/vbcd/bcd_vbcd are one family in FlexLib's UsbCableType — collapse
        // before lookup so any of the three sub-types selects the BCD entry.
        const QString family = (proto == "vbcd" || proto == "bcd_vbcd") ? "bcd" : proto;
        for (int i = 0; i < kCableTypes.size(); ++i) {
            if (kCableTypes[i].proto == family) {
                return i;
            }
        }
        return -1;  // invalid / unrecognized — leave the combo unset
    };

    // Helper: build the "Cable Settings" header group (Name/[Enabled]/Status/
    // Type) shared by all six cable pages. `includeEnabled` is false only for
    // the Unconfigured page, which has no enable state to toggle yet.
    struct CableHeaderWidgets {
        QGroupBox* group{nullptr};
        QLineEdit* nameEdit{nullptr};
        QCheckBox* enabledCheck{nullptr};  // nullptr when !includeEnabled
        QLabel*    statusLabel{nullptr};
        QComboBox* typeCombo{nullptr};
    };
    auto makeCableHeader = [makeTypeCombo](bool includeEnabled) -> CableHeaderWidgets {
        auto* group = new QGroupBox("Cable Settings");
        group->setStyleSheet(kGroupStyle);
        auto* hg = new QGridLayout(group);
        hg->setSpacing(4);

        int row = 0;
        hg->addWidget(new QLabel("Name:"), row, 0);
        auto* nameEdit = new QLineEdit;
        nameEdit->setStyleSheet(kEdit);
        nameEdit->setAccessibleName("Cable name");
        hg->addWidget(nameEdit, row, 1);
        ++row;

        QCheckBox* enabledCheck = nullptr;
        if (includeEnabled) {
            enabledCheck = new QCheckBox("Enabled");
            AetherSDR::ThemeManager::instance().applyStyleSheet(enabledCheck, kCheck);
            hg->addWidget(enabledCheck, row, 0, 1, 2);
            ++row;
        }

        auto* statusLabel = new QLabel("Unplugged");
        AetherSDR::ThemeManager::instance().applyStyleSheet(
            statusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }");
        hg->addWidget(new QLabel("Status:"), row, 0);
        hg->addWidget(statusLabel, row, 1);
        ++row;

        hg->addWidget(new QLabel("Type:"), row, 0);
        auto* typeCombo = makeTypeCombo();
        hg->addWidget(typeCombo, row, 1);

        return CableHeaderWidgets{group, nameEdit, enabledCheck, statusLabel, typeCombo};
    };

    // Tracks the serial number of a cable currently being retyped, so the
    // cableAdded handler (fired once the radio's fresh status for the new
    // type arrives — see "Wire model signals" below) knows to reselect it
    // and repopulate the detail panel. Without this, a successful retype
    // leaves the panel stuck on the empty-state page until manually
    // re-clicked, since a type change tears the old cable entry down and
    // rebuilds it fresh (UsbCableModel::applyStatus).
    //
    // Cleared two ways beyond the normal matching-cableAdded path, so a
    // rejected/never-echoed retype can't hijack a later unplug/replug's
    // selection: (1) cableRemoved for this serial defers a clear via a 0ms
    // timer — harmless for a genuine retype, since its own cableAdded fires
    // synchronously first (in the same applyStatus() call) and clears the
    // pointer before the deferred callback runs; (2) a bounded timeout armed
    // on send, in case the radio never echoes back at all.
    auto pendingTypeChangeSn = std::make_shared<QString>();
    // Generation counter so the bounded timeout can't clear a *newer* pending.
    // Each send bumps the generation; the timer captures its own generation and
    // only clears if it's still current. Without this, retyping the same serial
    // twice within 5s would let the first timer clear the second retype's
    // pending (QTimer::singleShot is fire-and-forget and can't be cancelled),
    // reintroducing the stuck-panel bug on exactly the lossy radios the timeout
    // exists to protect.
    auto pendingTypeChangeGen = std::make_shared<quint64>(0);
    auto sendCableType = [cableModel, cableList, pendingTypeChangeSn,
                          pendingTypeChangeGen](const QString& proto) {
        auto* item = cableList->currentItem();
        if (!item) {
            return;
        }
        const QString sn = item->data(Qt::UserRole).toString();
        *pendingTypeChangeSn = sn;
        const quint64 gen = ++(*pendingTypeChangeGen);
        cableModel->sendSet(sn, "type", proto);
        QTimer::singleShot(5000, [pendingTypeChangeSn, pendingTypeChangeGen, gen]() {
            if (*pendingTypeChangeGen == gen) {
                pendingTypeChangeSn->clear();
            }
        });
    };
    // Wires a Cable Type combo's index changes to sendCableType — collapses
    // what used to be six copy-pasted connect() blocks into one call site.
    auto wireTypeCombo = [this, sendCableType, typeIndexToProto](QComboBox* combo) {
        connect(combo, &QComboBox::currentIndexChanged, this,
                [sendCableType, typeIndexToProto](int idx) {
            if (idx >= 0) {
                sendCableType(typeIndexToProto(idx));
            }
        });
    };

    // Helper: serial parameter group (shared by CAT and Passthrough)
    auto makeSerialGroup = [](const QString& title) -> SerialWidgets {
        auto* group = new QGroupBox(title);
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(4);

        auto* speedCombo = new QComboBox;
        for (int s : {300,600,1200,2400,4800,9600,14400,19200,38400,57600,115200,230400,460800,921600})
            speedCombo->addItem(QString::number(s));
        speedCombo->setCurrentText("9600");
        speedCombo->setStyleSheet(kCombo);
        grid->addWidget(new QLabel("Speed:"), 0, 0);
        grid->addWidget(speedCombo, 0, 1);

        auto* dataCombo = new QComboBox;
        dataCombo->addItems({"7", "8"});
        dataCombo->setCurrentText("8");
        dataCombo->setStyleSheet(kCombo);
        grid->addWidget(new QLabel("Data Bits:"), 1, 0);
        grid->addWidget(dataCombo, 1, 1);

        auto* parityCombo = new QComboBox;
        parityCombo->addItems({"none", "odd", "even", "mark", "space"});
        parityCombo->setStyleSheet(kCombo);
        grid->addWidget(new QLabel("Parity:"), 2, 0);
        grid->addWidget(parityCombo, 2, 1);

        auto* stopCombo = new QComboBox;
        stopCombo->addItems({"1", "2"});
        stopCombo->setStyleSheet(kCombo);
        grid->addWidget(new QLabel("Stop Bits:"), 3, 0);
        grid->addWidget(stopCombo, 3, 1);

        auto* flowCombo = new QComboBox;
        flowCombo->addItems({"none", "rts_cts", "dtr_dsr", "xon_xoff"});
        flowCombo->setStyleSheet(kCombo);
        grid->addWidget(new QLabel("Flow:"), 4, 0);
        grid->addWidget(flowCombo, 4, 1);

        return SerialWidgets{group, speedCombo, dataCombo, parityCombo, stopCombo, flowCombo};
    };

    // Page 1: CAT cable
    QWidget* catPage;
    QLineEdit* catNameEdit;
    QCheckBox* catEnabledCheck;
    QLabel*    catStatusLabel;
    QComboBox* catSourceCombo;
    QCheckBox* catAutoReportCheck;
    SerialWidgets catSerialWidgets;
    QComboBox* catTypeCombo;
    {
        catPage = new QWidget;
        auto* vbox = new QVBoxLayout(catPage);
        vbox->setSpacing(6);

        // Common header
        auto catHeader = makeCableHeader(/*includeEnabled=*/true);
        catNameEdit = catHeader.nameEdit;
        catEnabledCheck = catHeader.enabledCheck;
        catStatusLabel = catHeader.statusLabel;
        catTypeCombo = catHeader.typeCombo;
        vbox->addWidget(catHeader.group);

        // Serial params
        catSerialWidgets = makeSerialGroup("Serial Parameters");
        vbox->addWidget(catSerialWidgets.group);

        // CAT source
        auto* srcGroup = new QGroupBox("CAT Source");
        srcGroup->setStyleSheet(kGroupStyle);
        auto* sg = new QGridLayout(srcGroup);
        sg->setSpacing(4);
        sg->addWidget(new QLabel("Source:"), 0, 0);
        catSourceCombo = makeSourceCombo();
        sg->addWidget(catSourceCombo, 0, 1);
        catAutoReportCheck = new QCheckBox("Auto Report");
        AetherSDR::ThemeManager::instance().applyStyleSheet(catAutoReportCheck, kCheck);
        sg->addWidget(catAutoReportCheck, 1, 0, 1, 2);
        vbox->addWidget(srcGroup);

        vbox->addStretch();
        stack->addWidget(catPage);  // index 1
    }

    // Page 2: BCD cable
    QWidget* bcdPage;
    QLineEdit* bcdNameEdit;
    QCheckBox* bcdEnabledCheck;
    QLabel*    bcdStatusLabel;
    QComboBox* bcdSourceCombo;
    QComboBox* bcdTypeCombo;
    QComboBox* bcdPolarityCombo;
    QComboBox* bcdCableTypeCombo;
    {
        bcdPage = new QWidget;
        auto* vbox = new QVBoxLayout(bcdPage);
        vbox->setSpacing(6);

        auto bcdHeader = makeCableHeader(/*includeEnabled=*/true);
        bcdNameEdit = bcdHeader.nameEdit;
        bcdEnabledCheck = bcdHeader.enabledCheck;
        bcdStatusLabel = bcdHeader.statusLabel;
        bcdCableTypeCombo = bcdHeader.typeCombo;
        vbox->addWidget(bcdHeader.group);

        auto* bcdGroup = new QGroupBox("BCD Settings");
        bcdGroup->setStyleSheet(kGroupStyle);
        auto* bg = new QGridLayout(bcdGroup);
        bg->setSpacing(4);
        bg->addWidget(new QLabel("BCD Type:"), 0, 0);
        // GuardedComboBox: scroll-wrapped tab + destructive `type=` on change.
        // NB this sub-type combo sends `type=` via sendBcdProp, which does NOT
        // arm pendingTypeChangeSn. That is correct only because
        // UsbCableModel::normalizeTypeFamily() collapses bcd/vbcd/bcd_vbcd to
        // one family, so a sub-type change updates in place (cableChanged) and
        // never triggers the remove+recreate path that would need a reselect.
        // If that invariant ever changes, route this through sendCableType.
        bcdTypeCombo = new GuardedComboBox;
        bcdTypeCombo->addItems({"HF (bcd)", "VHF (vbcd)", "HF+VHF (bcd_vbcd)"});
        bcdTypeCombo->setStyleSheet(kCombo);
        bcdTypeCombo->setAccessibleName("BCD sub-type");
        bg->addWidget(bcdTypeCombo, 0, 1);
        bg->addWidget(new QLabel("Polarity:"), 1, 0);
        bcdPolarityCombo = new GuardedComboBox;  // destructive polarity= on change
        bcdPolarityCombo->addItems({"Active High", "Active Low"});
        bcdPolarityCombo->setStyleSheet(kCombo);
        bcdPolarityCombo->setAccessibleName("BCD polarity");
        bg->addWidget(bcdPolarityCombo, 1, 1);
        bg->addWidget(new QLabel("Source:"), 2, 0);
        bcdSourceCombo = makeSourceCombo();
        bg->addWidget(bcdSourceCombo, 2, 1);
        vbox->addWidget(bcdGroup);

        vbox->addStretch();
        stack->addWidget(bcdPage);  // index 2
    }

    // Page 3: Bit cable
    // Master-detail sub-view (Bit 0-7 list + one detail form), mirroring the
    // outer cable-list/stack pattern above — a flat 8-row grid can't fit
    // PTT-dependent/PTT-delay/TX-delay/freq-range/antenna-slice fields
    // without becoming unreadably cramped at this panel's width (#3607).
    QWidget* bitPage;
    QLineEdit* bitNameEdit;
    QCheckBox* bitEnabledCheck;
    QLabel*    bitStatusLabel;
    QListWidget* bitIndexList;
    struct BitWidgets {
        QCheckBox* enabled{nullptr};
        QComboBox* source{nullptr};
        QComboBox* sourceDetail{nullptr};
        QLabel*    sourceDetailLabel{nullptr};
        QComboBox* output{nullptr};
        QLineEdit* band{nullptr};
        QLabel*    bandLabel{nullptr};
        QLineEdit* lowFreq{nullptr};
        QLabel*    lowFreqLabel{nullptr};
        QLineEdit* highFreq{nullptr};
        QLabel*    highFreqLabel{nullptr};
        QComboBox* polarity{nullptr};
        QCheckBox* pttDependent{nullptr};
        QSpinBox*  pttDelay{nullptr};
        QSpinBox*  txDelay{nullptr};
    };
    BitWidgets bitWidgets;
    auto currentBit = std::make_shared<int>(0);
    QComboBox* bitTypeCombo;
    {
        bitPage = new QWidget;
        auto* vbox = new QVBoxLayout(bitPage);
        vbox->setSpacing(6);

        auto bitHeader = makeCableHeader(/*includeEnabled=*/true);
        bitNameEdit = bitHeader.nameEdit;
        bitEnabledCheck = bitHeader.enabledCheck;
        bitStatusLabel = bitHeader.statusLabel;
        bitTypeCombo = bitHeader.typeCombo;
        vbox->addWidget(bitHeader.group);

        auto* bitSplit = new QHBoxLayout;

        auto* bitListGroup = new QGroupBox("Bits");
        bitListGroup->setStyleSheet(kGroupStyle);
        bitListGroup->setFixedWidth(90);
        auto* bitListVbox = new QVBoxLayout(bitListGroup);
        bitIndexList = new QListWidget;
        AetherSDR::ThemeManager::instance().applyStyleSheet(bitIndexList,
            "QListWidget { background: {{color.background.0}}; color: {{color.text.primary}}; border: 1px solid {{color.background.1}}; "
            "font-size: 11px; }"
            "QListWidget::item { padding: 4px; }"
            "QListWidget::item:selected { background: {{color.accent}}; color: {{color.background.0}}; }");
        for (int b = 0; b < 8; ++b)
            bitIndexList->addItem(QString("Bit %1").arg(b));
        bitListVbox->addWidget(bitIndexList);
        bitSplit->addWidget(bitListGroup);

        auto* bitDetailGroup = new QGroupBox("Bit Settings");
        bitDetailGroup->setStyleSheet(kGroupStyle);
        auto* bd = new QGridLayout(bitDetailGroup);
        bd->setSpacing(6);

        int row = 0;
        auto addRow = [&](const QString& label, QWidget* w) -> QLabel* {
            auto* lbl = new QLabel(label);
            bd->addWidget(lbl, row, 0);
            bd->addWidget(w, row, 1);
            ++row;
            return lbl;
        };

        bitWidgets.enabled = new QCheckBox;
        AetherSDR::ThemeManager::instance().applyStyleSheet(bitWidgets.enabled, kCheck);
        addRow("Enabled:", bitWidgets.enabled);

        bitWidgets.source = makeSourceCombo();
        addRow("Source:", bitWidgets.source);

        bitWidgets.sourceDetail = new QComboBox;
        bitWidgets.sourceDetail->setEditable(true);
        bitWidgets.sourceDetail->setStyleSheet(kCombo);
        bitWidgets.sourceDetailLabel = addRow("Antenna/Slice:", bitWidgets.sourceDetail);

        bitWidgets.output = new QComboBox;
        bitWidgets.output->addItems({"band", "freq_range"});
        bitWidgets.output->setStyleSheet(kCombo);
        addRow("Output:", bitWidgets.output);

        bitWidgets.band = new QLineEdit;
        bitWidgets.band->setPlaceholderText("e.g. 20");
        bitWidgets.band->setStyleSheet(kEdit);
        bitWidgets.bandLabel = addRow("Band:", bitWidgets.band);

        bitWidgets.lowFreq = new QLineEdit;
        bitWidgets.lowFreq->setPlaceholderText("e.g. 0.100");
        bitWidgets.lowFreq->setStyleSheet(kEdit);
        bitWidgets.lowFreqLabel = addRow("Low Freq (MHz):", bitWidgets.lowFreq);

        bitWidgets.highFreq = new QLineEdit;
        bitWidgets.highFreq->setPlaceholderText("e.g. 54.000");
        bitWidgets.highFreq->setStyleSheet(kEdit);
        bitWidgets.highFreqLabel = addRow("High Freq (MHz):", bitWidgets.highFreq);

        bitWidgets.polarity = new QComboBox;
        bitWidgets.polarity->addItems({"High", "Low"});
        bitWidgets.polarity->setStyleSheet(kCombo);
        addRow("Polarity:", bitWidgets.polarity);

        bitWidgets.pttDependent = new QCheckBox;
        AetherSDR::ThemeManager::instance().applyStyleSheet(bitWidgets.pttDependent, kCheck);
        addRow("PTT Dependent:", bitWidgets.pttDependent);

        bitWidgets.pttDelay = new QSpinBox;
        bitWidgets.pttDelay->setRange(0, 10000);
        bitWidgets.pttDelay->setSingleStep(5);
        bitWidgets.pttDelay->setSuffix(" ms");
        bitWidgets.pttDelay->setStyleSheet(kSpin);
        addRow("PTT Delay:", bitWidgets.pttDelay);

        bitWidgets.txDelay = new QSpinBox;
        bitWidgets.txDelay->setRange(0, 10000);
        bitWidgets.txDelay->setSingleStep(5);
        bitWidgets.txDelay->setSuffix(" ms");
        bitWidgets.txDelay->setStyleSheet(kSpin);
        addRow("TX Delay:", bitWidgets.txDelay);

        bd->setRowStretch(row, 1);
        bitSplit->addWidget(bitDetailGroup, 1);

        vbox->addLayout(bitSplit);
        vbox->addStretch();
        stack->addWidget(bitPage);  // index 3
    }

    // Bit detail refresh helpers — output mode (band vs freq-range) and
    // source (antenna/slice sub-selector) each drive which sibling fields
    // are visible; kept as named lambdas so both the interactive wiring
    // below and the model-driven repopulation in showCableProps can call
    // the exact same logic.
    auto refreshBitOutputVisibility = [bitWidgets]() {
        const bool isFreqRange = (bitWidgets.output->currentText() == "freq_range");
        bitWidgets.band->setVisible(!isFreqRange);
        bitWidgets.bandLabel->setVisible(!isFreqRange);
        bitWidgets.lowFreq->setVisible(isFreqRange);
        bitWidgets.lowFreqLabel->setVisible(isFreqRange);
        bitWidgets.highFreq->setVisible(isFreqRange);
        bitWidgets.highFreqLabel->setVisible(isFreqRange);
    };
    auto refreshBitSourceDetail = [bitWidgets, sourceToProto, this]() {
        const QString proto = sourceToProto(bitWidgets.source->currentText());
        const bool needsDetail = (proto == "tx_ant" || proto == "rx_ant" || proto == "ordinal_slice");
        bitWidgets.sourceDetail->setVisible(needsDetail);
        bitWidgets.sourceDetailLabel->setVisible(needsDetail);
        if (!needsDetail) return;
        QSignalBlocker blocker(bitWidgets.sourceDetail);
        bitWidgets.sourceDetail->clear();
        if (proto == "ordinal_slice") {
            for (int i = 0; i < m_model->maxSlices(); ++i)
                bitWidgets.sourceDetail->addItem(QString::number(i));
        } else {
            bitWidgets.sourceDetail->addItems(m_model->knownAntennaTokens());
        }
    };
    refreshBitOutputVisibility();
    refreshBitSourceDetail();

    auto refreshBitDetail = [=]() {
        auto* item = cableList->currentItem();
        if (!item) return;
        const QString sn = item->data(Qt::UserRole).toString();
        if (!cableModel->cables().contains(sn)) return;
        const auto& cable = cableModel->cables()[sn];
        if (cable.type != "bit") return;
        const int b = qBound(0, *currentBit, 7);
        const auto& bit = cable.bits[b];

        QSignalBlocker blk1(bitWidgets.enabled), blk2(bitWidgets.source),
                       blk3(bitWidgets.sourceDetail), blk4(bitWidgets.output),
                       blk5(bitWidgets.band), blk6(bitWidgets.lowFreq),
                       blk7(bitWidgets.highFreq), blk8(bitWidgets.polarity),
                       blk9(bitWidgets.pttDependent), blk10(bitWidgets.pttDelay),
                       blk11(bitWidgets.txDelay);

        bitWidgets.enabled->setChecked(bit.enabled);
        bitWidgets.source->setCurrentIndex(protoToSource(bit.source));
        bitWidgets.output->setCurrentText(bit.output.isEmpty() ? "band" : bit.output);
        bitWidgets.band->setText(bit.band);
        bitWidgets.lowFreq->setText(QString::number(bit.lowFreqMhz, 'f', 3));
        bitWidgets.highFreq->setText(QString::number(bit.highFreqMhz, 'f', 3));
        bitWidgets.polarity->setCurrentIndex(bit.activeHigh ? 0 : 1);
        bitWidgets.pttDependent->setChecked(bit.pttDependent);
        bitWidgets.pttDelay->setValue(bit.pttDelayMs);
        bitWidgets.txDelay->setValue(bit.txDelayMs);

        refreshBitOutputVisibility();
        refreshBitSourceDetail();
        QString detailValue;
        if (bit.source == "tx_ant")           detailValue = bit.sourceTxAnt;
        else if (bit.source == "rx_ant")      detailValue = bit.sourceRxAnt;
        else if (bit.source == "ordinal_slice") detailValue = bit.sourceSlice;
        bitWidgets.sourceDetail->setCurrentText(detailValue);
    };

    // Page 4: Passthrough cable
    QWidget* ptPage;
    QLineEdit* ptNameEdit;
    QCheckBox* ptEnabledCheck;
    QLabel*    ptStatusLabel;
    SerialWidgets ptSerialWidgets;
    QComboBox* ptTypeCombo;
    {
        ptPage = new QWidget;
        auto* vbox = new QVBoxLayout(ptPage);
        vbox->setSpacing(6);

        auto ptHeader = makeCableHeader(/*includeEnabled=*/true);
        ptNameEdit = ptHeader.nameEdit;
        ptEnabledCheck = ptHeader.enabledCheck;
        ptStatusLabel = ptHeader.statusLabel;
        ptTypeCombo = ptHeader.typeCombo;
        vbox->addWidget(ptHeader.group);

        ptSerialWidgets = makeSerialGroup("Serial Parameters");
        vbox->addWidget(ptSerialWidgets.group);

        vbox->addStretch();
        stack->addWidget(ptPage);  // index 4
    }

    // Page 5: LDPA cable
    QWidget* ldpaPage;
    QLineEdit* ldpaNameEdit;
    QCheckBox* ldpaEnabledCheck;
    QLabel*    ldpaStatusLabel;
    QComboBox* ldpaTypeCombo;
    QComboBox* ldpaBandCombo;
    QCheckBox* ldpaPreampCheck;
    QComboBox* ldpaSourceCombo;
    {
        ldpaPage = new QWidget;
        auto* vbox = new QVBoxLayout(ldpaPage);
        vbox->setSpacing(6);

        auto ldpaHeader = makeCableHeader(/*includeEnabled=*/true);
        ldpaNameEdit = ldpaHeader.nameEdit;
        ldpaEnabledCheck = ldpaHeader.enabledCheck;
        ldpaStatusLabel = ldpaHeader.statusLabel;
        ldpaTypeCombo = ldpaHeader.typeCombo;
        vbox->addWidget(ldpaHeader.group);

        auto* ldpaGroup = new QGroupBox("LDPA Settings");
        ldpaGroup->setStyleSheet(kGroupStyle);
        auto* lg = new QGridLayout(ldpaGroup);
        lg->setSpacing(4);
        lg->addWidget(new QLabel("Band:"), 0, 0);
        // GuardedComboBox: scroll-wrapped tab + destructive `band=` on change.
        ldpaBandCombo = new GuardedComboBox;
        ldpaBandCombo->addItems({"2m", "4m"});
        ldpaBandCombo->setStyleSheet(kCombo);
        ldpaBandCombo->setAccessibleName("LDPA band");
        ldpaBandCombo->setAccessibleDescription("LDPA amplifier band (2m or 4m)");
        lg->addWidget(ldpaBandCombo, 0, 1);
        ldpaPreampCheck = new QCheckBox("Preamp");
        AetherSDR::ThemeManager::instance().applyStyleSheet(ldpaPreampCheck, kCheck);
        lg->addWidget(ldpaPreampCheck, 1, 0, 1, 2);
        lg->addWidget(new QLabel("Source:"), 2, 0);
        ldpaSourceCombo = makeSourceCombo();
        lg->addWidget(ldpaSourceCombo, 2, 1);
        vbox->addWidget(ldpaGroup);

        vbox->addStretch();
        stack->addWidget(ldpaPage);  // index 5
    }

    // Page 6: Unconfigured cable (type is "invalid" or otherwise unrecognized —
    // a real, present cable that hasn't been assigned a type yet, distinct
    // from "nothing plugged in" at index 0)
    QWidget* unconfiguredPage;
    QLineEdit* unconfiguredNameEdit;
    QLabel*    unconfiguredStatusLabel;
    QComboBox* unconfiguredTypeCombo;
    QPushButton* unconfiguredRemoveBtn;
    {
        unconfiguredPage = new QWidget;
        auto* vbox = new QVBoxLayout(unconfiguredPage);
        vbox->setSpacing(6);

        auto unconfiguredHeader = makeCableHeader(/*includeEnabled=*/false);
        unconfiguredNameEdit = unconfiguredHeader.nameEdit;
        unconfiguredStatusLabel = unconfiguredHeader.statusLabel;
        unconfiguredTypeCombo = unconfiguredHeader.typeCombo;
        unconfiguredTypeCombo->setCurrentIndex(-1);  // force a real choice, no default
        vbox->addWidget(unconfiguredHeader.group);

        auto* note = new QLabel("Select a cable type to configure this device.");
        note->setStyleSheet("QLabel { color: #606880; font-size: 12px; }");
        note->setWordWrap(true);
        vbox->addWidget(note);

        unconfiguredRemoveBtn = new QPushButton("Remove This Cable");
        unconfiguredRemoveBtn->setAutoDefault(false);
        vbox->addWidget(unconfiguredRemoveBtn);

        vbox->addStretch();
        stack->addWidget(unconfiguredPage);  // index 6
    }

    hbox->addWidget(stack, 1);

    // ── Populate cable list from model ──────────────────────────────────
    auto refreshList = [cableList, cableModel]() {
        QString prevSn;
        if (cableList->currentItem())
            prevSn = cableList->currentItem()->data(Qt::UserRole).toString();
        cableList->clear();
        for (auto it = cableModel->cables().begin(); it != cableModel->cables().end(); ++it) {
            const auto& cable = it.value();
            QString label = cable.name.isEmpty() ? cable.serialNumber : cable.name;
            label += QString(" [%1]").arg(cable.type.toUpper());
            if (!cable.present)
                label += " (unplugged)";
            auto* item = new QListWidgetItem(label);
            item->setData(Qt::UserRole, cable.serialNumber);
            if (cable.enabled && cable.present)
                item->setForeground(QColor("#30d050"));
            else if (cable.enabled)
                item->setForeground(QColor("#d0d030"));
            else
                item->setForeground(QColor("#808080"));
            cableList->addItem(item);
            if (cable.serialNumber == prevSn)
                cableList->setCurrentItem(item);
        }
    };

    // ── Select cable → show properties ──────────────────────────────────
    auto showCableProps = [=](const QString& sn) {
        if (sn.isEmpty() || !cableModel->cables().contains(sn)) {
            stack->setCurrentIndex(0);
            return;
        }
        const auto& cable = cableModel->cables()[sn];
        const QString& t = cable.type;

        if (t == "cat") {
            stack->setCurrentIndex(1);
            QSignalBlocker b1(catNameEdit), b2(catEnabledCheck), b3(catSourceCombo),
                           b4(catAutoReportCheck), b5(catTypeCombo);
            QSignalBlocker b6(catSerialWidgets.speed), b7(catSerialWidgets.data),
                           b8(catSerialWidgets.parity), b9(catSerialWidgets.stop),
                           b10(catSerialWidgets.flow);
            catNameEdit->setText(cable.name);
            catEnabledCheck->setChecked(cable.enabled);
            catStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged");
            catStatusLabel->setStyleSheet(cable.present
                ? "QLabel { color: #30d050; font-size: 11px; }"
                : "QLabel { color: #808080; font-size: 11px; }");
            catSourceCombo->setCurrentIndex(protoToSource(cable.source));
            catAutoReportCheck->setChecked(cable.autoReport);
            catTypeCombo->setCurrentIndex(protoToTypeIndex(t));

            catSerialWidgets.speed->setCurrentText(QString::number(cable.speed));
            catSerialWidgets.data->setCurrentText(QString::number(cable.dataBits));
            catSerialWidgets.parity->setCurrentText(cable.parity);
            catSerialWidgets.stop->setCurrentText(QString::number(cable.stopBits));
            catSerialWidgets.flow->setCurrentText(cable.flowControl);
        } else if (t == "bcd" || t == "vbcd" || t == "bcd_vbcd") {
            stack->setCurrentIndex(2);
            QSignalBlocker b1(bcdNameEdit), b2(bcdEnabledCheck), b3(bcdSourceCombo),
                           b4(bcdTypeCombo), b5(bcdPolarityCombo), b6(bcdCableTypeCombo);
            bcdNameEdit->setText(cable.name);
            bcdEnabledCheck->setChecked(cable.enabled);
            bcdStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged");
            bcdStatusLabel->setStyleSheet(cable.present
                ? "QLabel { color: #30d050; font-size: 11px; }"
                : "QLabel { color: #808080; font-size: 11px; }");
            bcdSourceCombo->setCurrentIndex(protoToSource(cable.source));
            if (t == "vbcd") bcdTypeCombo->setCurrentIndex(1);
            else if (t == "bcd_vbcd") bcdTypeCombo->setCurrentIndex(2);
            else bcdTypeCombo->setCurrentIndex(0);
            bcdPolarityCombo->setCurrentIndex(cable.activeHigh ? 0 : 1);
            bcdCableTypeCombo->setCurrentIndex(protoToTypeIndex(t));
        } else if (t == "bit") {
            stack->setCurrentIndex(3);
            QSignalBlocker b1(bitNameEdit), b2(bitEnabledCheck), b3(bitTypeCombo);
            bitNameEdit->setText(cable.name);
            bitEnabledCheck->setChecked(cable.enabled);
            bitStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged");
            bitStatusLabel->setStyleSheet(cable.present
                ? "QLabel { color: #30d050; font-size: 11px; }"
                : "QLabel { color: #808080; font-size: 11px; }");
            bitTypeCombo->setCurrentIndex(protoToTypeIndex(t));
            if (bitIndexList->currentRow() < 0) {
                QSignalBlocker blk(bitIndexList);
                bitIndexList->setCurrentRow(0);
            }
            refreshBitDetail();
        } else if (t == "passthrough") {
            stack->setCurrentIndex(4);
            QSignalBlocker b1(ptNameEdit), b2(ptEnabledCheck), b3(ptTypeCombo);
            QSignalBlocker b4(ptSerialWidgets.speed), b5(ptSerialWidgets.data),
                           b6(ptSerialWidgets.parity), b7(ptSerialWidgets.stop),
                           b8(ptSerialWidgets.flow);
            ptNameEdit->setText(cable.name);
            ptEnabledCheck->setChecked(cable.enabled);
            ptStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged");
            ptStatusLabel->setStyleSheet(cable.present
                ? "QLabel { color: #30d050; font-size: 11px; }"
                : "QLabel { color: #808080; font-size: 11px; }");
            ptTypeCombo->setCurrentIndex(protoToTypeIndex(t));

            ptSerialWidgets.speed->setCurrentText(QString::number(cable.speed));
            ptSerialWidgets.data->setCurrentText(QString::number(cable.dataBits));
            ptSerialWidgets.parity->setCurrentText(cable.parity);
            ptSerialWidgets.stop->setCurrentText(QString::number(cable.stopBits));
            ptSerialWidgets.flow->setCurrentText(cable.flowControl);
        } else if (t == "ldpa") {
            stack->setCurrentIndex(5);
            QSignalBlocker b1(ldpaNameEdit), b2(ldpaEnabledCheck), b3(ldpaTypeCombo),
                           b4(ldpaBandCombo), b5(ldpaPreampCheck), b6(ldpaSourceCombo);
            ldpaNameEdit->setText(cable.name);
            ldpaEnabledCheck->setChecked(cable.enabled);
            ldpaStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged");
            ldpaStatusLabel->setStyleSheet(cable.present
                ? "QLabel { color: #30d050; font-size: 11px; }"
                : "QLabel { color: #808080; font-size: 11px; }");
            ldpaTypeCombo->setCurrentIndex(protoToTypeIndex(t));
            ldpaBandCombo->setCurrentIndex(cable.ldpaBand == "4" ? 1 : 0);
            ldpaPreampCheck->setChecked(cable.preamp);
            ldpaSourceCombo->setCurrentIndex(protoToSource(cable.source));
        } else {
            // "invalid" or any other unrecognized type: a real, present cable
            // that hasn't been assigned a type yet — distinct from "nothing
            // plugged in" (index 0), which is handled by the isEmpty()/
            // not-found guard above and by the cableRemoved handler.
            stack->setCurrentIndex(6);
            QSignalBlocker b1(unconfiguredNameEdit), b2(unconfiguredTypeCombo);
            unconfiguredNameEdit->setText(cable.name);
            unconfiguredStatusLabel->setText(cable.present ? "Plugged In" : "Unplugged");
            unconfiguredStatusLabel->setStyleSheet(cable.present
                ? "QLabel { color: #30d050; font-size: 11px; }"
                : "QLabel { color: #808080; font-size: 11px; }");
            unconfiguredTypeCombo->setCurrentIndex(-1);
        }
    };

    connect(cableList, &QListWidget::currentItemChanged, this,
            [showCableProps](QListWidgetItem* current, QListWidgetItem*) {
        if (current)
            showCableProps(current->data(Qt::UserRole).toString());
    });

    // ── Wire model signals ──────────────────────────────────────────────
    connect(cableModel, &UsbCableModel::cableAdded, this,
            [refreshList, cableList, showCableProps, pendingTypeChangeSn](const QString& sn) {
        refreshList();
        // A retype tears the old cable entry down and rebuilds it fresh
        // (UsbCableModel::applyStatus), which drops the list selection along
        // the way (see the cableRemoved handler below). Reselect and
        // repopulate the detail panel so a successful retype doesn't leave
        // it stuck on the empty-state page until manually re-clicked.
        if (*pendingTypeChangeSn == sn) {
            pendingTypeChangeSn->clear();
            for (int i = 0; i < cableList->count(); ++i) {
                if (cableList->item(i)->data(Qt::UserRole).toString() == sn) {
                    cableList->setCurrentItem(cableList->item(i));
                    break;
                }
            }
            showCableProps(sn);
        }
    });
    connect(cableModel, &UsbCableModel::cableRemoved, this,
            [refreshList, stack, pendingTypeChangeSn](const QString& sn) {
        refreshList();
        stack->setCurrentIndex(0);
        // Defer the clear to the next event-loop tick: a genuine retype's own
        // cableAdded for this serial fires synchronously right after this
        // handler returns (same applyStatus() call) and clears the pointer
        // itself first, so this is a no-op for that case. It only matters for
        // a standalone removal unrelated to any pending retype, preventing a
        // later unplug/replug's cableAdded from being mistaken for a retype
        // completion.
        if (*pendingTypeChangeSn == sn) {
            QTimer::singleShot(0, [pendingTypeChangeSn, sn]() {
                if (*pendingTypeChangeSn == sn) {
                    pendingTypeChangeSn->clear();
                }
            });
        }
    });
    connect(cableModel, &UsbCableModel::cableChanged, this,
            [refreshList, cableList, showCableProps](const QString& sn) {
        refreshList();
        if (cableList->currentItem() &&
            cableList->currentItem()->data(Qt::UserRole).toString() == sn)
            showCableProps(sn);
    });

    // ── Wire property edits → commands ──────────────────────────────────
    // CAT
    auto sendCatProp = [cableModel, cableList](const QString& key, const QString& val) {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendSet(item->data(Qt::UserRole).toString(), key, val);
    };
    connect(catNameEdit, &QLineEdit::editingFinished, this, [catNameEdit, sendCatProp]() {
        sendCatProp("name", QString(catNameEdit->text()).replace(' ', QChar(0x7F)));
    });
    connect(catEnabledCheck, &QCheckBox::toggled, this, [sendCatProp](bool on) {
        sendCatProp("enable", on ? "1" : "0");
    });
    connect(catSourceCombo, &QComboBox::currentTextChanged, this,
            [sendCatProp, sourceToProto](const QString& text) {
        sendCatProp("source", sourceToProto(text));
    });
    connect(catAutoReportCheck, &QCheckBox::toggled, this, [sendCatProp](bool on) {
        sendCatProp("auto_report", on ? "1" : "0");
    });
    wireTypeCombo(catTypeCombo);

    connect(catSerialWidgets.speed, &QComboBox::currentTextChanged, this, [sendCatProp](const QString& val) { sendCatProp("speed", val); });
    connect(catSerialWidgets.data, &QComboBox::currentTextChanged, this, [sendCatProp](const QString& val) { sendCatProp("data_bits", val); });
    connect(catSerialWidgets.parity, &QComboBox::currentTextChanged, this, [sendCatProp](const QString& val) { sendCatProp("parity", val); });
    connect(catSerialWidgets.stop, &QComboBox::currentTextChanged, this, [sendCatProp](const QString& val) { sendCatProp("stop_bits", val); });
    connect(catSerialWidgets.flow, &QComboBox::currentTextChanged, this, [sendCatProp](const QString& val) { sendCatProp("flow_control", val); });

    // BCD
    auto sendBcdProp = [cableModel, cableList](const QString& key, const QString& val) {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendSet(item->data(Qt::UserRole).toString(), key, val);
    };
    connect(bcdNameEdit, &QLineEdit::editingFinished, this, [bcdNameEdit, sendBcdProp]() {
        sendBcdProp("name", QString(bcdNameEdit->text()).replace(' ', QChar(0x7F)));
    });
    connect(bcdEnabledCheck, &QCheckBox::toggled, this, [sendBcdProp](bool on) {
        sendBcdProp("enable", on ? "1" : "0");
    });
    connect(bcdTypeCombo, &QComboBox::currentIndexChanged, this,
            [sendBcdProp](int idx) {
        static const char* types[] = {"bcd", "vbcd", "bcd_vbcd"};
        if (idx >= 0 && idx < 3) sendBcdProp("type", types[idx]);
    });
    connect(bcdPolarityCombo, &QComboBox::currentIndexChanged, this,
            [sendBcdProp](int idx) {
        sendBcdProp("polarity", idx == 0 ? "active_high" : "active_low");
    });
    connect(bcdSourceCombo, &QComboBox::currentTextChanged, this,
            [sendBcdProp, sourceToProto](const QString& text) {
        sendBcdProp("source", sourceToProto(text));
    });
    wireTypeCombo(bcdCableTypeCombo);

    // Bit cable header (whole-cable name/enable, cableModel->sendSet)
    auto sendBitProp = [cableModel, cableList](const QString& key, const QString& val) {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendSet(item->data(Qt::UserRole).toString(), key, val);
    };
    connect(bitNameEdit, &QLineEdit::editingFinished, this, [bitNameEdit, sendBitProp]() {
        sendBitProp("name", QString(bitNameEdit->text()).replace(' ', QChar(0x7F)));
    });
    connect(bitEnabledCheck, &QCheckBox::toggled, this, [sendBitProp](bool on) {
        sendBitProp("enable", on ? "1" : "0");
    });
    wireTypeCombo(bitTypeCombo);

    // Bit detail fields (per-bit, cableModel->sendSetBit against *currentBit)
    auto sendBitFieldProp = [cableModel, cableList, currentBit](const QString& key, const QString& val) {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendSetBit(item->data(Qt::UserRole).toString(), *currentBit, key, val);
    };
    connect(bitWidgets.enabled, &QCheckBox::toggled, this, [sendBitFieldProp](bool on) {
        sendBitFieldProp("enable", on ? "1" : "0");
    });
    connect(bitWidgets.source, &QComboBox::currentTextChanged, this,
            [sendBitFieldProp, sourceToProto, refreshBitSourceDetail](const QString& text) {
        sendBitFieldProp("source", sourceToProto(text));
        refreshBitSourceDetail();
    });
    connect(bitWidgets.sourceDetail, &QComboBox::currentTextChanged, this,
            [sendBitFieldProp, sourceToProto, bitWidgets](const QString& text) {
        if (text.isEmpty()) return;
        const QString proto = sourceToProto(bitWidgets.source->currentText());
        if (proto == "tx_ant") sendBitFieldProp("source_tx_ant", text);
        else if (proto == "rx_ant") sendBitFieldProp("source_rx_ant", text);
        else if (proto == "ordinal_slice") sendBitFieldProp("source_slice", text);
    });
    connect(bitWidgets.output, &QComboBox::currentTextChanged, this,
            [sendBitFieldProp, refreshBitOutputVisibility](const QString& text) {
        sendBitFieldProp("output", text);
        refreshBitOutputVisibility();
    });
    connect(bitWidgets.band, &QLineEdit::editingFinished, this, [sendBitFieldProp, bitWidgets]() {
        sendBitFieldProp("band", bitWidgets.band->text());
    });
    connect(bitWidgets.lowFreq, &QLineEdit::editingFinished, this, [sendBitFieldProp, bitWidgets]() {
        sendBitFieldProp("low_freq", QString::number(bitWidgets.lowFreq->text().toDouble(), 'f', 3));
    });
    connect(bitWidgets.highFreq, &QLineEdit::editingFinished, this, [sendBitFieldProp, bitWidgets]() {
        sendBitFieldProp("high_freq", QString::number(bitWidgets.highFreq->text().toDouble(), 'f', 3));
    });
    connect(bitWidgets.polarity, &QComboBox::currentTextChanged, this,
            [sendBitFieldProp](const QString& text) {
        sendBitFieldProp("polarity", text == "High" ? "active_high" : "active_low");
    });
    connect(bitWidgets.pttDependent, &QCheckBox::toggled, this, [sendBitFieldProp](bool on) {
        sendBitFieldProp("ptt_dependent", on ? "1" : "0");
    });
    connect(bitWidgets.pttDelay, QOverload<int>::of(&QSpinBox::valueChanged), this,
            [sendBitFieldProp](int val) {
        sendBitFieldProp("ptt_delay", QString::number(val));
    });
    connect(bitWidgets.txDelay, QOverload<int>::of(&QSpinBox::valueChanged), this,
            [sendBitFieldProp](int val) {
        sendBitFieldProp("tx_delay", QString::number(val));
    });
    connect(bitIndexList, &QListWidget::currentRowChanged, this,
            [currentBit, refreshBitDetail](int row) {
        if (row < 0) return;
        *currentBit = row;
        refreshBitDetail();
    });

    // Passthrough
    auto sendPtProp = [cableModel, cableList](const QString& key, const QString& val) {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendSet(item->data(Qt::UserRole).toString(), key, val);
    };
    connect(ptNameEdit, &QLineEdit::editingFinished, this, [ptNameEdit, sendPtProp]() {
        sendPtProp("name", QString(ptNameEdit->text()).replace(' ', QChar(0x7F)));
    });
    connect(ptEnabledCheck, &QCheckBox::toggled, this, [sendPtProp](bool on) {
        sendPtProp("enable", on ? "1" : "0");
    });
    wireTypeCombo(ptTypeCombo);

    // LDPA
    auto sendLdpaProp = [cableModel, cableList](const QString& key, const QString& val) {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendSet(item->data(Qt::UserRole).toString(), key, val);
    };
    connect(ldpaNameEdit, &QLineEdit::editingFinished, this, [ldpaNameEdit, sendLdpaProp]() {
        sendLdpaProp("name", QString(ldpaNameEdit->text()).replace(' ', QChar(0x7F)));
    });
    connect(ldpaEnabledCheck, &QCheckBox::toggled, this, [sendLdpaProp](bool on) {
        sendLdpaProp("enable", on ? "1" : "0");
    });
    wireTypeCombo(ldpaTypeCombo);
    connect(ldpaBandCombo, &QComboBox::currentIndexChanged, this,
            [sendLdpaProp](int idx) {
        sendLdpaProp("band", idx == 1 ? "4" : "2");
    });
    connect(ldpaPreampCheck, &QCheckBox::toggled, this, [sendLdpaProp](bool on) {
        sendLdpaProp("preamp", on ? "1" : "0");
    });
    connect(ldpaSourceCombo, &QComboBox::currentTextChanged, this,
            [sendLdpaProp, sourceToProto](const QString& text) {
        sendLdpaProp("source", sourceToProto(text));
    });

    // Unconfigured cable
    auto sendUnconfiguredProp = [cableModel, cableList](const QString& key, const QString& val) {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendSet(item->data(Qt::UserRole).toString(), key, val);
    };
    connect(unconfiguredNameEdit, &QLineEdit::editingFinished, this,
            [unconfiguredNameEdit, sendUnconfiguredProp]() {
        sendUnconfiguredProp("name", QString(unconfiguredNameEdit->text()).replace(' ', QChar(0x7F)));
    });
    wireTypeCombo(unconfiguredTypeCombo);
    connect(unconfiguredRemoveBtn, &QPushButton::clicked, this, [cableModel, cableList]() {
        auto* item = cableList->currentItem();
        if (!item) return;
        cableModel->sendRemove(item->data(Qt::UserRole).toString());
    });

    connect(ptSerialWidgets.speed, &QComboBox::currentTextChanged, this, [sendPtProp](const QString& val) { sendPtProp("speed", val); });
    connect(ptSerialWidgets.data, &QComboBox::currentTextChanged, this, [sendPtProp](const QString& val) { sendPtProp("data_bits", val); });
    connect(ptSerialWidgets.parity, &QComboBox::currentTextChanged, this, [sendPtProp](const QString& val) { sendPtProp("parity", val); });
    connect(ptSerialWidgets.stop, &QComboBox::currentTextChanged, this, [sendPtProp](const QString& val) { sendPtProp("stop_bits", val); });
    connect(ptSerialWidgets.flow, &QComboBox::currentTextChanged, this, [sendPtProp](const QString& val) { sendPtProp("flow_control", val); });

    // Initial populate
    refreshList();

    return page;
}

#ifdef HAVE_SERIALPORT
QWidget* RadioSetupDialog::buildSerialTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);
    vbox->setContentsMargins(8, 8, 8, 8);

    const QString kLabelStyle = "QLabel { color: #8898a8; font-size: 11px; }";
    const QString kGroupStyle = "QGroupBox { color: #00b4d8; font-size: 12px; border: 1px solid #203040; "
                                "border-radius: 4px; margin-top: 6px; padding-top: 14px; } "
                                "QGroupBox::title { subcontrol-origin: margin; left: 8px; }";

    auto& settings = AppSettings::instance();

    // ── USB control surfaces (Ulanzi Dial, StreamDeck+) (#3257) ──────────
    // These are opt-in because the first call into each backend triggers the
    // macOS Input Monitoring permission prompt (kIOHIDOptionsTypeSeizeDevice
    // in the IOKit-direct backend, hid_open() in HIDAPI). Defaulting them off
    // means the prompt only ever fires for users who actually own and want to
    // use the hardware.
    {
        auto* group = new QGroupBox("USB Control Surfaces");
        group->setStyleSheet(kGroupStyle);
        auto* gvbox = new QVBoxLayout(group);
        gvbox->setSpacing(6);

        auto* note = new QLabel(
            "Enable only if you connect a Ulanzi Dial or Elgato Stream Deck+. "
            "On macOS, enabling will trigger an Input Monitoring permission "
            "prompt the first time AetherSDR scans for the device.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        gvbox->addWidget(note);

        auto* ulanziEnable = new QCheckBox("Enable Ulanzi Dial");
        AetherSDR::ThemeManager::instance().applyStyleSheet(
            ulanziEnable, "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }"
            + kCheckBoxIndicator);
        ulanziEnable->setChecked(
            settings.value("UlanziDialEnabled", "False").toString() == "True");
        connect(ulanziEnable, &QCheckBox::toggled, this, [this](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("UlanziDialEnabled", on ? "True" : "False");
            s.save();
            emit serialSettingsChanged();
        });
        gvbox->addWidget(ulanziEnable);

#ifdef HAVE_HIDAPI
        auto* hidEnable = new QCheckBox(
            "Enable HID encoders / StreamDeck+ (RC-28, PowerMate, ShuttleXpress, …)");
        AetherSDR::ThemeManager::instance().applyStyleSheet(
            hidEnable, "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }"
            + kCheckBoxIndicator);
        hidEnable->setChecked(
            settings.value("HidEncoderEnabled", "False").toString() == "True");
        connect(hidEnable, &QCheckBox::toggled, this, [this](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("HidEncoderEnabled", on ? "True" : "False");
            s.save();
            emit serialSettingsChanged();
        });
        gvbox->addWidget(hidEnable);
#endif

        vbox->addWidget(group);
    }

    // ── Port Configuration ───────────────────────────────────────────────
    {
        auto* group = new QGroupBox("Port Configuration");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(4);

        // Port selector + manual entry for non-standard TTYs (#897)
        grid->addWidget(new QLabel("Port:"), 0, 0);
        auto* portCombo = new QComboBox;
        portCombo->setMinimumWidth(200);
        QString savedPort = settings.value("SerialPortName", "").toString();
        auto* customEdit = new QLineEdit;
        customEdit->setPlaceholderText("/dev/ttyr0");
        bool isCustom = populateSerialPortCombo(portCombo, customEdit, savedPort);
        grid->addWidget(portCombo, 0, 1);

        auto* refreshBtn = new QPushButton("Refresh");
        // Let native style (notably macOS) drive the button height; a fixed
        // 24 px clipped the macOS button bezel and label baseline.
        refreshBtn->setMinimumHeight(24);
        grid->addWidget(refreshBtn, 0, 3);

        // Custom port row — hidden unless "Custom..." selected or saved port is custom
        auto* customLabel = new QLabel("Path:");
        customLabel->setVisible(isCustom);
        customEdit->setVisible(isCustom);
        grid->addWidget(customLabel, 1, 0);
        grid->addWidget(customEdit, 1, 1, 1, 3);

        connect(portCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
                this, [portCombo, customLabel, customEdit](int idx) {
            bool custom = (portCombo->itemData(idx).toString() == "__custom__");
            customLabel->setVisible(custom);
            customEdit->setVisible(custom);
        });

        connect(refreshBtn, &QPushButton::clicked, this, [portCombo, customEdit]() {
            QString customText = customEdit->text();
            int customIdx = portCombo->count() - 1;  // "Custom..." is last
            bool wasCustom = (portCombo->currentIndex() == customIdx);
            // Remove all but "Custom..."
            while (portCombo->count() > 1)
                portCombo->removeItem(0);
            for (const auto& info : QSerialPortInfo::availablePorts())
                portCombo->insertItem(portCombo->count() - 1,
                    QString("%1 — %2").arg(info.portName(), info.description()),
                    info.portName());
            if (wasCustom) {
                portCombo->setCurrentIndex(portCombo->count() - 1);
            }
        });

        // Baud rate
        grid->addWidget(new QLabel("Baud:"), 2, 0);
        auto* baudCombo = new QComboBox;
        for (int b : {9600, 19200, 38400, 57600, 115200})
            baudCombo->addItem(QString::number(b), b);
        int savedBaud = settings.value("SerialBaudRate", "9600").toInt();
        baudCombo->setCurrentIndex(baudCombo->findData(savedBaud));
        grid->addWidget(baudCombo, 2, 1);

        // Data bits
        grid->addWidget(new QLabel("Data:"), 2, 2);
        auto* dataCombo = new QComboBox;
        dataCombo->addItem("8", 8);
        dataCombo->addItem("7", 7);
        int savedData = settings.value("SerialDataBits", "8").toInt();
        dataCombo->setCurrentIndex(dataCombo->findData(savedData));
        grid->addWidget(dataCombo, 2, 3);

        // Parity
        grid->addWidget(new QLabel("Parity:"), 3, 0);
        auto* parityCombo = new QComboBox;
        parityCombo->addItem("None", 0);
        parityCombo->addItem("Even", 2);
        parityCombo->addItem("Odd", 3);
        int savedParity = settings.value("SerialParity", "0").toInt();
        parityCombo->setCurrentIndex(parityCombo->findData(savedParity));
        grid->addWidget(parityCombo, 3, 1);

        // Stop bits
        grid->addWidget(new QLabel("Stop:"), 3, 2);
        auto* stopCombo = new QComboBox;
        stopCombo->addItem("1", 1);
        stopCombo->addItem("2", 2);
        int savedStop = settings.value("SerialStopBits", "1").toInt();
        stopCombo->setCurrentIndex(stopCombo->findData(savedStop));
        grid->addWidget(stopCombo, 3, 3);

        vbox->addWidget(group);

        // Save port settings on any change
        auto savePort = [portCombo, customEdit, baudCombo, dataCombo, parityCombo, stopCombo]() {
            auto& s = AppSettings::instance();
            QString port = portCombo->currentData().toString();
            if (port == "__custom__") {
                port = customEdit->text().trimmed();
            }
            s.setValue("SerialPortName", port);
            s.setValue("SerialBaudRate", baudCombo->currentData().toString());
            s.setValue("SerialDataBits", dataCombo->currentData().toString());
            s.setValue("SerialParity", parityCombo->currentData().toString());
            s.setValue("SerialStopBits", stopCombo->currentData().toString());
            s.save();
        };
        connect(portCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, savePort);
        connect(customEdit, &QLineEdit::textChanged, this, savePort);
        connect(baudCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, savePort);
        connect(dataCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, savePort);
        connect(parityCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, savePort);
        connect(stopCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, savePort);
    }

    // ── Pin Assignment ───────────────────────────────────────────────────
    {
        auto* group = new QGroupBox("Pin Assignment");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(4);

        auto* headerPin = new QLabel("Pin");
        auto* headerFn  = new QLabel("Function");
        auto* headerPol = new QLabel("Polarity");
        headerPin->setStyleSheet(kLabelStyle);
        headerFn->setStyleSheet(kLabelStyle);
        headerPol->setStyleSheet(kLabelStyle);
        grid->addWidget(headerPin, 0, 0);
        grid->addWidget(headerFn,  0, 1);
        grid->addWidget(headerPol, 0, 2);

        auto makeFnCombo = [this](const QString& savedKey) {
            auto* combo = new QComboBox;
            combo->addItem("None",   "None");
            combo->addItem("PTT",    "PTT");
            combo->addItem("CW Key", "CwKey");
            combo->addItem("CW PTT", "CwPTT");
            QString saved = AppSettings::instance().value(savedKey, "None").toString();
            for (int i = 0; i < combo->count(); ++i)
                if (combo->itemData(i).toString() == saved) { combo->setCurrentIndex(i); break; }
            connect(combo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [savedKey, combo]() {
                auto& s = AppSettings::instance();
                s.setValue(savedKey, combo->currentData().toString());
                s.save();
            });
            return combo;
        };

        auto makePolCombo = [this](const QString& savedKey) {
            auto* combo = new QComboBox;
            combo->addItem("Active High", "ActiveHigh");
            combo->addItem("Active Low",  "ActiveLow");
            QString saved = AppSettings::instance().value(savedKey, "ActiveHigh").toString();
            combo->setCurrentIndex(saved == "ActiveLow" ? 1 : 0);
            connect(combo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [savedKey, combo]() {
                auto& s = AppSettings::instance();
                s.setValue(savedKey, combo->currentData().toString());
                s.save();
            });
            return combo;
        };

        // DTR row
        grid->addWidget(new QLabel("DTR"), 1, 0);
        grid->addWidget(makeFnCombo("SerialDtrFunction"), 1, 1);
        grid->addWidget(makePolCombo("SerialDtrPolarity"), 1, 2);

        // RTS row
        grid->addWidget(new QLabel("RTS"), 2, 0);
        grid->addWidget(makeFnCombo("SerialRtsFunction"), 2, 1);
        grid->addWidget(makePolCombo("SerialRtsPolarity"), 2, 2);

        // Input pin function combo (different options than output)
        auto makeInputFnCombo = [this](const QString& savedKey) {
            auto* combo = new QComboBox;
            combo->addItem("None",         "None");
            combo->addItem("PTT Input",    "PttInput");
            combo->addItem("CW Key Input", "CwKeyInput");
            combo->addItem("CW Dit Input", "CwDitInput");
            combo->addItem("CW Dah Input", "CwDahInput");
            QString saved = AppSettings::instance().value(savedKey, "None").toString();
            for (int i = 0; i < combo->count(); ++i)
                if (combo->itemData(i).toString() == saved) { combo->setCurrentIndex(i); break; }
            connect(combo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [savedKey, combo]() {
                auto& s = AppSettings::instance();
                s.setValue(savedKey, combo->currentData().toString());
                s.save();
            });
            return combo;
        };

        // CTS row (input)
        auto* ctsLabel = new QLabel("CTS");
        ctsLabel->setStyleSheet("QLabel { color: #60a0c0; }");
        grid->addWidget(ctsLabel, 3, 0);
        grid->addWidget(makeInputFnCombo("SerialCtsFunction"), 3, 1);
        grid->addWidget(makePolCombo("SerialCtsPolarity"), 3, 2);

        // DSR row (input)
        auto* dsrLabel = new QLabel("DSR");
        dsrLabel->setStyleSheet("QLabel { color: #60a0c0; }");
        grid->addWidget(dsrLabel, 4, 0);
        grid->addWidget(makeInputFnCombo("SerialDsrFunction"), 4, 1);
        grid->addWidget(makePolCombo("SerialDsrPolarity"), 4, 2);

        // DCD row (input) — added for accessories that wire to the FTDI
        // chip's DCD# pin (e.g. HaliKey Serial from Halibut Electronics,
        // whose TRS Ring is wired to both DSR and DCD).
        auto* dcdLabel = new QLabel("DCD");
        dcdLabel->setStyleSheet("QLabel { color: #60a0c0; }");
        grid->addWidget(dcdLabel, 5, 0);
        grid->addWidget(makeInputFnCombo("SerialDcdFunction"), 5, 1);
        grid->addWidget(makePolCombo("SerialDcdPolarity"), 5, 2);

        // Paddle swap
        auto* swapCb = new QCheckBox("Paddle Swap (swap dit/dah)");
        AetherSDR::ThemeManager::instance().applyStyleSheet(swapCb,
            "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }"
            + kCheckBoxIndicator);
        swapCb->setChecked(AppSettings::instance().value("SerialPaddleSwap", "False").toString() == "True");
        connect(swapCb, &QCheckBox::toggled, this, [](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("SerialPaddleSwap", on ? "True" : "False");
            s.save();
        });
        grid->addWidget(swapCb, 6, 0, 1, 3);

        vbox->addWidget(group);
    }

    // ── Open / Close / Auto-open ────────────────────────────────────────
    {
        auto* row = new QHBoxLayout;
        row->setSpacing(8);

        const QString btnStyle =
            "QPushButton { background: #00b4d8; color: #0f0f1a; font-weight: bold; "
            "border: 1px solid #008ba8; padding: 3px; border-radius: 3px; }"
            "QPushButton:hover { background: #00c8f0; }"
            "QPushButton:disabled { background: {{color.button.background.disabled}}; color: {{color.button.foreground.disabled}}; border-color: {{color.button.border.disabled}}; }";

        auto* openBtn = new QPushButton("Open");
        // Min-width rather than fixed-width: macOS native button metrics need
        // more horizontal room than the 80 px Windows/Fusion baseline.
        openBtn->setMinimumWidth(80);
        AetherSDR::ThemeManager::instance().applyStyleSheet(openBtn, btnStyle);
        auto* closeBtn = new QPushButton("Close");
        closeBtn->setMinimumWidth(80);
        AetherSDR::ThemeManager::instance().applyStyleSheet(closeBtn, btnStyle);

        auto* statusLabel = new QLabel;
        statusLabel->setStyleSheet("QLabel { font-size: 11px; }");

        row->addWidget(openBtn);
        row->addWidget(closeBtn);
        row->addWidget(statusLabel);
        row->addStretch();

        auto updatePortStatus = [openBtn, closeBtn, statusLabel]() {
            bool open = AppSettings::instance().value("SerialPortOpen", "False").toString() == "True";
            openBtn->setEnabled(!open);
            closeBtn->setEnabled(open);
            if (open) {
                statusLabel->setText("Open");
                AetherSDR::ThemeManager::instance().applyStyleSheet(statusLabel, "QLabel { color: {{color.accent.success}}; font-size: 11px; }");
            } else {
                statusLabel->setText("Closed");
                AetherSDR::ThemeManager::instance().applyStyleSheet(statusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }");
            }
        };

        connect(openBtn, &QPushButton::clicked, this, [this, updatePortStatus]() {
            auto& s = AppSettings::instance();
            s.setValue("SerialPortOpen", "True");
            s.save();
            updatePortStatus();
            emit serialSettingsChanged();
        });

        connect(closeBtn, &QPushButton::clicked, this, [this, updatePortStatus]() {
            auto& s = AppSettings::instance();
            s.setValue("SerialPortOpen", "False");
            s.save();
            updatePortStatus();
            emit serialSettingsChanged();
        });

        updatePortStatus();
        vbox->addLayout(row);

        auto* autoOpen = new QCheckBox("Auto-open serial port on startup");
        AetherSDR::ThemeManager::instance().applyStyleSheet(autoOpen,
            "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }"
            + kCheckBoxIndicator);
        autoOpen->setChecked(settings.value("SerialAutoOpen", "False").toString() == "True");
        connect(autoOpen, &QCheckBox::toggled, this, [](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("SerialAutoOpen", on ? "True" : "False");
            s.save();
        });
        vbox->addWidget(autoOpen);
    }

    // ── FlexControl tuning knob ────────────────────────────────────────
    {
        auto* group = new QGroupBox("FlexControl Tuning Knob");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        // Status
        auto* fcStatusLabel = new QLabel("Not detected");
        AetherSDR::ThemeManager::instance().applyStyleSheet(fcStatusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }");
        m_flexControlStatusLabel = fcStatusLabel;
        grid->addWidget(new QLabel("Status:"), 0, 0);
        grid->addWidget(fcStatusLabel, 0, 1);

        // Detect / Close buttons
        auto* fcDetectBtn = new QPushButton("Detect");
        // Same macOS-native-metrics consideration as the Open/Close pair above.
        fcDetectBtn->setMinimumWidth(80);
        AetherSDR::ThemeManager::instance().applyStyleSheet(fcDetectBtn, "QPushButton { background: {{color.accent}}; color: {{color.background.0}}; font-weight: bold; "
            "border: 1px solid {{color.accent.dim}}; padding: 3px; border-radius: 3px; }"
            "QPushButton:hover { background: {{color.accent.bright}}; }");
        auto* fcCloseBtn = new QPushButton("Close");
        fcCloseBtn->setMinimumWidth(80);
        fcCloseBtn->setStyleSheet(fcDetectBtn->styleSheet());
        fcCloseBtn->setEnabled(false);
        m_flexControlDetectButton = fcDetectBtn;
        m_flexControlCloseButton = fcCloseBtn;

        auto* btnRow = new QHBoxLayout;
        btnRow->addWidget(fcDetectBtn);
        btnRow->addWidget(fcCloseBtn);
        btnRow->addStretch();
        grid->addLayout(btnRow, 0, 2);

        // Update status display
        connect(fcDetectBtn, &QPushButton::clicked, this, [this] {
            QString port = FlexControlManager::detectPort();
            if (port.isEmpty()) {
                setFlexControlConnectionStatus(false);
                return;
            }
            // Store port for MainWindow to open
            auto& s = AppSettings::instance();
            s.setValue("FlexControlPort", port);
            s.setValue("FlexControlOpen", "True");
            s.save();
            emit serialSettingsChanged();
        });
        connect(fcCloseBtn, &QPushButton::clicked, this, [this] {
            auto& s = AppSettings::instance();
            s.setValue("FlexControlOpen", "False");
            s.save();
            setFlexControlConnectionStatus(false);
            emit serialSettingsChanged();
        });

        // Show current state from settings
        if (settings.value("FlexControlOpen", "False").toString() == "True") {
            QString port = settings.value("FlexControlPort").toString();
            if (!port.isEmpty())
                setFlexControlConnectionStatus(true, port);
        }

        // Button action configuration
        static const QStringList actions = {
            "None", "StepUp", "StepDown", "ToggleMox",
            "ToggleTune", "ToggleMute", "ToggleLock",
            "BandZoom", "SegmentZoom",
            "NextSlice", "PrevSlice",
            "SplitActiveSlice",
            "ToggleAgc", "VolumeUp", "VolumeDown",
            "WheelFrequency", "WheelVolume", "WheelPower",
            "WheelRit", "WheelXit",
            "WheelSliceAudio",
            "WheelHeadphoneVolume",
            "WheelAgcT", "WheelApf", "WheelCwSpeed",
            "ClearRit", "ClearXit", "ToggleApf",
            "CwxF1", "CwxF2", "CwxF3", "CwxF4",
            "CwxF5", "CwxF6", "CwxF7", "CwxF8",
            "CwxF9", "CwxF10", "CwxF11", "CwxF12"
        };
        static const char* defaultActions[4][2] = {
            {"StepUp", "StepDown"},
            {"ToggleMox", "ToggleTune"},
            {"ToggleMute", "ToggleLock"},
            {"StepUp", "StepDown"},      // Knob button
        };
        static const char* btnLabels[4] = {"Button 1:", "Button 2:", "Button 3:", "Knob Button:"};
        static const char* actLabels[2] = {"Tap", "Double"};

        for (int b = 0; b < 4; ++b) {
            grid->addWidget(new QLabel(btnLabels[b]), b + 1, 0);
            auto* row = new QHBoxLayout;
            for (int a = 0; a < 2; ++a) {
                row->addWidget(new QLabel(actLabels[a]));
                auto* combo = new QComboBox;
                combo->addItems(actions);
                combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
                QString key = QString("FlexControlBtn%1Action%2").arg(b + 1).arg(a);
                QString current = settings.value(key, defaultActions[b][a]).toString();
                int idx = actions.indexOf(current);
                if (idx >= 0) combo->setCurrentIndex(idx);
                m_flexControlActionCombos.insert(key, combo);
                m_flexControlActionDefaults.insert(key, QString::fromLatin1(defaultActions[b][a]));
                connect(combo, &QComboBox::currentTextChanged, this, [this, key](const QString& text) {
                    auto& s = AppSettings::instance();
                    s.setValue(key, text);
                    s.save();
                    emit serialSettingsChanged();
                });
                row->addWidget(combo);
            }
            row->addStretch();
            grid->addLayout(row, b + 1, 1, 1, 2);
        }

        // Auto-detect checkbox
        auto* autoDetect = new QCheckBox("Auto-detect on startup");
        AetherSDR::ThemeManager::instance().applyStyleSheet(autoDetect,
            "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }"
            + kCheckBoxIndicator);
        autoDetect->setChecked(settings.value("FlexControlAutoDetect", "True").toString() == "True");
        connect(autoDetect, &QCheckBox::toggled, this, [this](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("FlexControlAutoDetect", on ? "True" : "False");
            s.save();
            emit serialSettingsChanged();
        });
        grid->addWidget(autoDetect, 5, 0, 1, 3);

        auto* invertDir = new QCheckBox("Invert tuning direction");
        AetherSDR::ThemeManager::instance().applyStyleSheet(invertDir,
            "QCheckBox { color: {{color.text.primary}}; spacing: 8px; }"
            + kCheckBoxIndicator);
        invertDir->setChecked(settings.value("FlexControlInvertDir", "False").toString() == "True");
        m_flexControlInvertCheck = invertDir;
        connect(invertDir, &QCheckBox::toggled, this, [this](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("FlexControlInvertDir", on ? "True" : "False");
            s.save();
            emit serialSettingsChanged();
        });
        grid->addWidget(invertDir, 6, 0, 1, 3);

        vbox->addWidget(group);
    }

    // ── HID / StreamDeck+ LCD button action mapping (#1510) ──────────────────
#ifdef HAVE_HIDAPI
    {
        auto* group = new QGroupBox("StreamDeck+ LCD Button Actions");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* note = new QLabel(
            "Assign an action to each of the 8 LCD buttons. "
            "The button label updates on the device to match.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        grid->addWidget(note, 0, 0, 1, 4);

        static const struct { const char* id; const char* label; } kKeyActions[] = {
            {"None",             "None"},
            {"ToggleMox",        "Toggle TX (MOX)"},
            {"ToggleTune",       "Toggle Tune"},
            {"ToggleRit",        "Toggle RIT on/off"},
            {"ToggleXit",        "Toggle XIT on/off"},
            {"ClearRit",         "Clear RIT offset"},
            {"ClearXit",         "Clear XIT offset"},
            {"StepUp",           "Step Size Up"},
            {"StepDown",         "Step Size Down"},
            {"ToggleMute",       "Toggle Mute"},
            {"ToggleLock",       "Toggle Slice Lock"},
            {"ToggleApf",        "Toggle APF"},
            {"ToggleAgc",        "Cycle AGC Mode"},
            {"BandZoom",         "Toggle Band Zoom"},
            {"SegmentZoom",      "Toggle Segment Zoom"},
            {"NextSlice",        "Next Slice"},
            {"PrevSlice",        "Previous Slice"},
            {"VolumeUp",         "Volume Up (+5)"},
            {"VolumeDown",       "Volume Down (-5)"},
            {"SplitActiveSlice", "Toggle Split"},
        };

        // 8 keys laid out as 2 columns of 4
        for (int i = 0; i < 8; ++i) {
            const int row = (i % 4) + 1;
            const int col = (i / 4) * 2;

            grid->addWidget(new QLabel(QString("Key %1:").arg(i + 1)), row, col);

            auto* combo = new QComboBox;
            combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
            for (const auto& act : kKeyActions)
                combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id));

            const QString key    = QString("HidKeyAction%1").arg(i);
            const QString saved  = settings.value(key, QStringLiteral("None")).toString();
            const int     selIdx = combo->findData(saved);
            combo->setCurrentIndex(selIdx >= 0 ? selIdx : 0);

            connect(combo, &QComboBox::currentIndexChanged, this, [combo, key, this](int) {
                auto& s = AppSettings::instance();
                s.setValue(key, combo->currentData().toString());
                s.save();
                emit serialSettingsChanged();
            });

            m_hidKeyActionCombos[i] = combo;
            grid->addWidget(combo, row, col + 1);
        }
        grid->setColumnStretch(1, 1);
        grid->setColumnStretch(3, 1);

        vbox->addWidget(group);
    }

    // ── HID Encoder — per-encoder action mapping (#1510) ─────────────────────
    {
        auto* group = new QGroupBox("HID Encoder / StreamDeck+ Encoders");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* note = new QLabel(
            "Assign an action to each encoder dial. "
            "Single-encoder devices (RC-28, PowerMate, ShuttleXpress) use Encoder 1 only.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        grid->addWidget(note, 0, 0, 1, 3);

        static const struct { const char* id; const char* label; } kEncoderActions[] = {
            {"WheelFrequency",      "Tune Slice"},
            {"WheelRit",            "RIT (Receive Incremental Tuning)"},
            {"WheelXit",            "XIT (Transmit Incremental Tuning)"},
            {"WheelVolume",         "Master Volume"},
            {"WheelSliceAudio",     "Slice Audio Volume"},
            {"WheelHeadphoneVolume","Headphone Volume"},
            {"WheelAgcT",           "AGC Threshold"},
            {"WheelApf",            "APF Level"},
            {"WheelCwSpeed",        "CW Speed"},
            {"WheelPower",          "RF Power"},
            {"None",                "None"},
        };

        static const char* kEncoderDefaults[4] = {
            "WheelFrequency", "WheelRit", "WheelXit", "WheelVolume"
        };

        for (int i = 0; i < 4; ++i) {
            grid->addWidget(new QLabel(QString("Encoder %1:").arg(i + 1)), i + 1, 0);

            auto* combo = new QComboBox;
            combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
            for (const auto& act : kEncoderActions)
                combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id));

            const QString key = QString("HidEncoderAction%1").arg(i);
            const QString saved = settings.value(key, QString::fromLatin1(kEncoderDefaults[i])).toString();
            const int idx = combo->findData(saved);
            combo->setCurrentIndex(idx >= 0 ? idx : 0);

            connect(combo, &QComboBox::currentIndexChanged, this, [combo, key, this](int) {
                auto& s = AppSettings::instance();
                s.setValue(key, combo->currentData().toString());
                s.save();
                emit serialSettingsChanged();
            });

            m_hidEncoderActionCombos[i] = combo;
            grid->addWidget(combo, i + 1, 1, 1, 2);
            grid->setColumnStretch(1, 1);
        }

        vbox->addWidget(group);
    }

    // ── HID Encoder — per-encoder push-button action mapping (#1510) ─────────
    {
        auto* group = new QGroupBox("StreamDeck+ Encoder Push Actions");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* note = new QLabel(
            "Action when each encoder dial is pressed. "
            "Defaults: Enc 1 = Cycle Tuning Step, Enc 2 = Toggle RIT, Enc 3 = Toggle XIT.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        grid->addWidget(note, 0, 0, 1, 3);

        static const struct { const char* id; const char* label; } kPushActions[] = {
            {"StepCycle",  "Cycle Tuning Step"},
            {"ToggleRit",  "Toggle RIT on/off"},
            {"ToggleXit",  "Toggle XIT on/off"},
            {"ToggleMox",  "Toggle TX (MOX)"},
            {"ToggleMute", "Toggle Mute"},
            {"ToggleLock", "Lock Slice"},
            {"None",       "None"},
        };

        static const char* kPushDefaults[4] = {
            "StepCycle", "ToggleRit", "ToggleXit", "None"
        };

        for (int i = 0; i < 4; ++i) {
            grid->addWidget(new QLabel(QString("Encoder %1 push:").arg(i + 1)), i + 1, 0);

            auto* combo = new QComboBox;
            combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
            for (const auto& act : kPushActions)
                combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id));

            const QString key = QString("HidEncoderPushAction%1").arg(i);
            const QString saved = settings.value(key, QString::fromLatin1(kPushDefaults[i])).toString();
            const int idx = combo->findData(saved);
            combo->setCurrentIndex(idx >= 0 ? idx : 0);

            connect(combo, &QComboBox::currentIndexChanged, this, [combo, key, this](int) {
                auto& s = AppSettings::instance();
                s.setValue(key, combo->currentData().toString());
                s.save();
                emit serialSettingsChanged();
            });

            m_hidEncoderPushActionCombos[i] = combo;
            grid->addWidget(combo, i + 1, 1, 1, 2);
            grid->setColumnStretch(1, 1);
        }

        vbox->addWidget(group);
    }

    // --- TMate 2 device actions, encoders, and backlight ---------------------
    {
        auto* group = new QGroupBox("TMate 2 Key Actions");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* note = new QLabel("Assign actions to the six TMate 2 function keys.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        grid->addWidget(note, 0, 0, 1, 4);

        static const struct { const char* id; const char* label; } kTMate2KeyActions[] = {
            {"None",             "None"},
            {"ToggleMox",        "Toggle TX (MOX)"},
            {"ToggleTune",       "Toggle Tune"},
            {"ToggleRit",        "Toggle RIT on/off"},
            {"ToggleXit",        "Toggle XIT on/off"},
            {"ClearRit",         "Clear RIT offset"},
            {"ClearXit",         "Clear XIT offset"},
            {"StepUp",           "Step Size Up"},
            {"StepDown",         "Step Size Down"},
            {"ToggleMute",       "Toggle Mute"},
            {"ToggleLock",       "Toggle Slice Lock"},
            {"ToggleApf",        "Toggle APF"},
            {"ToggleAgc",        "Cycle AGC Mode"},
            {"BandZoom",         "Toggle Band Zoom"},
            {"SegmentZoom",      "Toggle Segment Zoom"},
            {"NextSlice",        "Next Slice"},
            {"PrevSlice",        "Previous Slice"},
            {"VolumeUp",         "Volume Up (+5)"},
            {"VolumeDown",       "Volume Down (-5)"},
            {"SplitActiveSlice", "Toggle Split"},
        };

        static const char* kTMate2KeyDefaults[6] = {
            "ToggleMox", "ToggleAgc", "BandZoom", "ToggleApf", "ToggleMute", "ToggleRit"
        };

        for (int i = 0; i < 6; ++i) {
            const int row = (i % 3) + 1;
            const int col = (i / 3) * 2;
            grid->addWidget(new QLabel(QString("F%1:").arg(i + 1)), row, col);

            auto* combo = new QComboBox;
            combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
            for (const auto& act : kTMate2KeyActions)
                combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id));

            const QString key = QString("TMate2KeyAction%1").arg(i);
            const QString saved = settings.value(
                key, QString::fromLatin1(kTMate2KeyDefaults[i])).toString();
            const int idx = combo->findData(saved);
            combo->setCurrentIndex(idx >= 0 ? idx : 0);

            connect(combo, &QComboBox::currentIndexChanged, this, [combo, key, this](int) {
                auto& s = AppSettings::instance();
                s.setValue(key, combo->currentData().toString());
                s.save();
                emit serialSettingsChanged();
            });

            m_tmate2KeyActionCombos[i] = combo;
            grid->addWidget(combo, row, col + 1);
        }
        grid->setColumnStretch(1, 1);
        grid->setColumnStretch(3, 1);

        vbox->addWidget(group);
    }

    {
        auto* group = new QGroupBox("TMate 2 Encoder Actions");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* note = new QLabel("Assign actions to the three TMate 2 encoder dials.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        grid->addWidget(note, 0, 0, 1, 3);

        static const struct { const char* id; const char* label; } kTMate2EncoderActions[] = {
            {"WheelFrequency",      "Tune Slice"},
            {"WheelRit",            "RIT (Receive Incremental Tuning)"},
            {"WheelXit",            "XIT (Transmit Incremental Tuning)"},
            {"WheelVolume",         "Master Volume"},
            {"WheelSliceAudio",     "Slice Audio Volume"},
            {"WheelHeadphoneVolume","Headphone Volume"},
            {"WheelAgcT",           "AGC Threshold"},
            {"WheelApf",            "APF Level"},
            {"WheelCwSpeed",        "CW Speed"},
            {"WheelPower",          "RF Power"},
            {"None",                "None"},
        };

        static const char* kTMate2EncoderDefaults[3] = {
            "WheelFrequency", "WheelRit", "WheelXit"
        };

        for (int i = 0; i < 3; ++i) {
            grid->addWidget(new QLabel(QString("Encoder %1:").arg(i + 1)), i + 1, 0);

            auto* combo = new QComboBox;
            combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
            for (const auto& act : kTMate2EncoderActions)
                combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id));

            const QString key = QString("TMate2EncoderAction%1").arg(i);
            const QString saved = settings.value(
                key, QString::fromLatin1(kTMate2EncoderDefaults[i])).toString();
            const int idx = combo->findData(saved);
            combo->setCurrentIndex(idx >= 0 ? idx : 0);

            connect(combo, &QComboBox::currentIndexChanged, this, [combo, key, this](int) {
                auto& s = AppSettings::instance();
                s.setValue(key, combo->currentData().toString());
                s.save();
                emit serialSettingsChanged();
            });

            m_tmate2EncoderActionCombos[i] = combo;
            grid->addWidget(combo, i + 1, 1, 1, 2);
            grid->setColumnStretch(1, 1);
        }

        vbox->addWidget(group);
    }

    {
        auto* group = new QGroupBox("TMate 2 Encoder Push Actions");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* note = new QLabel("Action when each TMate 2 encoder is pressed.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        grid->addWidget(note, 0, 0, 1, 3);

        static const struct { const char* id; const char* label; } kTMate2PushActions[] = {
            {"StepCycle",  "Cycle Tuning Step"},
            {"ToggleRit",  "Toggle RIT on/off"},
            {"ToggleXit",  "Toggle XIT on/off"},
            {"ToggleMox",  "Toggle TX (MOX)"},
            {"ToggleMute", "Toggle Mute"},
            {"ToggleLock", "Lock Slice"},
            {"None",       "None"},
        };

        static const char* kTMate2PushDefaults[3] = {
            "StepCycle", "ToggleRit", "ToggleXit"
        };

        for (int i = 0; i < 3; ++i) {
            grid->addWidget(new QLabel(QString("Encoder %1 push:").arg(i + 1)), i + 1, 0);

            auto* combo = new QComboBox;
            combo->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QComboBox"));
            for (const auto& act : kTMate2PushActions)
                combo->addItem(QString::fromLatin1(act.label), QString::fromLatin1(act.id));

            const QString key = QString("TMate2PushAction%1").arg(i);
            const QString saved = settings.value(
                key, QString::fromLatin1(kTMate2PushDefaults[i])).toString();
            const int idx = combo->findData(saved);
            combo->setCurrentIndex(idx >= 0 ? idx : 0);

            connect(combo, &QComboBox::currentIndexChanged, this, [combo, key, this](int) {
                auto& s = AppSettings::instance();
                s.setValue(key, combo->currentData().toString());
                s.save();
                emit serialSettingsChanged();
            });

            m_tmate2EncoderPushActionCombos[i] = combo;
            grid->addWidget(combo, i + 1, 1, 1, 2);
            grid->setColumnStretch(1, 1);
        }

        vbox->addWidget(group);
    }

    {
        auto* group = new QGroupBox("TMate 2 Display");
        group->setStyleSheet(kGroupStyle);
        auto* grid = new QGridLayout(group);
        grid->setSpacing(6);

        auto* note = new QLabel(
            "Backlight colours and temporary display feedback. "
            "Overlay duration controls how long changed values are shown on the TMate 2 LCD.");
        note->setWordWrap(true);
        note->setStyleSheet(kLabelStyle);
        grid->addWidget(note, 0, 0, 1, 7);

        static const struct {
            const char* rowLabel;
            const char* rKey; const char* gKey; const char* bKey;
            const char* rDflt; const char* gDflt; const char* bDflt;
            int spinOffset;
        } kRows[2] = {
            {"RX backlight:", "TMate2BacklightR",   "TMate2BacklightG",   "TMate2BacklightB",
                              "0",                  "50",                 "255", 0},
            {"TX backlight:", "TMate2TxBacklightR", "TMate2TxBacklightG", "TMate2TxBacklightB",
                              "255",                "30",                 "0",   3},
        };

        static const char* kLabels[3] = {"R:", "G:", "B:"};
        for (int row = 0; row < 2; ++row) {
            auto* rowLbl = new QLabel(QString::fromLatin1(kRows[row].rowLabel));
            rowLbl->setStyleSheet(kLabelStyle);
            grid->addWidget(rowLbl, row + 1, 0);
            const char* keys[3] = {kRows[row].rKey, kRows[row].gKey, kRows[row].bKey};
            const char* dflts[3] = {kRows[row].rDflt, kRows[row].gDflt, kRows[row].bDflt};
            for (int ch = 0; ch < 3; ++ch) {
                auto* lbl = new QLabel(QString::fromLatin1(kLabels[ch]));
                lbl->setStyleSheet(kLabelStyle);
                grid->addWidget(lbl, row + 1, 1 + ch * 2);

                auto* spin = new QSpinBox;
                spin->setRange(0, 255);
                spin->setValue(settings.value(keys[ch], dflts[ch]).toInt());
                spin->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QSpinBox"));
                grid->addWidget(spin, row + 1, 2 + ch * 2);
                m_tmate2BacklightSpins[kRows[row].spinOffset + ch] = spin;

                const QString key = QString::fromLatin1(keys[ch]);
                connect(spin, QOverload<int>::of(&QSpinBox::valueChanged),
                        this, [this, key](int val) {
                    auto& s = AppSettings::instance();
                    s.setValue(key, QString::number(val));
                    s.save();
                    emit serialSettingsChanged();
                });
            }
        }

        auto addTimingSpin = [&](int row, const QString& label, const QString& key,
                                 int dflt, int min, int max, int step) -> QSpinBox* {
            auto* lbl = new QLabel(label);
            lbl->setStyleSheet(kLabelStyle);
            grid->addWidget(lbl, row, 0, 1, 2);

            auto* spin = new QSpinBox;
            spin->setRange(min, max);
            spin->setSingleStep(step);
            spin->setSuffix(" ms");
            spin->setValue(settings.value(key, QString::number(dflt)).toInt());
            spin->setStyleSheet(QString(kEditStyle).replace("QLineEdit", "QSpinBox"));
            grid->addWidget(spin, row, 2, 1, 2);

            connect(spin, QOverload<int>::of(&QSpinBox::valueChanged),
                    this, [this, key](int val) {
                auto& s = AppSettings::instance();
                s.setValue(key, QString::number(val));
                s.save();
                emit serialSettingsChanged();
            });
            return spin;
        };

        m_tmate2OverlayDurationSpin = addTimingSpin(
            3, "Overlay duration:", "TMate2OverlayDurationMs", 1500, 100, 10000, 100);
        m_tmate2UserInteractionTimeoutSpin = addTimingSpin(
            4, "User interaction timeout:", "TMate2UserInteractionTimeoutMs", 2000, 0, 60000, 100);

        for (int col = 2; col <= 6; col += 2)
            grid->setColumnStretch(col, 1);

        vbox->addWidget(group);
    }

#endif

    vbox->addStretch();
    return page;
}
#endif



// ─── Peripherals tab — manual IP connect for TGXL, PGXL, AG (#914) ───────────

QWidget* RadioSetupDialog::buildPeripheralsTab()
{
    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(8);

    auto* group = new QGroupBox("External Devices — Manual IP Connection");
    group->setStyleSheet(kGroupStyle);
    auto* grid = new QGridLayout(group);
    grid->setSpacing(6);

    // Column headers
    auto addHeader = [&](int col, const QString& text) {
        auto* lbl = new QLabel(text);
        AetherSDR::ThemeManager::instance().applyStyleSheet(lbl, "QLabel { color: {{color.text.secondary}}; font-size: 11px; font-weight: bold; }");
        grid->addWidget(lbl, 0, col);
    };
    addHeader(0, "Device");
    addHeader(1, "IP Address");
    addHeader(2, "Port");
    addHeader(3, "");
    addHeader(4, "Status");

    auto& settings = AppSettings::instance();

    static const QString kBtnStyle =
        "QPushButton { background: #1a2a3a; border: 1px solid #304050; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; font-weight: bold; "
        "padding: 3px 10px; }"
        "QPushButton:hover { background: #203040; }";

    // Helper to build one peripheral row
    auto buildRow = [&](int row, const QString& label, const QString& ipKey,
                        const QString& portKey, int defaultPort,
                        auto connectFn, auto disconnectFn, auto isConnectedFn,
                        auto peerAddressFn, auto peerPortFn) {
        // Device label
        auto* devLbl = new QLabel(label);
        devLbl->setStyleSheet(kLabelStyle);
        grid->addWidget(devLbl, row, 0);

        // IP field — pre-fill from settings, or from live connection if discovered
        auto* ipEdit = new QLineEdit;
        ipEdit->setPlaceholderText("e.g. 192.168.1.100");
        ipEdit->setStyleSheet(kEditStyle);
        ipEdit->setMinimumWidth(140);
        QString savedIp = settings.value(ipKey, "").toString();
        if (!savedIp.isEmpty()) {
            ipEdit->setText(savedIp);
        } else if (isConnectedFn()) {
            ipEdit->setText(peerAddressFn());
        }
        grid->addWidget(ipEdit, row, 1);

        // Port field — pre-fill from settings, or from live connection
        auto* portSpin = new QSpinBox;
        portSpin->setRange(1, 65535);
        int savedPort = settings.value(portKey, "0").toInt();
        if (savedPort > 0) {
            portSpin->setValue(savedPort);
        } else if (isConnectedFn() && peerPortFn() > 0) {
            portSpin->setValue(peerPortFn());
        } else {
            portSpin->setValue(defaultPort);
        }
        AetherSDR::ThemeManager::instance().applyStyleSheet(portSpin, "QSpinBox { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 12px; padding: 2px; }");
        grid->addWidget(portSpin, row, 2);

        // Status label
        auto* statusLbl = new QLabel(isConnectedFn() ? "Connected" : "Not connected");
        statusLbl->setStyleSheet(isConnectedFn()
            ? "QLabel { color: #00e060; font-size: 11px; }"
            : "QLabel { color: #8aa8c0; font-size: 11px; }");
        grid->addWidget(statusLbl, row, 4);

        // Connect/Disconnect button
        auto* btn = new QPushButton(isConnectedFn() ? "Disconnect" : "Connect");
        btn->setStyleSheet(kBtnStyle);
        grid->addWidget(btn, row, 3);

        connect(btn, &QPushButton::clicked, this,
                [=, &settings]() {
            QString ip = ipEdit->text().trimmed();
            if (isConnectedFn()) {
                // If the user cleared the IP field before clicking, wipe
                // the saved manual IP/port FIRST — the disconnect signal
                // fires synchronously and downstream handlers (e.g. SS
                // button visibility) read these settings to decide
                // whether to keep showing the device. Clearing after the
                // disconnect would leave the button visible.
                if (ip.isEmpty()) {
                    settings.remove(ipKey);
                    settings.remove(portKey);
                    settings.save();
                }
                disconnectFn();
            } else {
                if (ip.isEmpty()) {
                    // Empty IP while disconnected: if a manual IP was saved
                    // previously, treat this click as "save back to default"
                    // — clear the persisted manual IP/port so the device
                    // stops auto-connecting.
                    if (!settings.value(ipKey, "").toString().isEmpty()) {
                        settings.remove(ipKey);
                        settings.remove(portKey);
                        settings.save();
                    }
                    return;
                }
                int port = portSpin->value();
                // Save to settings
                settings.setValue(ipKey, ip);
                settings.setValue(portKey, QString::number(port));
                settings.save();
                connectFn(ip, static_cast<quint16>(port));
            }
        });

        // Update UI on connection state changes
        auto updateState = [btn, statusLbl, isConnectedFn]() {
            bool conn = isConnectedFn();
            btn->setText(conn ? "Disconnect" : "Connect");
            statusLbl->setText(conn ? "Connected" : "Not connected");
            statusLbl->setStyleSheet(conn
                ? "QLabel { color: #00e060; font-size: 11px; }"
                : "QLabel { color: #8aa8c0; font-size: 11px; }");
        };

        // Save-on-close: if the user has cleared the IP field and closes
        // the dialog without clicking Connect/Disconnect, treat that as
        // "wipe the saved manual IP/port". A non-empty edit still requires
        // an explicit Connect click so a partially-typed IP cannot leak in.
        m_peripheralRowSavers.append([ipEdit, ipKey, portKey, &settings,
                                      isConnectedFn, disconnectFn]() {
            if (!ipEdit) return;
            const QString ip = ipEdit->text().trimmed();
            if (!ip.isEmpty()) return;
            const QString savedIp = settings.value(ipKey, "").toString();
            if (savedIp.isEmpty()) return;
            // The user cleared a previously-saved IP. If still connected
            // (e.g. auto-connect ran at startup), disconnect first so
            // downstream visibility handlers see the cleared settings.
            settings.remove(ipKey);
            settings.remove(portKey);
            settings.save();
            if (isConnectedFn()) disconnectFn();
        });

        return updateState;
    };

    // Row 1: Tuner Genius XL (TGXL)
    if (m_tgxl) {
        auto updateTgxl = buildRow(1, "Tuner Genius XL (TGXL)", "TGXL_ManualIp", "TGXL_ManualPort", 9010,
            [this](const QString& ip, quint16 port) { m_tgxl->connectToTgxl(ip, port); },
            [this]() { m_tgxl->disconnect(); },
            [this]() { return m_tgxl->isConnected(); },
            [this]() { return m_tgxl->peerAddress(); },
            [this]() { return m_tgxl->peerPort(); });
        connect(m_tgxl, &TgxlConnection::connected, this, updateTgxl);
        connect(m_tgxl, &TgxlConnection::disconnected, this, updateTgxl);
        // Pre-fill radio-discovered TGXL IP when no saved IP and not connected (#1039)
        auto* tgxlIpEdit = qobject_cast<QLineEdit*>(grid->itemAtPosition(1, 1)->widget());
        if (tgxlIpEdit && tgxlIpEdit->text().isEmpty()) {
            QString discovered = m_model->tunerModel().tgxlIp();
            if (!discovered.isEmpty()) {
                tgxlIpEdit->setText(discovered);
            }
        }
        // Show TCP error reason in status column (#1039)
        auto* tgxlStatus = qobject_cast<QLabel*>(grid->itemAtPosition(1, 4)->widget());
        if (tgxlStatus) {
            connect(m_tgxl, &TgxlConnection::connectionFailed, this,
                    [tgxlStatus](const QString& err) {
                tgxlStatus->setText("Error: " + err);
                tgxlStatus->setStyleSheet("QLabel { color: #e06060; font-size: 11px; }");
            });
        }
    }

    // Row 2: Power Genius XL (PGXL)
    if (m_pgxl) {
        auto updatePgxl = buildRow(2, "Power Genius XL (PGXL)", "PGXL_ManualIp", "PGXL_ManualPort", 9008,
            [this](const QString& ip, quint16 port) { m_pgxl->connectToPgxl(ip, port); },
            [this]() { m_pgxl->disconnect(); },
            [this]() { return m_pgxl->isConnected(); },
            [this]() { return m_pgxl->peerAddress(); },
            [this]() { return m_pgxl->peerPort(); });
        connect(m_pgxl, &PgxlConnection::connected, this, updatePgxl);
        connect(m_pgxl, &PgxlConnection::disconnected, this, updatePgxl);
    }

    // Row 3: Antenna Genius (AG) — hide "Connected" when ShackSwitch is using the model
    if (m_ag) {
        auto isRealAg = [this]() {
            if (!m_ag->isConnected()) return false;
            return !AntennaGeniusModel::isShackSwitch(m_ag->connectedDevice());
        };
        auto updateAg = buildRow(3, "Antenna Genius (AG)", "AG_ManualIp", "AG_ManualPort", 9007,
            [this](const QString& ip, quint16 port) {
                m_ag->connectToAddress(QHostAddress(ip), port);
            },
            [this]() { m_ag->disconnectFromDevice(); },
            isRealAg,
            [this]() { return m_ag->peerAddress(); },
            [this]() { return m_ag->peerPort(); });
        connect(m_ag, &AntennaGeniusModel::connected,    this, updateAg);
        connect(m_ag, &AntennaGeniusModel::disconnected, this, updateAg);
    }

    // Row 4: ShackSwitch — Connect/Disconnect + status (same pattern as AG)
    //         plus a small "⚙ Web UI" button that opens the device's web interface.
    if (m_ag) {
        auto isSsConnected = [this]() {
            return m_ag->isConnected() &&
                   AntennaGeniusModel::isShackSwitch(m_ag->connectedDevice());
        };
        auto updateSs = buildRow(4, "ShackSwitch", "SS_ManualIp", "SS_ControlPort", 9007,
            [this](const QString& ip, quint16 /*port*/) {
                // Always connect on port 9007 (AG control protocol)
                AgDeviceInfo info;
                info.ip     = QHostAddress(ip);
                info.port   = 9007;
                info.serial = QStringLiteral("ShackSwitch-manual");
                info.name   = QStringLiteral("ShackSwitch");
                m_ag->connectToDevice(info);
            },
            [this]() { m_ag->disconnectFromDevice(); },
            isSsConnected,
            [this]() { return m_ag->peerAddress(); },
            []() { return (quint16)9007; });
        connect(m_ag, &AntennaGeniusModel::connected,    this, updateSs);
        connect(m_ag, &AntennaGeniusModel::disconnected, this, updateSs);

        // "⚙ Web UI" button — opens ShackSwitch web interface in a compact app window
        auto* webBtn = new QPushButton("⚙ Web UI");
        webBtn->setStyleSheet(kBtnStyle);
        webBtn->setToolTip("Open ShackSwitch web interface");
        grid->addWidget(webBtn, 4, 5);
        connect(webBtn, &QPushButton::clicked, this, [this]() {
            auto& s = AppSettings::instance();
            QString ip = s.value("SS_ManualIp", "").toString();
            // Only use live address if the connected device is actually the ShackSwitch
            if (ip.isEmpty() && m_ag->isConnected()) {
                if (AntennaGeniusModel::isShackSwitch(m_ag->connectedDevice()))
                    ip = m_ag->peerAddress();
            }
            if (ip.isEmpty()) return;
            // Use beacon webPort only when advertising a valid port (>1024).
            int port = 0;
            if (m_ag->isConnected()) {
                const auto& dev = m_ag->connectedDevice();
                if (AntennaGeniusModel::isShackSwitch(dev) && dev.webPort > 1024)
                    port = dev.webPort;
            }
            if (port <= 1024)
                port = s.value("SS_WebPort", "5000").toInt();
            if (port <= 1024) port = 5000;
            QDesktopServices::openUrl(QUrl("http://" + ip + ":" + QString::number(port) + "/"));
        });
    }

    // Row 5: ACOM S-series amplifier — serial OR ser2net network, unlike the
    // rows above (which are network-only). See
    // docs/architecture/acom-600s-amplifier-design.md for the design note.
    if (m_acom) {
        const int row = 5;
        static const QString kComboStyle =
            "QComboBox { background: #1a2a3a; border: 1px solid #304050; "
            "border-radius: 3px; color: #c8d8e8; font-size: 12px; padding: 2px 4px; }"
            "QComboBox::drop-down { border: none; }";

        auto* devWidget = new QWidget;
        auto* devLay = new QVBoxLayout(devWidget);
        devLay->setContentsMargins(0, 0, 0, 0);
        devLay->setSpacing(2);
        auto* devLbl = new QLabel("ACOM Amplifier");
        devLbl->setStyleSheet(kLabelStyle);
        devLay->addWidget(devLbl);
        auto* modeCombo = new QComboBox;
        modeCombo->setStyleSheet(kComboStyle);
#ifdef HAVE_SERIALPORT
        modeCombo->addItem("Serial", "Serial");
#endif
        modeCombo->addItem("Network", "Network");
        devLay->addWidget(modeCombo);
        grid->addWidget(devWidget, row, 0);

        // Address column: serial-port combo (with "Custom..." fallback, same
        // pattern as the CW/keying Port Configuration group) OR a plain IP
        // field, swapped via a QStackedWidget.
        auto* addrStack = new QStackedWidget;
        int serialPageIdx = -1;
        QComboBox* serialCombo = nullptr;
        QLineEdit* serialCustomEdit = nullptr;
#ifdef HAVE_SERIALPORT
        {
            auto* serialPage = new QWidget;
            auto* lay = new QHBoxLayout(serialPage);
            lay->setContentsMargins(0, 0, 0, 0);
            serialCombo = new QComboBox;
            serialCombo->setStyleSheet(kComboStyle);
            serialCustomEdit = new QLineEdit;
            serialCustomEdit->setPlaceholderText("/dev/ttyUSB0");
            serialCustomEdit->setStyleSheet(kEditStyle);
            const QString savedSerialPort = PeripheralSettings::deviceString("Acom", "SerialPort");
            populateSerialPortCombo(serialCombo, serialCustomEdit, savedSerialPort);
            serialCustomEdit->setVisible(serialCombo->currentData().toString() == "__custom__");
            connect(serialCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
                    [serialCombo, serialCustomEdit](int idx) {
                serialCustomEdit->setVisible(serialCombo->itemData(idx).toString() == "__custom__");
            });
            lay->addWidget(serialCombo, 1);
            lay->addWidget(serialCustomEdit, 1);
            serialPageIdx = addrStack->addWidget(serialPage);
        }
#endif
        auto* netPage = new QWidget;
        auto* netLay = new QHBoxLayout(netPage);
        netLay->setContentsMargins(0, 0, 0, 0);
        auto* netIpEdit = new QLineEdit;
        netIpEdit->setPlaceholderText("ser2net host, raw mode — e.g. 192.168.1.52");
        netIpEdit->setStyleSheet(kEditStyle);
        netIpEdit->setText(PeripheralSettings::deviceString("Acom", "ManualIp"));
        netLay->addWidget(netIpEdit);
        const int netPageIdx = addrStack->addWidget(netPage);
        grid->addWidget(addrStack, row, 1);

        // Port/baud column: fixed "9600 8N1" text for serial (not
        // user-configurable — mandated by the amplifier's own protocol
        // spec) OR a port spin box for network mode.
        auto* portStack = new QStackedWidget;
        int serialBaudIdx = -1;
#ifdef HAVE_SERIALPORT
        {
            auto* fixedLbl = new QLabel("9600 8N1");
            fixedLbl->setStyleSheet("QLabel { color: #8aa8c0; font-size: 11px; }");
            serialBaudIdx = portStack->addWidget(fixedLbl);
        }
#endif
        auto* netPortSpin = new QSpinBox;
        netPortSpin->setRange(1, 65535);
        netPortSpin->setValue(PeripheralSettings::deviceInt("Acom", "ManualPort", 7000));
        AetherSDR::ThemeManager::instance().applyStyleSheet(netPortSpin,
            "QSpinBox { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
            "border-radius: 3px; color: {{color.text.primary}}; font-size: 12px; padding: 2px; }");
        const int netPortIdx = portStack->addWidget(netPortSpin);
        grid->addWidget(portStack, row, 2);

        auto applyMode = [=](const QString& mode) {
#ifdef HAVE_SERIALPORT
            if (mode == "Serial" && serialPageIdx >= 0) {
                addrStack->setCurrentIndex(serialPageIdx);
                portStack->setCurrentIndex(serialBaudIdx);
                return;
            }
#endif
            addrStack->setCurrentIndex(netPageIdx);
            portStack->setCurrentIndex(netPortIdx);
        };
        const QString savedMode = PeripheralSettings::deviceString("Acom", "ConnectionMode",
#ifdef HAVE_SERIALPORT
            "Serial"
#else
            "Network"
#endif
        );
        {
            const int idx = modeCombo->findData(savedMode);
            modeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
        }
        applyMode(modeCombo->currentData().toString());
        connect(modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
                [=](int idx) {
            const QString mode = modeCombo->itemData(idx).toString();
            PeripheralSettings::setDeviceString("Acom", "ConnectionMode", mode);
            applyMode(mode);
        });

        auto* statusLbl = new QLabel(m_acom->isConnected() ? "Connected" : "Not connected");
        statusLbl->setStyleSheet(m_acom->isConnected()
            ? "QLabel { color: #00e060; font-size: 11px; }"
            : "QLabel { color: #8aa8c0; font-size: 11px; }");
        grid->addWidget(statusLbl, row, 4);

        auto* acomBtn = new QPushButton(m_acom->isConnected() ? "Disconnect" : "Connect");
        acomBtn->setStyleSheet(kBtnStyle);
        grid->addWidget(acomBtn, row, 3);

        auto updateAcomState = [this, acomBtn, statusLbl]() {
            const bool conn = m_acom->isConnected();
            acomBtn->setText(conn ? "Disconnect" : "Connect");
            statusLbl->setText(conn ? "Connected" : "Not connected");
            statusLbl->setStyleSheet(conn
                ? "QLabel { color: #00e060; font-size: 11px; }"
                : "QLabel { color: #8aa8c0; font-size: 11px; }");
        };
        connect(m_acom, &AcomConnection::connected, this, updateAcomState);
        connect(m_acom, &AcomConnection::disconnected, this, updateAcomState);
        connect(m_acom, &AcomConnection::connectionFailed, this,
                [statusLbl](const QString& err) {
            statusLbl->setText("Error: " + err);
            statusLbl->setStyleSheet("QLabel { color: #e06060; font-size: 11px; }");
        });

        connect(acomBtn, &QPushButton::clicked, this, [=, this]() {
            if (m_acom->isConnected()) {
                m_acom->disconnect();
                return;
            }
            const QString mode = modeCombo->currentData().toString();
            if (mode == "Network") {
                const QString ip = netIpEdit->text().trimmed();
                if (ip.isEmpty()) return;
                const int port = netPortSpin->value();
                PeripheralSettings::setDeviceString("Acom", "ManualIp", ip);
                PeripheralSettings::setDeviceInt("Acom", "ManualPort", port);
                m_acom->connectNetwork(ip, static_cast<quint16>(port));
            }
#ifdef HAVE_SERIALPORT
            else {
                QString port = serialCombo->currentData().toString();
                if (port == "__custom__")
                    port = serialCustomEdit->text().trimmed();
                if (port.isEmpty()) return;
                PeripheralSettings::setDeviceString("Acom", "SerialPort", port);
                m_acom->connectSerial(port);
            }
#endif
        });

        // Save-on-close: same "user cleared the field and closed the dialog
        // without clicking Connect/Disconnect" handling the network-only
        // rows get via buildRow() above — wipe the saved manual network IP
        // (and its port) so a cleared field does not leave a stale
        // auto-connect target behind. Serial mode has no equivalent free-text
        // field to leak (the combo always resolves to either a discovered
        // port or the explicit "Custom..." edit, which is only committed on
        // a Connect click), so this only covers Acom's ManualIp/ManualPort.
        m_peripheralRowSavers.append([netIpEdit, this]() {
            if (!netIpEdit) return;
            const QString ip = netIpEdit->text().trimmed();
            if (!ip.isEmpty()) return;
            const QString savedIp = PeripheralSettings::deviceString("Acom", "ManualIp");
            if (savedIp.isEmpty()) return;
            PeripheralSettings::clearDeviceField("Acom", "ManualIp");
            PeripheralSettings::clearDeviceField("Acom", "ManualPort");
            // Only disconnect if the live connection is actually the network
            // target being cleared — ACOM, unlike the network-only rows
            // above, may currently be connected over Serial instead, which
            // this saved IP has nothing to do with.
            if (m_acom->isConnected() && m_acom->description().startsWith(savedIp + ":")) {
                m_acom->disconnect();
            }
        });
    }

    for (auto* lbl : group->findChildren<QLabel*>())
        if (lbl->styleSheet().isEmpty()) lbl->setStyleSheet(kLabelStyle);

    vbox->addWidget(group);

    // Auto-reconnect checkbox
    auto* reconnectCheck = new QCheckBox("Auto-reconnect to peripherals on connection drop");
    AetherSDR::ThemeManager::instance().applyStyleSheet(reconnectCheck,
        "QCheckBox { color: {{color.text.primary}}; font-size: 11px; spacing: 8px; }"
        + kCheckBoxIndicator);
    const bool autoReconnect = PeripheralSettings::autoReconnect();
    reconnectCheck->setChecked(autoReconnect);
    connect(reconnectCheck, &QCheckBox::toggled, this, [this](bool on) {
        PeripheralSettings::setAutoReconnect(on);
        // Propagate immediately to live connection objects
        if (m_tgxl) {
            m_tgxl->setAutoReconnect(on);
        }
        if (m_pgxl) {
            m_pgxl->setAutoReconnect(on);
        }
        if (m_ag) {
            m_ag->setAutoReconnect(on);
        }
        if (m_acom) {
            m_acom->setAutoReconnect(on);
        }
    });
    vbox->addWidget(reconnectCheck);

    // Info note
    auto* note = new QLabel(
        "Configure manual IP addresses for peripherals that cannot be discovered via UDP broadcast.\n"
        "This is needed for remote, VPN, and SmartLink connections. "
        "Configured devices auto-connect when the radio connects.");
    note->setWordWrap(true);
    AetherSDR::ThemeManager::instance().applyStyleSheet(note, "QLabel { color: {{color.text.label}}; font-size: 11px; padding: 8px; }");
    vbox->addWidget(note);

    vbox->addStretch();
    return page;
}

void RadioSetupDialog::buildDeferredTab(int index)
{
    auto it = m_deferredBuilders.find(index);
    if (it == m_deferredBuilders.end())
        return;                             // already built or out of range

    QWidget* placeholder = m_pages->widget(index);
    QWidget* content = it.value()();        // run the real builder
    auto* lay = new QVBoxLayout(placeholder);
    lay->setContentsMargins(0, 0, 0, 0);
    // Wrap in a scroll area so tall tabs (Themes, Audio, Filters,
    // Peripherals on small / high-DPI displays) become scrollable instead
    // of forcing the dialog past the screen edge (#3345).
    lay->addWidget(wrapTabInScrollArea(content));
    m_deferredBuilders.erase(it);           // build only once
}

void RadioSetupDialog::selectTab(const QString& tabName)
{
    if (!m_navigation) {
        return;
    }

    static const QHash<QString, QString> kLegacyPageNames = {
        {QStringLiteral("TX"), QStringLiteral("Transmit")},
        {QStringLiteral("RX"), QStringLiteral("Receive")},
        {QStringLiteral("Phone/CW"), QStringLiteral("Phone & CW")},
        {QStringLiteral("XVTR"), QStringLiteral("Transverters")},
        {QStringLiteral("Themes"), QStringLiteral("Appearance & Behavior")},
        {QStringLiteral("QRZ"), QStringLiteral("QRZ & Callsigns")},
        {QStringLiteral("Serial"), QStringLiteral("Serial & Controllers")}
    };
    const QString pageName = kLegacyPageNames.value(tabName, tabName);
    const int index = m_pageIndexes.value(pageName, -1);
    if (QTreeWidgetItem* item = m_pageItems.value(index, nullptr)) {
        m_navigation->setCurrentItem(item);
        m_navigation->scrollToItem(item, QAbstractItemView::PositionAtCenter);
    }
}

void RadioSetupDialog::refreshFlexControlButtonActions()
{
    auto& settings = AppSettings::instance();
    for (auto it = m_flexControlActionCombos.begin();
         it != m_flexControlActionCombos.end(); ++it) {
        auto* combo = it.value();
        if (!combo)
            continue;
        const QString fallback = m_flexControlActionDefaults.value(it.key(), QStringLiteral("None"));
        const QString saved = settings.value(it.key(), fallback).toString();
        const int idx = combo->findText(saved);
        if (idx < 0)
            continue;
        const QSignalBlocker blocker(combo);
        combo->setCurrentIndex(idx);
    }
    if (m_flexControlInvertCheck) {
        const bool inverted =
            settings.value("FlexControlInvertDir", "False").toString() == "True";
        const QSignalBlocker blocker(m_flexControlInvertCheck);
        m_flexControlInvertCheck->setChecked(inverted);
    }
}

void RadioSetupDialog::setFlexControlConnectionStatus(bool connected, const QString& port)
{
    if (m_flexControlStatusLabel) {
        if (connected) {
            const QString displayPort = port.isEmpty()
                ? AppSettings::instance().value("FlexControlPort").toString()
                : port;
            m_flexControlStatusLabel->setText(QString("Connected (%1)").arg(displayPort));
            AetherSDR::ThemeManager::instance().applyStyleSheet(m_flexControlStatusLabel, "QLabel { color: {{color.accent.success}}; font-size: 11px; }");
        } else {
            m_flexControlStatusLabel->setText("Not detected");
            AetherSDR::ThemeManager::instance().applyStyleSheet(m_flexControlStatusLabel, "QLabel { color: {{color.text.label}}; font-size: 11px; }");
        }
    }
    if (m_flexControlCloseButton)
        m_flexControlCloseButton->setEnabled(connected);
    if (m_flexControlDetectButton)
        m_flexControlDetectButton->setEnabled(!connected);
}

// ── UI Enhancements tab ───────────────────────────────────────────────────────

QWidget* RadioSetupDialog::buildUiEnhancementsTab()
{
    static const QString kBtnBase =
        "QPushButton { border: 1px solid #304050; border-radius: 3px; "
        "font-weight: bold; font-size: 13px; min-width: 36px; min-height: 36px; }"
        "QPushButton:hover { border: 2px solid #60a0c0; }";

    auto* page = new QWidget;
    auto* vbox = new QVBoxLayout(page);
    vbox->setSpacing(12);
    vbox->setContentsMargins(16, 16, 16, 16);

    // ── Slice letter display ─────────────────────────────────────────────────
    // Two display modes for slice letters in the GUI (#2606):
    //   "Global"       (default) — letters track the radio's global slice
    //                  index ('A' = slot 0, 'B' = slot 1, ...) so Multi-Flex
    //                  operators can see at a glance which global slots are
    //                  in use.
    //   "RadioIndexed" — use the radio-provided per-client letter (matches
    //                  SmartSDR behaviour) with the global slot id rendered
    //                  as a subscript so slot awareness survives.
    //
    // Pure display change — slice IDs in commands, settings keys, and
    // signal routing remain global throughout.
    {
        auto* letterGrp = new QGroupBox("Slice Letter Display");
        letterGrp->setStyleSheet(kGroupStyle);
        auto* letterLayout = new QVBoxLayout(letterGrp);
        letterLayout->setSpacing(8);

        auto* radioRow = new QHBoxLayout;
        auto* globalRadio = new QRadioButton("Global slot index (A=0, B=1, …)");
        auto* radioIdxRadio =
            new QRadioButton("Radio-assigned letter with global subscript (A₂)");
        AetherSDR::ThemeManager::instance().applyStyleSheet(globalRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }");
        AetherSDR::ThemeManager::instance().applyStyleSheet(radioIdxRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }");
        radioRow->addWidget(globalRadio);
        radioRow->addWidget(radioIdxRadio);
        radioRow->addStretch();
        letterLayout->addLayout(radioRow);

        auto* letterDesc = new QLabel(
            "Choose how slice letters are rendered in badges, faders, and "
            "applet labels. Multi-Flex sessions with multiple clients only: "
            "Radio-assigned letters match SmartSDR's behaviour; the global "
            "subscript preserves which physical slot you're on.");
        letterDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
        letterDesc->setWordWrap(true);
        letterLayout->addWidget(letterDesc);

        auto& s = AppSettings::instance();
        const QString current =
            s.value("SliceLetterDisplay", "Global").toString();
        (current == "RadioIndexed" ? radioIdxRadio : globalRadio)->setChecked(true);

        auto saveLetterMode = [this](const QString& mode) {
            auto& s = AppSettings::instance();
            s.setValue("SliceLetterDisplay", mode);
            s.save();
            // Push a refresh through anything that paints a slice letter
            // — the active slice path's syncFromSlice() pulls.  Cheapest
            // broad-stroke: re-emit currentSliceChanged so the model
            // listeners reapply via their existing slots.
            emit sliceLetterDisplayModeChanged();
        };
        connect(globalRadio, &QRadioButton::toggled, this, [saveLetterMode](bool on) {
            if (on) saveLetterMode("Global");
        });
        connect(radioIdxRadio, &QRadioButton::toggled, this, [saveLetterMode](bool on) {
            if (on) saveLetterMode("RadioIndexed");
        });

        vbox->addWidget(letterGrp);
    }

    // ── Slice color group ────────────────────────────────────────────────────
    auto* grp = new QGroupBox("Slice Colors");
    grp->setStyleSheet(kGroupStyle);
    auto* grpLayout = new QVBoxLayout(grp);
    grpLayout->setSpacing(10);

    auto* modeLayout = new QHBoxLayout;
    auto* defaultsRadio = new QRadioButton("Use Aether defaults");
    auto* customRadio   = new QRadioButton("Custom colors");
    AetherSDR::ThemeManager::instance().applyStyleSheet(defaultsRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }");
    AetherSDR::ThemeManager::instance().applyStyleSheet(customRadio, "QRadioButton { color: {{color.text.primary}}; font-size: 12px; }");
    modeLayout->addWidget(defaultsRadio);
    modeLayout->addWidget(customRadio);
    modeLayout->addStretch();
    grpLayout->addLayout(modeLayout);

    auto* desc = new QLabel(
        "Customize the color used for each slice marker, filter band, and badge.");
    desc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
    desc->setWordWrap(true);
    grpLayout->addWidget(desc);

    // Grid of 8 color buttons (A–H)
    auto* colorGrid = new QHBoxLayout;
    colorGrid->setSpacing(8);

    static const char kLetters[] = "ABCDEFGH";
    // One button per slice; holds the current color as its background.
    QVector<QPushButton*> colorBtns;
    for (int i = 0; i < AetherSDR::kSliceColorCount; ++i) {
        auto* col = new QVBoxLayout;
        col->setSpacing(4);

        auto* lbl = new QLabel(QString(kLetters[i]));
        lbl->setAlignment(Qt::AlignCenter);
        AetherSDR::ThemeManager::instance().applyStyleSheet(lbl, "QLabel { color: {{color.text.secondary}}; font-size: 11px; }");

        auto* btn = new QPushButton(QString(kLetters[i]));
        btn->setStyleSheet(kBtnBase);
        colorBtns.append(btn);

        col->addWidget(lbl);
        col->addWidget(btn);
        colorGrid->addLayout(col);
    }
    colorGrid->addStretch();
    grpLayout->addLayout(colorGrid);

    // Reset-all button
    auto* resetRow = new QHBoxLayout;
    auto* resetBtn = new QPushButton("Reset All to Defaults");
    AetherSDR::ThemeManager::instance().applyStyleSheet(resetBtn, "QPushButton { background: {{color.background.1}}; border: 1px solid {{color.background.2}}; "
        "border-radius: 3px; color: {{color.text.primary}}; font-size: 11px; padding: 3px 12px; }"
        "QPushButton:hover { background: {{color.background.1}}; }");
    resetRow->addWidget(resetBtn);
    resetRow->addStretch();
    grpLayout->addLayout(resetRow);

    vbox->addWidget(grp);
    vbox->addStretch();

    // ── Helpers (capture manager by pointer, buttons by value) ────────────────
    SliceColorManager* pMgr = &SliceColorManager::instance();

    // Updates a single button's background to reflect current color.
    auto applyBtnColor = [pMgr, colorBtns](int idx) mutable {
        QColor c = pMgr->activeColor(idx);
        bool light = (c.red() * 299 + c.green() * 587 + c.blue() * 114) > 128000;
        QString textColor = light ? "#000000" : "#ffffff";
        colorBtns[idx]->setStyleSheet(
            kBtnBase +
            QStringLiteral("QPushButton { background: %1; color: %2; }")
                .arg(c.name(), textColor));
    };

    auto refreshAllBtns = [applyBtnColor]() mutable {
        for (int i = 0; i < AetherSDR::kSliceColorCount; ++i)
            applyBtnColor(i);
    };

    auto syncModeRadios = [defaultsRadio, customRadio, colorBtns](bool custom) mutable {
        defaultsRadio->blockSignals(true);
        customRadio->blockSignals(true);
        defaultsRadio->setChecked(!custom);
        customRadio->setChecked(custom);
        defaultsRadio->blockSignals(false);
        customRadio->blockSignals(false);
        for (QPushButton* b : colorBtns)
            b->setEnabled(custom);
    };

    // ── Initial state ─────────────────────────────────────────────────────────
    syncModeRadios(pMgr->useCustomColors());
    refreshAllBtns();

    // ── Signals ───────────────────────────────────────────────────────────────
    connect(defaultsRadio, &QRadioButton::toggled, page,
            [pMgr, syncModeRadios, refreshAllBtns](bool checked) mutable {
        if (!checked) return;
        pMgr->setUseCustomColors(false);
        syncModeRadios(false);
        refreshAllBtns();
    });

    connect(customRadio, &QRadioButton::toggled, page,
            [pMgr, syncModeRadios, refreshAllBtns](bool checked) mutable {
        if (!checked) return;
        pMgr->setUseCustomColors(true);
        syncModeRadios(true);
        refreshAllBtns();
    });

    for (int i = 0; i < AetherSDR::kSliceColorCount; ++i) {
        connect(colorBtns[i], &QPushButton::clicked, page,
                [i, pMgr, applyBtnColor, page]() mutable {
            QColor initial = pMgr->customColor(i);
            QColor chosen = QColorDialog::getColor(initial, page,
                                                   QStringLiteral("Slice %1 Color")
                                                       .arg(QChar('A' + i)));
            if (!chosen.isValid()) return;
            pMgr->setCustomColor(i, chosen);
            applyBtnColor(i);
        });
    }

    connect(resetBtn, &QPushButton::clicked, page,
            [pMgr, refreshAllBtns]() mutable {
        for (int i = 0; i < AetherSDR::kSliceColorCount; ++i)
            pMgr->resetToDefault(i);
        refreshAllBtns();
    });

    // Sync button backgrounds when another widget changes a color
    connect(pMgr, &SliceColorManager::colorsChanged, page,
            [syncModeRadios, refreshAllBtns, pMgr]() mutable {
        syncModeRadios(pMgr->useCustomColors());
        refreshAllBtns();
    });

    // ── Single-click delay group (#3009) ────────────────────────────────────
    // Several widgets (slice-mute button, RX chain stages, waveform widgets)
    // defer the single-click action by this interval so a double click can
    // override it.  Default is the platform's QApplication::doubleClickInterval
    // (typically 400 ms on Linux, 500 ms on Windows).  Power users who never
    // double-click can set this to 0 for instant single-click response;
    // double-click affordances become unreachable in that case but the
    // mute-all keyboard shortcut still works for the most common use.
    {
        auto* clickGrp = new QGroupBox("Single-click delay");
        clickGrp->setStyleSheet(kGroupStyle);
        auto* clickLayout = new QVBoxLayout(clickGrp);
        clickLayout->setSpacing(8);

        auto* row = new QHBoxLayout;
        row->setSpacing(8);

        auto* clickLabel = new QLabel("Delay (ms):");
        AetherSDR::ThemeManager::instance().applyStyleSheet(clickLabel, "QLabel { color: {{color.text.primary}}; font-size: 12px; }");
        row->addWidget(clickLabel);

        auto& s = AppSettings::instance();
        const int platformDefault = QApplication::doubleClickInterval();
        const int current = s.value("ClickDiscriminationIntervalMs",
                                     platformDefault).toInt();

        auto* clickSpin = new QSpinBox;
        clickSpin->setRange(0, 1000);
        clickSpin->setSingleStep(50);
        clickSpin->setSuffix(" ms");
        clickSpin->setValue(qBound(0, current, 1000));
        clickSpin->setFixedWidth(110);
        AetherSDR::ThemeManager::instance().applyStyleSheet(clickSpin, "QSpinBox { background: {{color.background.1}}; color: {{color.text.primary}}; "
            "border: 1px solid {{color.background.2}}; border-radius: 3px; padding: 2px 4px; }");
        row->addWidget(clickSpin);

        auto* resetBtn = new QPushButton("Reset");
        AetherSDR::ThemeManager::instance().applyStyleSheet(resetBtn, "QPushButton { background: {{color.background.1}}; color: {{color.text.primary}}; "
            "border: 1px solid {{color.background.2}}; border-radius: 3px; padding: 4px 12px; }"
            "QPushButton:hover { border-color: #60a0c0; }");
        resetBtn->setToolTip(QString("Reset to platform default (%1 ms)")
                             .arg(platformDefault));
        row->addWidget(resetBtn);

        row->addStretch();
        clickLayout->addLayout(row);

        auto* clickDesc = new QLabel(QString(
            "Time AetherSDR waits after a single click on a widget that "
            "also has a double-click action (e.g. the slice mute button) "
            "before firing the single-click. The platform default is "
            "%1 ms. Set to 0 to fire single-click actions instantly — "
            "this also disables the double-click affordances on those "
            "widgets.").arg(platformDefault));
        clickDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
        clickDesc->setWordWrap(true);
        clickLayout->addWidget(clickDesc);

        connect(clickSpin, QOverload<int>::of(&QSpinBox::valueChanged),
                this, [](int v) {
            auto& s = AppSettings::instance();
            s.setValue("ClickDiscriminationIntervalMs", QString::number(v));
            s.save();
        });
        connect(resetBtn, &QPushButton::clicked, this,
                [clickSpin, platformDefault]() {
            clickSpin->setValue(platformDefault);
            // valueChanged handler above persists the value.
        });

        vbox->addWidget(clickGrp);
    }

    // ── Mouse wheel group (#3302) ───────────────────────────────────────────
    // Trackball / inverted-scroll users (and parity with Thetis / KE9NS) get
    // a single checkbox that reverses the wheel direction for frequency
    // tuning.  Reading is per-event in the wheel handlers, so this takes
    // effect immediately with no signal plumbing.  The Ctrl+wheel bandwidth
    // zoom on the panadapter is intentionally not reversed.
    {
        auto* wheelGrp = new QGroupBox("Mouse wheel");
        wheelGrp->setStyleSheet(kGroupStyle);
        auto* wheelLayout = new QVBoxLayout(wheelGrp);
        wheelLayout->setSpacing(8);

        auto* reverseChk = new QCheckBox("Reverse mouse-wheel tuning direction");
        AetherSDR::ThemeManager::instance().applyStyleSheet(reverseChk,
            "QCheckBox { color: {{color.text.primary}}; font-size: 12px; spacing: 8px; }"
            + kCheckBoxIndicator);
        {
            auto& s = AppSettings::instance();
            reverseChk->setChecked(s.value("ReverseMouseWheel", false).toBool());
        }
        wheelLayout->addWidget(reverseChk);

        auto* wheelDesc = new QLabel(
            "When enabled, scrolling the wheel up tunes the frequency down "
            "(and vice versa). Useful for trackballs and any pointer where "
            "the natural scroll direction feels inverted. Affects the VFO "
            "frequency display and the panadapter / waterfall; the "
            "Ctrl+wheel bandwidth zoom is not reversed.");
        wheelDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
        wheelDesc->setWordWrap(true);
        wheelLayout->addWidget(wheelDesc);

        connect(reverseChk, &QCheckBox::toggled, this, [](bool on) {
            auto& s = AppSettings::instance();
            s.setValue("ReverseMouseWheel", on);
            s.save();
        });

        vbox->addWidget(wheelGrp);
    }

    return page;
}

QWidget* RadioSetupDialog::buildSmartLinkTab()
{
    // Phase 2 of GHSA-wfx7-w6p8-4jr2 (#2951): Pinned Certificates panel.
    // Lists every SmartLink host this client has TOFU-pinned a cert
    // fingerprint for, when it was pinned, and lets the operator
    // forget individual pins or clear the whole cache. Used when the
    // operator deliberately rotates a radio's cert (firmware update,
    // hardware replacement) and wants the next connect to re-pin
    // silently instead of triggering the mismatch dialog.

    auto* page = new QWidget;
    auto* root = new QVBoxLayout(page);
    root->setContentsMargins(16, 16, 16, 16);
    root->setSpacing(12);

    auto* grp = new QGroupBox("Pinned SmartLink Certificates");
    grp->setStyleSheet(kGroupStyle);
    auto* grpLay = new QVBoxLayout(grp);
    grpLay->setSpacing(8);

    auto* desc = new QLabel(
        "AetherSDR pins each SmartLink radio's TLS certificate the first "
        "time you connect (trust-on-first-use). Subsequent connects "
        "compare against the pin; a mismatch shows a warning dialog and "
        "pauses the connection until you accept or reject it.\n\n"
        "Forget a pin when you deliberately change a radio's certificate "
        "(firmware update, hardware replacement) so the next connect can "
        "re-pin silently. See GHSA-wfx7-w6p8-4jr2 for the threat model.");
    desc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
    desc->setWordWrap(true);
    grpLay->addWidget(desc);

    auto* table = new QTableWidget(0, 3, grp);
    m_pinnedCertsTable = table;
    table->setHorizontalHeaderLabels({"Host", "SHA-256 fingerprint", "Pinned"});
    table->horizontalHeader()->setStretchLastSection(false);
    table->horizontalHeader()->setSectionResizeMode(
        0, QHeaderView::ResizeToContents);
    table->horizontalHeader()->setSectionResizeMode(
        1, QHeaderView::Stretch);
    table->horizontalHeader()->setSectionResizeMode(
        2, QHeaderView::ResizeToContents);
    table->verticalHeader()->setVisible(false);
    table->setEditTriggers(QAbstractItemView::NoEditTriggers);
    table->setSelectionBehavior(QAbstractItemView::SelectRows);
    AetherSDR::ThemeManager::instance().applyStyleSheet(table, "QTableWidget { background: {{color.background.0}}; color: {{color.text.primary}};"
        " gridline-color: {{color.background.1}}; }"
        "QHeaderView::section { background: {{color.background.1}}; color: #b0c4d6;"
        " padding: 4px; border: none; }");
    grpLay->addWidget(table);

    auto* btnRow = new QHBoxLayout;
    btnRow->setSpacing(8);
    auto* forgetSel = new QPushButton("Forget selected");
    auto* forgetAll = new QPushButton("Forget all");
    static const QString kPinnedBtnStyle =
        "QPushButton { background: #1a2230; border: 1px solid #2a3744;"
        " border-radius: 3px; color: #c8d8e8; font-size: 11px;"
        " padding: 4px 12px; }"
        "QPushButton:hover { background: #243044; color: #e8e8e8; }"
        "QPushButton:pressed { background: #2a3a52; }";
    forgetSel->setStyleSheet(kPinnedBtnStyle);
    forgetAll->setStyleSheet(kPinnedBtnStyle);
    btnRow->addWidget(forgetSel);
    btnRow->addStretch();
    btnRow->addWidget(forgetAll);
    grpLay->addLayout(btnRow);

    connect(forgetSel, &QPushButton::clicked, this, [this, table]() {
        const int row = table->currentRow();
        if (row < 0) return;
        auto* item = table->item(row, 0);
        if (!item) return;
        const QString host = item->text();
        WanCertCache::forgetPinnedCert(host);
        refreshPinnedCertsTable();
    });

    connect(forgetAll, &QPushButton::clicked, this, [this]() {
        if (QMessageBox::question(this, tr("Forget all SmartLink certificates"),
                tr("Clear every pinned SmartLink cert fingerprint?\n\n"
                   "Next connect to each radio will silently re-pin "
                   "whatever certificate it presents (no mismatch warning)."))
            != QMessageBox::Yes) {
            return;
        }
        WanCertCache::forgetAllPinnedCerts();
        refreshPinnedCertsTable();
    });

    root->addWidget(grp);
    root->addStretch();

    refreshPinnedCertsTable();
    return page;
}

void RadioSetupDialog::refreshPinnedCertsTable()
{
    if (!m_pinnedCertsTable) return;
    const QVector<PinnedCertInfo> pins = WanCertCache::listPinnedCerts();
    m_pinnedCertsTable->setRowCount(pins.size());
    for (int i = 0; i < pins.size(); ++i) {
        const PinnedCertInfo& pin = pins.at(i);
        m_pinnedCertsTable->setItem(i, 0, new QTableWidgetItem(pin.host));
        // Use a monospace cell for the fingerprint so the hex aligns.
        auto* fpItem = new QTableWidgetItem(pin.fingerprintHex);
        QFont mono("monospace");
        fpItem->setFont(mono);
        fpItem->setToolTip(pin.fingerprintHex);
        m_pinnedCertsTable->setItem(i, 1, fpItem);
        const QString when = pin.pinnedAtIso.isEmpty()
            ? QStringLiteral("(pre-phase 2)")
            : pin.pinnedAtIso.left(10);   // YYYY-MM-DD
        m_pinnedCertsTable->setItem(i, 2, new QTableWidgetItem(when));
    }
}

QWidget* RadioSetupDialog::buildQrzTab()
{
    auto* page = new QWidget;
    auto* root = new QVBoxLayout(page);
    root->setContentsMargins(16, 16, 16, 16);
    root->setSpacing(12);

    auto& svc = CallsignLookupService::instance();

    // ---- Account group -------------------------------------------------
    auto* acct = new QGroupBox("QRZ.com Account");
    acct->setStyleSheet(kGroupStyle);
    auto* acctLay = new QVBoxLayout(acct);
    acctLay->setSpacing(8);

    auto* desc = new QLabel(
        "AetherSDR uses your QRZ.com account to look up station details — "
        "name, location, grid, and photo — for callsigns heard in the CW "
        "decoder and entered in View → Callsign Lookup. An XML Logbook Data "
        "subscription returns full details; a free account returns limited "
        "fields. Your password is stored in the operating system keychain, "
        "never in the settings file.");
    desc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
    desc->setWordWrap(true);
    acctLay->addWidget(desc);

    auto* enableCheck = new QCheckBox("Enable QRZ callsign lookups");
    enableCheck->setObjectName("qrzEnableCheck");
    enableCheck->setAccessibleName("Enable QRZ callsign lookups");
    enableCheck->setStyleSheet("QCheckBox { color: #c8d8e8; font-size: 12px; }");
    enableCheck->setChecked(QrzLookupSettings::enabled());
    connect(enableCheck, &QCheckBox::toggled, this, [](bool on) {
        QrzLookupSettings::setEnabled(on);
        CallsignLookupService::instance().reloadConfiguration();
    });
    acctLay->addWidget(enableCheck);

    auto* grid = new QGridLayout;
    grid->setSpacing(8);

    auto* userLbl = new QLabel("Username (callsign):");
    userLbl->setStyleSheet(kLabelStyle);
    grid->addWidget(userLbl, 0, 0);
    auto* userEdit = new QLineEdit(QrzLookupSettings::username());
    userEdit->setObjectName("qrzUsernameEdit");
    userEdit->setAccessibleName("QRZ username");
    userEdit->setStyleSheet(kEditStyle);
    userEdit->setMaxLength(64);
    grid->addWidget(userEdit, 0, 1);

    auto* passLbl = new QLabel("Password:");
    passLbl->setStyleSheet(kLabelStyle);
    grid->addWidget(passLbl, 1, 0);
    auto* passEdit = new QLineEdit;
    passEdit->setObjectName("qrzPasswordEdit");
    passEdit->setAccessibleName("QRZ password");
    passEdit->setStyleSheet(kEditStyle);
    passEdit->setEchoMode(QLineEdit::Password);
    passEdit->setMaxLength(128);
    grid->addWidget(passEdit, 1, 1);
    grid->setColumnStretch(1, 1);
    acctLay->addLayout(grid);

    // Populate the password field from the keychain (async).
    QPointer<QLineEdit> passGuard(passEdit);
    auto passLoaded = std::make_shared<bool>(false);
    svc.readPassword([passGuard, passLoaded](const QString& pw) {
        *passLoaded = true;
        if (passGuard && passGuard->text().isEmpty())
            passGuard->setText(pw);
    });

    connect(userEdit, &QLineEdit::editingFinished, this, [userEdit] {
        QrzLookupSettings::setUsername(userEdit->text().trimmed());
        CallsignLookupService::instance().reloadConfiguration();
    });
    connect(passEdit, &QLineEdit::editingFinished, this, [passEdit, passLoaded] {
        // Don't let a focus-out before the async keychain read completes delete
        // a stored password: skip the save when the field is empty and the read
        // hasn't landed yet (savePassword("") deletes the keychain entry). (#3990)
        if (!*passLoaded && passEdit->text().isEmpty())
            return;
        CallsignLookupService::instance().savePassword(passEdit->text());
    });

    auto* testRow = new QHBoxLayout;
    testRow->setSpacing(8);
    auto* testBtn = new QPushButton("Test Login");
    testBtn->setObjectName("qrzTestLoginBtn");
    testBtn->setAccessibleName("Test QRZ login");
    testBtn->setStyleSheet(
        "QPushButton { background: #183548; border: 1px solid #28506a; "
        "border-radius: 3px; color: #c8d8e8; font-size: 11px; padding: 4px 12px; }"
        "QPushButton:hover { background: #1f4258; }"
        "QPushButton:disabled { color: #506070; }");
    testRow->addWidget(testBtn);
    auto* testStatus = new QLabel;
    testStatus->setObjectName("qrzTestStatus");
    testStatus->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
    testStatus->setWordWrap(true);
    testRow->addWidget(testStatus, 1);
    acctLay->addLayout(testRow);

    connect(testBtn, &QPushButton::clicked, this,
            [testBtn, testStatus, userEdit, passEdit] {
        const QString user = userEdit->text().trimmed();
        const QString pass = passEdit->text();
        if (user.isEmpty() || pass.isEmpty()) {
            testStatus->setText("Enter a username and password first.");
            return;
        }
        testBtn->setEnabled(false);
        testStatus->setText("Contacting QRZ.com…");
        CallsignLookupService::instance().testLogin(user, pass);
    });
    connect(&svc, &CallsignLookupService::loginTestFinished, this,
            [testBtn, testStatus](bool ok, const QString& message) {
        testBtn->setEnabled(true);
        testStatus->setText(ok
            ? (message.isEmpty() ? QStringLiteral("Login OK.")
                                 : QStringLiteral("Login OK — %1").arg(message))
            : QStringLiteral("Login failed: %1").arg(message));
        testStatus->setStyleSheet(ok
            ? "QLabel { color: #4dd87a; font-size: 11px; }"
            : "QLabel { color: #ff6060; font-size: 11px; }");
    });

    root->addWidget(acct);

    // ---- Cache group ----------------------------------------------------
    auto* cache = new QGroupBox("Lookup Cache");
    cache->setStyleSheet(kGroupStyle);
    auto* cacheLay = new QVBoxLayout(cache);
    cacheLay->setSpacing(8);

    auto* cacheDesc = new QLabel(
        "Looked-up callsigns are cached for 7 days so a busy net never asks "
        "QRZ twice for the same station. Station photos are cached alongside.");
    cacheDesc->setStyleSheet("QLabel { color: #7090a0; font-size: 11px; }");
    cacheDesc->setWordWrap(true);
    cacheLay->addWidget(cacheDesc);

    auto* cacheRow = new QHBoxLayout;
    cacheRow->setSpacing(8);
    auto* cacheCount = new QLabel;
    cacheCount->setObjectName("qrzCacheCount");
    cacheCount->setStyleSheet(kValueStyle);
    auto refreshCacheCount = [cacheCount] {
        cacheCount->setText(QStringLiteral("%1 cached callsign(s)")
            .arg(CallsignLookupService::instance().cacheEntryCount()));
    };
    refreshCacheCount();
    cacheRow->addWidget(cacheCount);
    cacheRow->addStretch();

    auto* clearBtn = new QPushButton("Clear Cache");
    clearBtn->setObjectName("qrzClearCacheBtn");
    clearBtn->setAccessibleName("Clear QRZ lookup cache");
    clearBtn->setStyleSheet(testBtn->styleSheet());
    connect(clearBtn, &QPushButton::clicked, this, [refreshCacheCount] {
        CallsignLookupService::instance().clearCache();
        refreshCacheCount();
    });
    cacheRow->addWidget(clearBtn);
    cacheLay->addLayout(cacheRow);

    root->addWidget(cache);
    root->addStretch();
    return page;
}

} // namespace AetherSDR
