//---------------------------------------------------------- MainWindow
#include "mainwindow.h"
#include <QAction>
#include <QActionGroup>
#include <QApplication>
#include <QByteArrayView>
#include <QCursor>
#include <QDesktopServices>
#include <QDir>
#include <QElapsedTimer>
#include <QFileDialog>
#include <QHostInfo>
#include <QInputDialog>
#include <QLineEdit>
#include <QLoggingCategory>
#include <QMdiSubWindow>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPixmap>
#include <QProgressDialog>
#include <QRegularExpression>
#include <QRegularExpressionValidator>
#include <QScrollBar>
#include <QSoundEffect>
#include <QStandardPaths>
#include <QStringBuilder>
#include <QTime>
#include <QTimeZone>
#include <QToolTip>
#include <QUdpSocket>
#include <QUrl>
#include <QVariant>
#include <QVector>
#include <QVersionNumber>
#include <QtConcurrent/QtConcurrentRun>
#include <algorithm>
#include <array>
#include <boost/crc.hpp>
#include <cmath>
#include <complex>
#include <cstring>
#include <fftw3.h>
#include <functional>
#include <iterator>
#include <mutex>
#include <stdexcept>
#include <string_view>
#include <vector>

#include "JS8_Audio/NotificationAudio.h"
#include "JS8_Audio/soundin.h"
#include "JS8_Audio/soundout.h"
#include "JS8_Include/EventFilter.h"
#include "JS8_Main/Bands.h"
#include "JS8_Main/DriftingDateTime.h"
#include "JS8_Main/Geodesic.h"
#include "JS8_Main/HelpTextWindow.h"
#include "JS8_Main/Inbox.h"
#include "JS8_Main/MessageClient.h"
#include "JS8_Main/MultiSettings.h"
#include "JS8_Main/Radio.h"
#include "JS8_Main/SelfDestructMessageBox.h"
#include "JS8_Main/SignalMeter.h"
#include "JS8_Main/StationList.h"
#include "JS8_Main/qt_helpers.h"
#include "JS8_Main/revision_utils.h"
#include "JS8_Mode/Decoder.h"
#include "JS8_Mode/Detector.h"
#include "JS8_Mode/JS8Submode.h"
#include "JS8_Mode/Modulator.h"
#include "JS8_Mode/decodedtext.h"
#include "JS8_Network/NetworkAccessManager.h"
#include "JS8_Transceiver/TransceiverFactory.h"
#include "JS8_UDP/WSJTXMessageClient.h"
#include "JS8_UDP/WSJTXMessageMapper.h"
#include "JS8_UI/about.h"
#include "JS8_UI/widegraph.h"
#include "JS8_jsc/jsc_checker.h"
#include "logqso.h"
#include "messagereplydialog.h"
#include "messagewindow.h"

#include "moc_mainwindow.cpp"
#include "ui_mainwindow.h"

Q_DECLARE_LOGGING_CATEGORY(mainwindow_js8)

// TODO: Move to member:
static char message[29];
static char msgsent[29];
static int msgibits;

// How often to poll the UI, in MS.
// Some things may depend on this being a divisor of 1000.
constexpr quint32 UI_POLL_INTERVAL_MS = 100;

// How many milliseconds to wait before releasing PTT at end of transmission.
constexpr int TX_SWITCHOFF_DELAY = 200;

int volatile itone[JS8_NUM_SYMBOLS]; // Audio tones for all Tx symbols
struct dec_data dec_data;            // for sharing with Fortran
struct specData specData;            // Used by plotter
std::mutex fftw_mutex;

namespace {
namespace Default {
constexpr Radio::Frequency DIAL_FREQUENCY = 14078000;
constexpr auto FREQUENCY = 1500;
constexpr auto SUBMODE = Varicode::JS8CallNormal;
} // namespace Default

namespace State {
constexpr auto RX = 1;
constexpr auto TX = 2;
} // namespace State

int ms_minute_error() {
    auto const now = DriftingDateTime::currentDateTimeLocal();
    auto const time = now.time();
    auto const second = time.second();

    return now.msecsTo(now.addSecs(second > 30 ? 60 - second : -second)) -
           time.msec();
}

QString since(QDateTime const &time) {
    auto const delta = time.secsTo(DriftingDateTime::currentDateTimeUtc());

    if (delta >= 60 * 60 * 24)
        return QString("%1d").arg(delta / (60 * 60 * 24));
    else if (delta >= 60 * 60)
        return QString("%1h").arg(delta / (60 * 60));
    else if (delta >= 60)
        return QString("%1m").arg(delta / (60));
    else if (delta >= 15)
        return QString("%1s").arg(delta - (delta % 15));
    else
        return QString("now");
}

namespace State {
constexpr QStringView Ready = u"Ready";
constexpr QStringView Send = u"Send";
constexpr QStringView Sending = u"Sending";
constexpr QStringView Tuning = u"Tuning";

QString timed(QStringView const state, int const delay) {
    auto time = std::div(delay, 60);

    if (time.quot && time.rem)
        return QString("%1 (%2m %3s)").arg(state).arg(time.quot).arg(time.rem);
    else if (time.quot)
        return QString("%1 (%2m)").arg(state).arg(time.quot);
    else
        return QString("%1 (%2s)").arg(state).arg(time.rem);
}
} // namespace State

#if 0
  int round(int numToRound, int multiple)
  {
   if(multiple == 0)
   {
    return numToRound;
   }

   int roundDown = ( (int) (numToRound) / multiple) * multiple;

   if(numToRound - roundDown > multiple/2){
    return roundDown + multiple;
   }

   return roundDown;
  }
#endif

int roundUp(int numToRound, int multiple) {
    if (multiple == 0) {
        return numToRound;
    }

    int roundDown = (numToRound / multiple) * multiple;
    return roundDown + multiple;
}

auto replaceMacros(QString const &text, QMap<QString, QString> const &values,
                   bool const prune) {
    QString output = text;

    for (auto const [key, value] : values.asKeyValueRange()) {
        output = output.replace(key, value.toUpper());
    }

    return prune ? output.replace(QRegularExpression("[<](?:[^>]+)[>]"), "")
                 : output;
}

// Copy at most size bytes into the array, filling any unused size
// with spaces if less than size bytes were available to copy. For
// convenience, return a one past the end iterator, i.e., equal to
// array + size.

auto copyByteData(QByteArrayView const bytes, char *const array,
                  qsizetype const size) {
    return std::fill_n(
        std::copy_n(bytes.begin(), std::min(size, bytes.size()), array),
        size - bytes.size(), ' ');
}

// Copy at most size bytes into the array, padding out the message
// with spaces if less than size bytes were available to copy, and
// null-terminate it. Caller is responsible for ensuring that at
// least (size + 1) bytes of space are available.

void copyMessage(QStringView const string, char *const array,
                 qsizetype const size = 28) {
    *copyByteData(string.toLocal8Bit(), array, size) = '\0';
}

// Emulation of the Fortran 'flat1' subroutine.

void flat1(float const *const savg, int const iz, int const nsmo,
           float *const slin) {
    constexpr int x_size = 8192;
    constexpr int nstep = 20;
    constexpr int nh = nstep / 2;

    // Define bounds for smoothing
    int const ia = nsmo / 2 + 1;
    int const ib = iz - nsmo / 2 - 1;

    std::vector<float> x(x_size, 0.0f);

    // Smooth savg using median percentiles

    auto const rank =
        std::clamp(static_cast<int>(std::round(0.5f * nsmo)), 0, nsmo - 1);

    for (int i = ia; i <= ib; i += nstep) {
        auto const data = &savg[i - nsmo / 2];
        auto temp = std::vector<float>(data, data + nsmo);

        std::nth_element(temp.begin(), temp.begin() + rank, temp.end());

        x[i] = temp[rank];

        std::fill(x.begin() + (i - nh), x.begin() + (i + nh), x[i]);
    }

    // Extend smoothed values to boundaries
    std::fill(x.begin(), x.begin() + ia, x[ia]);
    std::fill(x.begin() + ib + 1, x.begin() + iz, x[ib]);

    // Compute scaling factor
    float x0 = 0.001f * *std::max_element(x.begin() + iz / 10,
                                          x.begin() + (9 * iz) / 10);

    // Normalize savg to compute slin
    for (int i = 0; i < iz; ++i)
        slin[i] = savg[i] / (x[i] + x0);
}

// Emulation of the Fortran 'smo' subroutine. However, doesn't copy the data
// back from b to a; rather, a is input and, b is output. Since we invariably
// call this twice, we can just swap the order of the arrays to achieve the
// same result without the extra two copy operations.

void smo(float const *const a, float *const b, int const npts, int const nadd) {
    auto const nh = nadd / 2;

    // Smooth the array
    for (int i = nh; i < npts - nh; ++i) {
        float sum = 0.0f;
        for (int j = -nh; j <= nh; ++j) {
            sum += a[i + j];
        }
        b[i] = sum;
    }

    // Set edges to zero
    for (int i = 0; i < nh; ++i)
        b[i] = 0.0f; // Zero out leading edge
    for (int i = npts - nh; i < npts; ++i)
        b[i] = 0.0f; // Zero out trailing edge
}
} // namespace

//--------------------------------------------------- MainWindow constructor
MainWindow::MainWindow(QString const &program_info, QDir const &temp_directory,
                       bool const multiple, MultiSettings *multi_settings,
                       QWidget *parent)
    : QMainWindow(parent), m_stopTxButtonIsLongterm{true},
      m_hbButtonIsLongterm{true}, m_cqButtonIsLongterm{true},
      m_network_manager{this}, m_valid{true}, m_multiple{multiple},
      m_multi_settings{multi_settings}, m_configurations_button{0},
      m_settings{multi_settings->settings()}, m_settings_read{false},
      ui(new Ui::MainWindow), m_config{temp_directory, m_settings, this},
      m_rigErrorMessageBox{JS8MessageBox::Critical, tr("Rig Control Error"),
                           JS8MessageBox::Cancel | JS8MessageBox::Ok |
                               JS8MessageBox::Retry},
      m_wideGraph(new WideGraph(m_settings)),
      // no parent so that it has a taskbar icon
      m_logDlg(new LogQSO(program_title(), m_settings, &m_config, nullptr)),
      m_lastDialFreq{0},
      m_detector{new Detector{JS8_RX_SAMPLE_RATE, JS8_NTMAX}},
      m_FFTSize{6912 / 2}, // conservative value to avoid buffer overruns
      m_soundInput{new SoundInput}, m_modulator{new Modulator},
      m_soundOutput{new SoundOutput}, m_notification{new NotificationAudio},
      m_cq_loop{new TxLoop{"CQ calls"}}, m_hb_loop{new TxLoop{"HB calls"}},
      m_decoder{this}, m_secBandChanged{0}, m_freqNominal{0},
      m_freqTxNominal{0}, m_XIT{0}, m_sec0{-1},
      m_RxLog{1}, // Write Date and Time to RxLog
      m_nutc0{999999}, m_TRperiod{60}, m_inGain{0}, m_idleMinutes{0},
      m_nSubMode{Default::SUBMODE},
      m_frequency_list_fcal_iter{m_config.frequencies()->begin()}, m_i3bit{0},
      m_btxok{false}, m_auto{false}, m_restart{false}, m_currentMessageType{-1},
      m_lastMessageType{-1}, m_tuneup{false}, m_isTimeToSend{false}, m_ihsym{0},
      m_px{0.0}, m_iptt0{0}, m_btxok0{false}, m_onAirFreq0{0.0},
      m_first_error{true}, tx_status_label{"Receiving"},
      m_appDir{QApplication::applicationDirPath()}, m_palette{"Linrad"},
      m_txFrameCountEstimate{0}, m_txFrameCount{0}, m_txFrameCountSent{0},
      m_txTextDirty{false}, m_driftMsMMA{0}, m_driftMsMMA_N{0},
      m_previousFreq{0}, m_hbInterval{0}, m_cqInterval{0}, m_hbPaused{false},
      m_msAudioOutputBuffered(0u),
      m_framesAudioInputBuffered(JS8_RX_SAMPLE_RATE / 10),
      m_audioThreadPriority(QThread::HighPriority),
      m_notificationAudioThreadPriority(QThread::LowPriority),
      m_decoderThreadPriority(QThread::HighPriority), m_splitMode{false},
      m_monitoring{false}, m_generateAudioWhenPttConfirmedByTX{false},
      m_transmitting{false}, m_tune{false}, m_tx_watchdog{false},
      m_block_pwr_tooltip{false}, m_PwrBandSetOK{true},
      m_lastMonitoredFrequency{Default::DIAL_FREQUENCY},
      m_messageClient{new MessageClient{m_config.udp_server_name(),
                                        m_config.udp_server_port(), this}},
      m_messageServer{new MessageServer()}, m_wsjtxMessageClient{nullptr},
      m_wsjtxMessageMapper{nullptr}, m_n3fjpClient{new TCPClient{this}},
      m_pskReporter{new PSKReporter{&m_config, program_info}}, // UR
      m_spotClient{new SpotClient{"spot.js8call.com", 50000, program_info}},
      m_aprsClient{new APRSISClient{"rotate.aprs2.net", 14580}},
      m_manual{&m_network_manager} {
    ui->setupUi(this);

    createStatusBar();
    add_child_to_event_filter(this);

    m_baseCall = Radio::base_callsign(m_config.my_callsign());
    m_opCall = m_config.opCall();

    // Closedown.
    connect(ui->actionExit, &QAction::triggered, this, &QMainWindow::close);

    // parts of the rig error message box that are fixed
    m_rigErrorMessageBox.setInformativeText(
        tr("Do you want to reconfigure the radio interface?"));
    m_rigErrorMessageBox.setDefaultButton(JS8MessageBox::Ok);

    // start audio thread and hook up slots & signals for shutdown management
    // these objects need to be in the audio thread so that invoking
    // their slots is done in a thread safe way
    m_soundOutput->moveToThread(&m_audioThread);
    m_modulator->moveToThread(&m_audioThread);
    m_soundInput->moveToThread(&m_audioThread);
    m_detector->moveToThread(&m_audioThread);

    // notification audio operates in its own thread at a lower priority
    m_notification->moveToThread(&m_notificationAudioThread);

    // Move the aprs client message server, psk reporter, and spot client
    // to the network thread at a lower priority.

    m_aprsClient->moveToThread(&m_networkThread);
    m_messageServer->moveToThread(&m_networkThread);
    m_pskReporter->moveToThread(&m_networkThread);
    m_spotClient->moveToThread(&m_networkThread);

    // hook up the message server slots and signals and disposal
    connect(m_messageServer, &MessageServer::message, this,
            &MainWindow::tcpNetworkMessage);
    connect(this, &MainWindow::apiSetMaxConnections, m_messageServer,
            &MessageServer::setMaxConnections);
    connect(this, &MainWindow::apiSetServer, m_messageServer,
            &MessageServer::setServer);
    connect(this, &MainWindow::apiStartServer, m_messageServer,
            &MessageServer::start);
    connect(this, &MainWindow::apiStopServer, m_messageServer,
            &MessageServer::stop);
    connect(&m_config, &Configuration::tcp_server_changed, m_messageServer,
            &MessageServer::setServerHost);
    connect(&m_config, &Configuration::tcp_server_port_changed, m_messageServer,
            &MessageServer::setServerPort);
    connect(&m_config, &Configuration::tcp_max_connections_changed,
            m_messageServer, &MessageServer::setMaxConnections);
    connect(&m_networkThread, &QThread::finished, m_messageServer,
            &QObject::deleteLater);

    // hook up the aprs client slots and signals and disposal
    connect(this, &MainWindow::aprsClientEnqueueSpot, m_aprsClient,
            &APRSISClient::enqueueSpot);
    connect(this, &MainWindow::aprsClientEnqueueThirdParty, m_aprsClient,
            &APRSISClient::enqueueThirdParty);
    connect(this, &MainWindow::aprsClientSendReports, m_aprsClient,
            &APRSISClient::sendReports);
    connect(this, &MainWindow::aprsClientSetLocalStation, m_aprsClient,
            &APRSISClient::setLocalStation);
    connect(this, &MainWindow::aprsClientSetPaused, m_aprsClient,
            &APRSISClient::setPaused);
    connect(this, &MainWindow::aprsClientSetServer, m_aprsClient,
            &APRSISClient::setServer);
    connect(this, &MainWindow::aprsClientSetSkipPercent, m_aprsClient,
            &APRSISClient::setSkipPercent);
    connect(&m_networkThread, &QThread::finished, m_aprsClient,
            &QObject::deleteLater);

    // hook up the psk reporter slots and signals and disposal
    connect(m_pskReporter, &PSKReporter::errorOccurred, this,
            &MainWindow::pskReporterError);
    connect(this, &MainWindow::pskReporterSendReport, m_pskReporter,
            &PSKReporter::sendReport);
    connect(this, &MainWindow::pskReporterAddRemoteStation, m_pskReporter,
            &PSKReporter::addRemoteStation);
    connect(this, &MainWindow::pskReporterSetLocalStation, m_pskReporter,
            &PSKReporter::setLocalStation);
    connect(&m_networkThread, &QThread::started, m_pskReporter,
            &PSKReporter::start);
    connect(&m_networkThread, &QThread::finished, m_pskReporter,
            &QObject::deleteLater);

    // hook up the spot client signals and disposal
    connect(this, &MainWindow::spotClientEnqueueCmd, m_spotClient,
            &SpotClient::enqueueCmd);
    connect(this, &MainWindow::spotClientEnqueueSpot, m_spotClient,
            &SpotClient::enqueueSpot);
    connect(this, &MainWindow::spotClientSetLocalStation, m_spotClient,
            &SpotClient::setLocalStation);
    connect(&m_networkThread, &QThread::started, m_spotClient,
            &SpotClient::start);
    connect(&m_networkThread, &QThread::finished, m_spotClient,
            &QObject::deleteLater);

    // hook up sound output stream slots & signals and disposal
    connect(this, &MainWindow::initializeAudioOutputStream, m_soundOutput,
            &SoundOutput::setFormat);
    connect(m_soundOutput, &SoundOutput::error, this,
            &MainWindow::showSoundOutError);
    connect(m_soundOutput, &SoundOutput::error, &m_config,
            &Configuration::invalidate_audio_output_device);
    connect(this, &MainWindow::outAttenuationChanged, m_soundOutput,
            &SoundOutput::setAttenuation);
    connect(&m_audioThread, &QThread::finished, m_soundOutput,
            &QObject::deleteLater);

    connect(this, &MainWindow::initializeNotificationAudioOutputStream,
            m_notification, &NotificationAudio::setDevice);
    connect(&m_config, &Configuration::test_notify, this,
            &MainWindow::tryNotify);
    connect(this, &MainWindow::playNotification, m_notification,
            &NotificationAudio::play);
    connect(&m_notificationAudioThread, &QThread::finished, m_notification,
            &QObject::deleteLater);

    // hook up Modulator slots and disposal
    connect(this, &MainWindow::transmitFrequency, m_modulator,
            &Modulator::setAudioFrequency);
    connect(this, &MainWindow::endTransmitMessage, m_modulator,
            &Modulator::stop);
    connect(this, &MainWindow::tune, m_modulator, &Modulator::tune);
    connect(this, &MainWindow::sendMessage, m_modulator, &Modulator::start);
    connect(&m_audioThread, &QThread::finished, m_modulator,
            &QObject::deleteLater);

    // hook up the audio input stream signals, slots and disposal
    connect(this, &MainWindow::startAudioInputStream, m_soundInput,
            &SoundInput::start);
    connect(this, &MainWindow::suspendAudioInputStream, m_soundInput,
            &SoundInput::suspend);
    connect(this, &MainWindow::resumeAudioInputStream, m_soundInput,
            &SoundInput::resume);
    connect(this, &MainWindow::finished, m_soundInput, &SoundInput::stop);
    connect(m_soundInput, &SoundInput::error, this,
            &MainWindow::showSoundInError);
    connect(m_soundInput, &SoundInput::error, &m_config,
            &Configuration::invalidate_audio_input_device);
    // connect(m_soundInput, &SoundInput::status, this,
    // &MainWindow::showStatusMessage);
    connect(&m_audioThread, &QThread::finished, m_soundInput,
            &QObject::deleteLater);

    connect(this, &MainWindow::finished, this, &MainWindow::close);

    // hook up the detector signals, slots and disposal
    connect(this, &MainWindow::FFTSize, m_detector, &Detector::setBlockSize);
    connect(m_detector, &Detector::framesWritten, this, &MainWindow::dataSink);
    connect(&m_audioThread, &QThread::finished, m_detector,
            &QObject::deleteLater);

    // setup the waterfall
    connect(m_wideGraph.data(), &WideGraph::f11f12, this, &MainWindow::f11f12);
    connect(m_wideGraph.data(), &WideGraph::setXIT, this, &MainWindow::setXIT);

    connect(this, &MainWindow::finished, m_wideGraph.data(), &WideGraph::close);

    // setup the log QSO dialog
    connect(m_logDlg.data(), &LogQSO::acceptQSO, this, &MainWindow::acceptQSO);
    connect(this, &MainWindow::finished, m_logDlg.data(), &LogQSO::close);

    // Network message handling
    connect(m_messageClient, &MessageClient::message, this,
            &MainWindow::udpNetworkMessage);

    /**
     * @brief Initialize WSJT-X protocol if enabled
     *
     * Creates and configures the WSJT-X message client and mapper when the
     * WSJT-X protocol is enabled. Sets up signal connections for configuration
     * changes and disables the native JSON client if it conflicts with WSJT-X
     * on the same port/address.
     */
    if (m_config.wsjtx_protocol_enabled()) {
        QString id = QApplication::applicationName();
        QString version = QApplication::applicationVersion();
        QString revision = ""; // Get from your version system if available

        m_wsjtxMessageClient = new WSJTXMessageClient{
            id,
            version,
            revision,
            m_config.wsjtx_server_name(),
            m_config.wsjtx_server_port(),
            m_config.wsjtx_interface_names(), // Use selected interfaces
            m_config.wsjtx_TTL(),
            this};

        m_wsjtxMessageClient->enable(m_config.wsjtx_accept_requests());

        m_wsjtxMessageMapper =
            new WSJTXMessageMapper(m_wsjtxMessageClient, this, this);

        // Disable native JSON client if it's using the same port/address as
        // WSJT-X
        if (m_config.wsjtx_server_port() == m_config.udp_server_port() &&
            m_config.wsjtx_server_name() == m_config.udp_server_name()) {
            m_messageClient->set_server_port(0); // Disable native JSON client
        }

        // Connect configuration changes
        connect(&m_config, &Configuration::wsjtx_server_changed,
                [this](QString const &server_name) {
                    m_wsjtxMessageClient->set_server(
                        server_name, m_config.wsjtx_interface_names());
                    // Check if we need to disable native JSON client
                    if (m_config.wsjtx_protocol_enabled() &&
                        m_config.wsjtx_server_port() ==
                            m_config.udp_server_port() &&
                        server_name == m_config.udp_server_name()) {
                        m_messageClient->set_server_port(0);
                    } else if (m_config.wsjtx_protocol_enabled() &&
                               m_config.wsjtx_server_port() !=
                                   m_config.udp_server_port()) {
                        m_messageClient->set_server_port(
                            m_config.udp_server_port());
                    }
                });
        connect(&m_config, &Configuration::wsjtx_server_port_changed,
                [this](quint16 port) {
                    m_wsjtxMessageClient->set_server_port(port);
                    // Check if we need to disable native JSON client
                    if (m_config.wsjtx_protocol_enabled() &&
                        port == m_config.udp_server_port() &&
                        m_config.wsjtx_server_name() ==
                            m_config.udp_server_name()) {
                        m_messageClient->set_server_port(0);
                    } else if (m_config.wsjtx_protocol_enabled() &&
                               port != m_config.udp_server_port()) {
                        m_messageClient->set_server_port(
                            m_config.udp_server_port());
                    }
                });
        connect(&m_config, &Configuration::wsjtx_TTL_changed, this,
                [this](int ttl) {
                    if (m_wsjtxMessageClient) {
                        m_wsjtxMessageClient->set_TTL(ttl);
                    }
                });
        connect(&m_config, &Configuration::wsjtx_interfaces_changed,
                [this](QStringList const &interfaces) {
                    if (m_wsjtxMessageClient) {
                        m_wsjtxMessageClient->set_server(
                            m_config.wsjtx_server_name(), interfaces);
                    }
                });
    }

    // decoder queue handler
    // connect (&m_decodeThread, &QThread::finished, m_notification,
    // &QObject::deleteLater); connect(this, &MainWindow::decodedLineReady,
    // this, &MainWindow::processDecodedLine);
    connect(&m_decoder, &JS8::Decoder::decodeEvent, this,
            &MainWindow::processDecodeEvent);

    m_dateTimeQSOOn = QDateTime{};

    // initialize decoded text font and hook up font change signals
    // defer initialization until after construction otherwise menu
    // fonts do not get set
    QTimer::singleShot(0, this, &MainWindow::initialize_fonts);
    connect(&m_config, &Configuration::gui_text_font_changed,
            [this](QFont const &font) { set_application_font(font); });
    connect(&m_config, &Configuration::table_font_changed,
            [this](QFont const &) {
                ui->tableWidgetRXAll->setFont(m_config.table_font());
                ui->tableWidgetCalls->setFont(m_config.table_font());
            });
    connect(&m_config, &Configuration::rx_text_font_changed,
            [this](QFont const &) {
                setTextEditFont(ui->textEditRX, m_config.rx_text_font());
            });
    connect(&m_config, &Configuration::compose_text_font_changed,
            [this](QFont const &) {
                setTextEditFont(ui->extFreeTextMsgEdit,
                                m_config.compose_text_font());
            });
    connect(&m_config, &Configuration::colors_changed, [this]() {
        setTextEditStyle(ui->textEditRX, m_config.color_rx_foreground(),
                         m_config.color_rx_background(),
                         m_config.rx_text_font());
        setTextEditStyle(
            ui->extFreeTextMsgEdit, m_config.color_compose_foreground(),
            m_config.color_compose_background(), m_config.compose_text_font());
        ui->extFreeTextMsgEdit->setFont(m_config.compose_text_font(),
                                        m_config.color_compose_foreground(),
                                        m_config.color_compose_background());

        // rehighlight
        auto d = ui->textEditRX->document();
        if (d) {
            for (int i = 0; i < d->lineCount(); i++) {
                auto b = d->findBlockByLineNumber(i);

                switch (b.userState()) {
                case State::RX:
                    highlightBlock(b, m_config.rx_text_font(),
                                   m_config.color_rx_foreground(),
                                   QColor(Qt::transparent));
                    break;
                case State::TX:
                    highlightBlock(b, m_config.tx_text_font(),
                                   m_config.color_tx_foreground(),
                                   QColor(Qt::transparent));
                    break;
                }
            }
        }
    });

    setWindowTitle(program_title());
    buildColumnLabelMap();

    // Hook up working frequencies.

    ui->currentFreq->setCursor(QCursor(Qt::PointingHandCursor));
    ui->currentFreq->display("14.078 000");
    ui->currentFreq->installEventFilter(new EventFilter::MouseButtonPress(
        [this](QMouseEvent *event) {
            QMenu *menu = new QMenu(ui->currentFreq);
            buildFrequencyMenu(menu);
            menu->popup(event->globalPosition().toPoint());
            return true;
        },
        this));

    ui->labDialFreqOffset->setCursor(QCursor(Qt::PointingHandCursor));
    ui->labDialFreqOffset->installEventFilter(new EventFilter::MouseButtonPress(
        [this](QMouseEvent *) {
            on_actionSetOffset_triggered();
            return true;
        },
        this));

    // Hook up callsign label click to open preferences

    ui->labCallsign->setCursor(QCursor(Qt::PointingHandCursor));
    ui->labCallsign->installEventFilter(new EventFilter::MouseButtonPress(
        [this](QMouseEvent *) {
            openSettings(0);
            return true;
        },
        this));

    // hook up configuration signals
    connect(&m_config, &Configuration::transceiver_update, this,
            &MainWindow::handle_transceiver_update);
    connect(&m_config, &Configuration::transceiver_failure, this,
            &MainWindow::handle_transceiver_failure);
    connect(&m_config, &Configuration::udp_server_name_changed, m_messageClient,
            &MessageClient::set_server_name);
    connect(&m_config, &Configuration::udp_server_port_changed, m_messageClient,
            &MessageClient::set_server_port);

    // Disable native JSON client if WSJT-X protocol is enabled on the same
    // port/address This prevents JSON PING messages from interfering with
    // WSJT-X binary protocol
    connect(
        &m_config, &Configuration::wsjtx_protocol_enabled_changed, this,
        [this](bool enabled) {
            if (enabled &&
                m_config.wsjtx_server_port() == m_config.udp_server_port() &&
                m_config.wsjtx_server_name() == m_config.udp_server_name()) {
                // Disable native JSON client to avoid conflicts with WSJT-X
                // protocol
                m_messageClient->set_server_port(0);
            } else if (!enabled) {
                // Re-enable native JSON client if WSJT-X is disabled
                m_messageClient->set_server_port(m_config.udp_server_port());
            }
        });
    connect(&m_config, &Configuration::band_schedule_changed, this,
            [this]() { this->m_bandHopped = true; });
    connect(&m_config, &Configuration::auto_switch_bands_changed, this,
            [this](bool auto_switch_bands) {
                this->m_bandHopped = this->m_bandHopped || auto_switch_bands;
            });
    connect(&m_config, &Configuration::manual_band_hop_requested, this,
            &MainWindow::manualBandHop);
    connect(&m_config, &Configuration::enumerating_audio_devices,
            [this]() { showStatusMessage(tr("Enumerating audio devices")); });

    // set up configurations menu
    connect(m_multi_settings, &MultiSettings::configurationNameChanged,
            [this](QString const &name) {
                if ("Default" != name) {
                    config_label.setText(name);
                    config_label.show();
                } else {
                    config_label.hide();
                }
            });
    m_multi_settings->create_menu_actions(this, ui->menuConfig);
    m_configurations_button = m_rigErrorMessageBox.addButton(
        tr("Configurations..."), QMessageBox::ActionRole);
    connect(ui->extFreeTextMsgEdit, &QTextEdit::textChanged,
            [this]() { currentTextChanged(); });

    m_guiTimer.setTimerType(Qt::PreciseTimer);
    m_guiTimer.setSingleShot(true);
    connect(&m_guiTimer, &QTimer::timeout, this, &MainWindow::guiUpdate);
    m_guiTimer.start(UI_POLL_INTERVAL_MS);

    pttReleaseTimer.setTimerType(Qt::PreciseTimer);
    pttReleaseTimer.setSingleShot(true);
    connect(&pttReleaseTimer, &QTimer::timeout, this, &MainWindow::stopTx2);

    logQSOTimer.setSingleShot(true);
    connect(&logQSOTimer, &QTimer::timeout, this,
            &MainWindow::on_logQSOButton_clicked);

    tuneButtonTimer.setSingleShot(true);
    connect(&tuneButtonTimer, &QTimer::timeout, this, &MainWindow::end_tuning);

    tuneATU_Timer.setSingleShot(true);
    connect(&tuneATU_Timer, &QTimer::timeout, this, &MainWindow::stopTuneATU);

    TxAgainTimer.setSingleShot(true);
    connect(&TxAgainTimer, &QTimer::timeout, this, &MainWindow::TxAgain);

    connect(m_wideGraph.data(), &WideGraph::changeFreq, this,
            &MainWindow::changeFreq);
    connect(m_wideGraph.data(), &WideGraph::qsy, this, &MainWindow::qsy);

    // DriftingDateTime management:
    connect(m_wideGraph.data(), &WideGraph::want_new_drift,
            &DriftingDateTimeSingleton::getSingleton(),
            &DriftingDateTimeSingleton::setDrift);

    // Distribute Drift change:
    connect(&DriftingDateTimeSingleton::getSingleton(),
            &DriftingDateTimeSingleton::driftChanged, this,
            &MainWindow::onDriftChanged);
    connect(&DriftingDateTimeSingleton::getSingleton(),
            &DriftingDateTimeSingleton::driftChanged, m_wideGraph.data(),
            &WideGraph::onDriftChanged);
    connect(&DriftingDateTimeSingleton::getSingleton(),
            &DriftingDateTimeSingleton::driftChanged, m_cq_loop,
            &TxLoop::onDriftChange);
    connect(&DriftingDateTimeSingleton::getSingleton(),
            &DriftingDateTimeSingleton::driftChanged, m_hb_loop,
            &TxLoop::onDriftChange);

    // HB and CQ loop:
    // For now, disable HB loop while CQ loop runs and vice versa:
    connect(m_cq_loop, &TxLoop::nextActivityChanged, this,
            [this](const QDateTime &) { this->m_hb_loop->onLoopCancel(); });
    connect(m_hb_loop, &TxLoop::nextActivityChanged, this,
            [this](const QDateTime &) { this->m_cq_loop->onLoopCancel(); });
    // It is not advisable to send a HB in one period and a CQ in the very next,
    // or a CQ first and then a HB too soon, so that transmissions of people
    // interested in the QSO might be drowned.
    //
    // We could conceivably devise a clean conflict resolution mechanism
    // disallowing automatic transmissions of one type if an automatic
    // transmission of the other type has happened recently. In the absence of
    // such a mechanism, just do either one or the other.
    //
    // People who call CQ are expected to monitor somewhat closely, so they will
    // have no problems triggering HBs at will.

    // Propagate tx submode changes to the CQ and HB loop:
    connect(this, &MainWindow::submodeChanged, this->m_hb_loop,
            &TxLoop::onModeChange);
    connect(this, &MainWindow::submodeChanged, this->m_cq_loop,
            &TxLoop::onModeChange);

    // When the loops are switched off, tell the UI:
    connect(m_hb_loop, &TxLoop::canceled, ui->hbMacroButton,
            [this]() { this->ui->hbMacroButton->setChecked(false); });
    connect(m_cq_loop, &TxLoop::canceled, ui->cqMacroButton,
            [this]() { this->ui->cqMacroButton->setChecked(false); });

    // The loops can trigger transmissions. That is what they are for.
    connect(m_hb_loop, &TxLoop::triggerTxNow, this,
            [this]() { this->sendHB(); });
    connect(m_cq_loop, &TxLoop::triggerTxNow, this,
            [this]() { this->sendCQ(true); });

    // Something like this would be nice to have:
    // connect(m_config, &Configuration::txDelayChanged, m_cq_loop,
    // &TxLoop::onTxDelayChange); connect(m_config,
    // &Configuration::txDelayChanged, m_hb_loop, &TxLoop::onTxDelayChange); But
    // the pertaining signals are not offered by Configuration, and the code was
    // somewhat of a beast to get into to change that, so some equivalent is
    // done in a pedestrian way via our polling routine.  That pedestrian code
    // also handles conversion of incoming tx delay in (double) seconds to
    // outgoing (qint64) milliseconds.

    decodeBusy(false);

    m_msg[0][0] = 0;

    displayDialFrequency();
    readSettings(); // Restore user's setup params

    {
        std::lock_guard<std::mutex> lock(fftw_mutex);
        fftwf_import_wisdom_from_filename(wisdomFileName());
    }

    m_networkThread.start(m_networkThreadPriority);
    m_audioThread.start(m_audioThreadPriority);
    m_notificationAudioThread.start(m_notificationAudioThreadPriority);
    m_decoder.start(m_decoderThreadPriority);

    Q_EMIT startAudioInputStream(m_config.audio_input_device(),
                                 m_framesAudioInputBuffered, m_detector,
                                 m_config.audio_input_channel());
    Q_EMIT initializeAudioOutputStream(
        m_config.audio_output_device(),
        AudioDevice::Mono == m_config.audio_output_channel() ? 1 : 2,
        m_msAudioOutputBuffered);
    Q_EMIT initializeNotificationAudioOutputStream(
        m_config.notification_audio_output_device(), m_msAudioOutputBuffered);
    Q_EMIT transmitFrequency(freq() + m_XIT);

    enable_DXCC_entity(
        m_config
            .DXCC()); // sets text window proportions and (re)inits the logbook

    // this must be done before initializing the mode as some modes need
    // to turn off split on the rig e.g. WSPR
    m_config.transceiver_online();

    setupJS8();

    Q_EMIT transmitFrequency(freq() + m_XIT);

    statusChanged();

    connect(&minuteTimer, &QTimer::timeout, this, &MainWindow::on_the_minute);
    minuteTimer.setSingleShot(true);
    minuteTimer.start(ms_minute_error() + 60 * 1000);

    QTimer::singleShot(0, this, &MainWindow::checkStartupWarnings);

    // UI Customizations & Tweaks
    ui->horizontalLayoutBand->insertSpacing(1, 6);
    ui->horizontalLayoutBand->insertWidget(2, m_wideGraph.data(), 1);
    ui->horizontalLayoutBand->insertSpacing(3, 8);

    // remove disabled menus from the menu bar
    foreach (auto action, ui->menuBar->actions()) {
        if (action->isEnabled()) {
            continue;
        }
        ui->menuBar->removeAction(action);
    }

    // auto f = findFreeFreqOffset(1000, 2000, 50);
    // setFreqOffsetForRestore(f, false);

    ui->actionModeAutoreply->setChecked(m_config.autoreply_on_at_startup());
    ui->spotButton->setChecked(m_config.spot_to_reporting_networks());

    QActionGroup *modeActionGroup = new QActionGroup(this);
    ui->actionModeJS8Normal->setActionGroup(modeActionGroup);
    ui->actionModeJS8Fast->setActionGroup(modeActionGroup);
    ui->actionModeJS8Turbo->setActionGroup(modeActionGroup);
    ui->actionModeJS8Slow->setActionGroup(modeActionGroup);
    ui->actionModeJS8Ultra->setActionGroup(modeActionGroup);

    ui->modeButton->installEventFilter(new EventFilter::MouseButtonPress(
        [this](QMouseEvent *event) {
            ui->menuModeJS8->popup(event->globalPosition().toPoint());
            return true;
        },
        this));

    if (!JS8_ENABLE_JS8A)
        ui->actionModeJS8Normal->setVisible(false);
    if (!JS8_ENABLE_JS8B)
        ui->actionModeJS8Fast->setVisible(false);
    if (!JS8_ENABLE_JS8C)
        ui->actionModeJS8Turbo->setVisible(false);
    if (!JS8_ENABLE_JS8E)
        ui->actionModeJS8Slow->setVisible(false);
    if (!JS8_ENABLE_JS8I)
        ui->actionModeJS8Ultra->setVisible(false);

    // prep
    prepareMonitorControls();
    prepareHeartbeatMode(canCurrentModeSendHeartbeat() &&
                         ui->actionModeJS8HB->isChecked());

    ui->extFreeTextMsgEdit->installEventFilter(new EventFilter::EnterKeyPress(
        [this](QKeyEvent *const event) {
            if (event->modifiers() & Qt::ShiftModifier)
                return false;
            if (ui->extFreeTextMsgEdit->isReadOnly())
                return false;

            if (ui->extFreeTextMsgEdit->toPlainText().trimmed().isEmpty())
                return true;
            if (!ensureCanTransmit())
                return true;
            if (!ensureCallsignSet(true))
                return true;

            toggleTx(true);
            return true;
        },
        this));

    ui->textEditRX->viewport()->installEventFilter(
        new EventFilter::MouseButtonDblClick(
            [this](QMouseEvent *) {
                QTimer::singleShot(150, this, [this]() {
                    // When we double click the rx window, we send the selected
                    // text to the log dialog when the text could be an snr
                    // value prefixed with a - or +, we extend the selection to
                    // include it.

                    auto textCursor = ui->textEditRX->textCursor();
                    auto text = textCursor.selectedText();

                    if (text.isEmpty())
                        return;

                    auto const start = textCursor.selectionStart();
                    auto const end = textCursor.selectionEnd();

                    textCursor.clearSelection();
                    textCursor.setPosition(start);
                    textCursor.movePosition(QTextCursor::PreviousCharacter,
                                            QTextCursor::MoveAnchor);
                    textCursor.movePosition(QTextCursor::NextCharacter,
                                            QTextCursor::KeepAnchor,
                                            1 + end - start);

                    if (auto const prev = textCursor.selectedText();
                        prev.startsWith("-") || prev.startsWith("+")) {
                        ui->textEditRX->setTextCursor(textCursor);
                        text = prev;
                    }

                    m_logDlg->acceptText(text);
                });
                return false;
            },
            this));

    auto clearActionSep = new QAction(nullptr);
    clearActionSep->setSeparator(true);

    auto clearActionAll = new QAction(QString("Clear All Lists"), nullptr);
    connect(clearActionAll, &QAction::triggered, this, [this]() {
        if (QMessageBox::Yes !=
            QMessageBox::question(
                this, "Clear All Activity",
                "Are you sure you would like to clear all activity?",
                QMessageBox::Yes | QMessageBox::No)) {
            return;
        }

        clearActivity();
    });

    // setup tablewidget context menus
    auto clearAction1 = new QAction(QString("Clear"), ui->textEditRX);
    connect(clearAction1, &QAction::triggered, this,
            [this]() { clearRXActivity(); });

    auto saveAction = new QAction(QString("Save As..."), ui->textEditRX);
    connect(saveAction, &QAction::triggered, this, [this]() {
        auto writePath =
            QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
        auto writeDir = QDir(writePath);
        auto defaultFilename = writeDir.absoluteFilePath(
            QString("js8call-%1.txt")
                .arg(DriftingDateTime::currentDateTimeUtc().toString(
                    "yyyyMMdd")));

        QString selectedFilter = "*.txt";

        auto filename = QFileDialog::getSaveFileName(
            this, "Save As...", defaultFilename,
            "Text files (*.txt);; All files (*)", &selectedFilter);
        if (filename.isEmpty()) {
            return;
        }

        auto text = ui->textEditRX->toPlainText();
        QFile f(filename);
        if (f.open(QIODevice::Truncate | QIODevice::WriteOnly |
                   QIODevice::Text)) {
            QTextStream stream(&f);
            stream << text;
        }
    });

    ui->textEditRX->setContextMenuPolicy(Qt::CustomContextMenu);
    connect(
        ui->textEditRX, &QTableWidget::customContextMenuRequested, this,
        [this, clearAction1, clearActionAll, saveAction](QPoint const &point) {
            QMenu *menu = new QMenu(ui->textEditRX);

            buildEditMenu(menu, ui->textEditRX);

            menu->addSeparator();

            menu->addAction(clearAction1);
            menu->addAction(clearActionAll);

            menu->addSeparator();
            menu->addAction(saveAction);

            menu->popup(ui->textEditRX->mapToGlobal(point));
        });

    auto clearAction2 = new QAction(QString("Clear"), ui->extFreeTextMsgEdit);
    connect(clearAction2, &QAction::triggered, this, [this]() {
        resetMessage();
        m_lastTxMessage.clear();
    });

    auto restoreAction = new QAction(QString("Restore Previous Message"),
                                     ui->extFreeTextMsgEdit);
    connect(restoreAction, &QAction::triggered, this,
            [this]() { this->restoreMessage(); });

    ui->extFreeTextMsgEdit->setContextMenuPolicy(Qt::CustomContextMenu);
    connect(
        ui->extFreeTextMsgEdit, &QTableWidget::customContextMenuRequested, this,
        [this, clearAction2, clearActionAll,
         restoreAction](QPoint const &point) {
            QMenu *menu = new QMenu(ui->extFreeTextMsgEdit);

            auto selectedCall = callsignSelected();
            bool missingCallsign = selectedCall.isEmpty();

            buildSuggestionsMenu(menu, ui->extFreeTextMsgEdit, point);

            restoreAction->setDisabled(m_lastTxMessage.isEmpty());
            menu->addAction(restoreAction);

            auto savedMenu = menu->addMenu("Saved Messages...");
            buildSavedMessagesMenu(savedMenu);

            auto directedMenu =
                menu->addMenu(QString("Directed to %1...").arg(selectedCall));
            directedMenu->setDisabled(missingCallsign);
            buildQueryMenu(directedMenu, selectedCall);

            auto relayMenu = menu->addMenu("Relay via...");
            relayMenu->setDisabled(
                ui->extFreeTextMsgEdit->toPlainText().isEmpty() ||
                m_callActivity.isEmpty());
            buildRelayMenu(relayMenu);

            menu->addSeparator();

            buildEditMenu(menu, ui->extFreeTextMsgEdit);

            menu->addSeparator();

            menu->addAction(clearAction2);
            menu->addAction(clearActionAll);

            menu->popup(ui->extFreeTextMsgEdit->mapToGlobal(point));

            displayActivity(true);
        });

    auto clearAction3 = new QAction(QString("Clear"), ui->tableWidgetRXAll);
    connect(clearAction3, &QAction::triggered, this,
            [this]() { clearBandActivity(); });

    auto removeActivity =
        new QAction(QString("Remove Activity"), ui->tableWidgetRXAll);
    connect(removeActivity, &QAction::triggered, this, [this]() {
        if (ui->tableWidgetRXAll->selectedItems().isEmpty()) {
            return;
        }

        auto selectedItems = ui->tableWidgetRXAll->selectedItems();
        int selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt();

        m_bandActivity.remove(selectedOffset);
        displayActivity(true);
    });

    auto logAction = new QAction(QString("Log..."), ui->tableWidgetCalls);
    connect(logAction, &QAction::triggered, this,
            &MainWindow::on_logQSOButton_clicked);

    // Disable default header mouseover and click behaviors, they are confusing
    // to users because they give the appearance of allowing sorting by header
    // clicks, which is not actually implemented
    ui->tableWidgetRXAll->horizontalHeader()->setHighlightSections(false);
    ui->tableWidgetRXAll->horizontalHeader()->setSectionsClickable(false);

    ui->tableWidgetRXAll->horizontalHeader()->setContextMenuPolicy(
        Qt::CustomContextMenu);
    connect(
        ui->tableWidgetRXAll->horizontalHeader(),
        &QHeaderView::customContextMenuRequested, this,
        [this](QPoint const &point) {
            QMenu *menu = new QMenu(ui->tableWidgetRXAll);

            QMenu *sortByMenu = menu->addMenu("Sort By...");
            buildBandActivitySortByMenu(sortByMenu);

            QMenu *showColumnsMenu = menu->addMenu("Show Columns...");
            buildShowColumnsMenu(showColumnsMenu, "band");

            menu->popup(
                ui->tableWidgetRXAll->horizontalHeader()->mapToGlobal(point));
        });

    ui->tableWidgetRXAll->setContextMenuPolicy(Qt::CustomContextMenu);
    connect(
        ui->tableWidgetRXAll, &QTableWidget::customContextMenuRequested, this,
        [this, clearAction3, clearActionAll, removeActivity,
         logAction](QPoint const &point) {
            QMenu *menu = new QMenu(ui->tableWidgetRXAll);

            // clear the selection of the call widget on right click
            // but only if the table has rows.
            if (ui->tableWidgetRXAll->rowAt(point.y()) != -1) {
                ui->tableWidgetCalls->selectionModel()->clearSelection();
            }

            QString selectedCall = callsignSelected();
            bool missingCallsign = selectedCall.isEmpty();
            bool isAllCall = isAllCallIncluded(selectedCall);

            int selectedOffset = -1;
            if (!ui->tableWidgetRXAll->selectedItems().isEmpty()) {
                auto selectedItems = ui->tableWidgetRXAll->selectedItems();
                selectedOffset =
                    selectedItems.first()->data(Qt::UserRole).toInt();
            }

            if (selectedOffset != -1) {
                auto qsyAction = menu->addAction(
                    QString("Jump to %1Hz").arg(selectedOffset));
                connect(qsyAction, &QAction::triggered, this,
                        [this, selectedOffset]() {
                            setFreqOffsetForRestore(selectedOffset, false);
                        });

                if (m_wideGraph->filterEnabled()) {
                    auto filterQsyAction = menu->addAction(
                        QString("Center filter at %1Hz").arg(selectedOffset));
                    connect(filterQsyAction, &QAction::triggered, this,
                            [this, selectedOffset]() {
                                m_wideGraph->setFilterCenter(selectedOffset);
                            });
                }

                auto items = m_bandActivity.value(selectedOffset);
                if (!items.isEmpty()) {
                    int submode = items.last().submode;
                    auto speed = JS8::Submode::name(submode);
                    if (submode != m_nSubMode) {
                        auto qrqAction =
                            menu->addAction(QString("Jump to %1%2 speed")
                                                .arg(speed.left(1))
                                                .arg(speed.mid(1).toLower()));
                        connect(qrqAction, &QAction::triggered, this,
                                [this, submode]() { setSubmode(submode); });
                    }

                    int tdrift = -int(items.last().tdrift * 1000);
                    auto qtrAction = menu->addAction(
                        QString("Jump to %1 ms time drift").arg(tdrift));
                    connect(qtrAction, &QAction::triggered, this,
                            [this, tdrift]() { setDrift(tdrift); });
                }

                menu->addSeparator();
            }

            menu->addAction(logAction);
            logAction->setDisabled(missingCallsign || isAllCall);

            menu->addSeparator();

            auto savedMenu = menu->addMenu("Saved Messages...");
            buildSavedMessagesMenu(savedMenu);

            auto directedMenu =
                menu->addMenu(QString("Directed to %1...").arg(selectedCall));
            directedMenu->setDisabled(missingCallsign);
            buildQueryMenu(directedMenu, selectedCall);

            auto relayAction = buildRelayAction(selectedCall);
            relayAction->setText(QString("Relay via %1...").arg(selectedCall));
            relayAction->setDisabled(missingCallsign);
            menu->addActions({relayAction});

            auto deselectAction =
                menu->addAction(QString("Deselect %1").arg(selectedCall));
            deselectAction->setDisabled(missingCallsign);
            connect(deselectAction, &QAction::triggered, this, [this]() {
                ui->tableWidgetRXAll->clearSelection();
                ui->tableWidgetCalls->clearSelection();
            });

            menu->addSeparator();

            removeActivity->setDisabled(selectedOffset == -1);
            menu->addAction(removeActivity);

            menu->addSeparator();
            menu->addAction(clearAction3);
            menu->addAction(clearActionAll);

            menu->popup(ui->tableWidgetRXAll->mapToGlobal(point));

            displayActivity(true);
        });

    auto clearAction4 =
        new QAction(QString("Clear Entire List"), ui->tableWidgetCalls);
    connect(clearAction4, &QAction::triggered, this,
            [this]() { clearCallActivity(); });

    auto addStation = new QAction(QString("Add New Station or Group..."),
                                  ui->tableWidgetCalls);
    connect(addStation, &QAction::triggered, this, [this]() {
        bool ok = false;
        QString callsign =
            QInputDialog::getText(this, tr("Add New Station or Group"),
                                  tr("Station or Group Callsign:"),
                                  QLineEdit::Normal, "", &ok)
                .toUpper()
                .trimmed();
        if (!ok || callsign.trimmed().isEmpty()) {
            return;
        }

        // if we're adding allcall, turn off allcall avoidance
        if (callsign == "@ALLCALL") {
            m_config.set_avoid_allcall(false);
        } else if (callsign.startsWith("@")) {
            if (Varicode::isCompoundCallsign(callsign)) {
                m_config.addGroup(callsign);
            } else {
                JS8MessageBox::critical_message(
                    this, QString("%1 is not a valid group").arg(callsign));
            }

        } else {
            if (Varicode::isValidCallsign(callsign, nullptr)) {
                CallDetail cd = {};
                cd.call = callsign;
                m_callActivity[callsign] = cd;
            } else {
                JS8MessageBox::critical_message(
                    this, QString("%1 is not a valid callsign or group")
                              .arg(callsign));
            }
        }

        displayActivity(true);
    });

    auto removeStation =
        new QAction(QString("Remove Station"), ui->tableWidgetCalls);
    connect(removeStation, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        if (selectedCall == "@ALLCALL") {
            m_config.set_avoid_allcall(true);
        } else if (selectedCall.startsWith("@")) {
            m_config.removeGroup(selectedCall);
        } else if (m_callActivity.contains(selectedCall)) {
            m_callActivity.remove(selectedCall);
        }

        displayActivity(true);
    });

    connect(ui->actionShow_Message_Inbox, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            selectedCall = "%";
        }

        Inbox inbox(inboxPath());
        if (!inbox.open()) {
            return;
        }

        QList<QPair<int, Message>> msgs;

        msgs.append(
            inbox.values("STORE", "$.params.TO", selectedCall, 0, 1000));

        msgs.append(
            inbox.values("READ", "$.params.FROM", selectedCall, 0, 1000));

        foreach (auto pair, inbox.values("UNREAD", "$.params.FROM",
                                         selectedCall, 0, 1000)) {
            msgs.append(pair);

            // mark as read
            auto msg = pair.second;
            msg.setType("READ");
            inbox.set(pair.first, msg);
        }

        std::stable_sort(
            msgs.begin(), msgs.end(),
            [](QPair<int, Message> const &a, QPair<int, Message> const &b) {
                return QVariant::compare(a.second.params().value("UTC"),
                                         b.second.params().value("UTC")) ==
                       QPartialOrdering::Greater;
            });

        auto mw = new MessageWindow(this);
        connect(mw, &MessageWindow::finished, this, [this](int) {
            refreshInboxCounts();
            displayCallActivity();
        });
        connect(mw, &MessageWindow::deleteMessage, this, [this](int id) {
            Inbox inbox(inboxPath());
            if (!inbox.open()) {
                return;
            }

            inbox.del(id);
        });
        connect(mw, &MessageWindow::replyMessage, this,
                [this, mw](const QString &text) {
                    addMessageText(text, true, true);
                    refreshInboxCounts();
                    displayCallActivity();
                    mw->close();
                });
        mw->setCall(selectedCall);
        mw->populateMessages(msgs);
        mw->show();
    });

    auto historyAction =
        new QAction(QString("Show Message Inbox..."), ui->tableWidgetCalls);
    connect(historyAction, &QAction::triggered, ui->actionShow_Message_Inbox,
            &QAction::trigger);

    auto localMessageAction =
        new QAction(QString("Store Message..."), ui->tableWidgetCalls);
    connect(localMessageAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        auto m = new MessageReplyDialog(this);
        m->setWindowTitle("Message");
        m->setLabel(
            QString("Store this message locally for %1:").arg(selectedCall));
        if (m->exec() != QMessageBox::Accepted) {
            return;
        }

        CommandDetail d = {};
        d.cmd = " MSG ";
        d.to = selectedCall;
        d.from = m_config.my_callsign();
        d.relayPath = d.from;
        d.text = m->textValue();
        d.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
        d.submode = m_nSubMode;

        addCommandToStorage("STORE", d);
    });

    // Disable default header mouseover and click behaviors, they are confusing
    // to users because they give the appearance of allowing sorting by header
    // clicks, which is not actually implemented
    ui->tableWidgetCalls->horizontalHeader()->setHighlightSections(false);
    ui->tableWidgetCalls->horizontalHeader()->setSectionsClickable(false);

    ui->tableWidgetCalls->horizontalHeader()->setContextMenuPolicy(
        Qt::CustomContextMenu);
    connect(
        ui->tableWidgetCalls->horizontalHeader(),
        &QHeaderView::customContextMenuRequested, this,
        [this](QPoint const &point) {
            QMenu *menu = new QMenu(ui->tableWidgetCalls);

            QMenu *sortByMenu = menu->addMenu("Sort By...");
            buildCallActivitySortByMenu(sortByMenu);

            QMenu *showColumnsMenu = menu->addMenu("Show Columns...");
            buildShowColumnsMenu(showColumnsMenu, "call");

            menu->popup(
                ui->tableWidgetCalls->horizontalHeader()->mapToGlobal(point));
        });

    ui->tableWidgetCalls->setContextMenuPolicy(Qt::CustomContextMenu);
    connect(
        ui->tableWidgetCalls, &QTableWidget::customContextMenuRequested, this,
        [this, logAction, historyAction, localMessageAction, clearAction4,
         clearActionAll, addStation, removeStation](QPoint const &point) {
            QMenu *menu = new QMenu(ui->tableWidgetCalls);

            // clear the selection of the call widget on right click
            // but only if the table has rows.
            if (ui->tableWidgetCalls->rowAt(point.y()) != -1) {
                ui->tableWidgetRXAll->selectionModel()->clearSelection();
            }

            QString selectedCall = callsignSelected();
            bool isAllCall = isAllCallIncluded(selectedCall);
            // bool isGroupCall = isGroupCallIncluded(selectedCall);
            bool missingCallsign = selectedCall.isEmpty();

            if (!missingCallsign && !isAllCall) {
                int selectedOffset = m_callActivity[selectedCall].offset;
                if (selectedOffset != -1) {
                    auto qsyAction = menu->addAction(
                        QString("Jump to %1Hz").arg(selectedOffset));
                    connect(qsyAction, &QAction::triggered, this,
                            [this, selectedOffset]() {
                                setFreqOffsetForRestore(selectedOffset, false);
                            });

                    if (m_wideGraph->filterEnabled()) {
                        auto filterQsyAction =
                            menu->addAction(QString("Center filter at %1Hz")
                                                .arg(selectedOffset));
                        connect(filterQsyAction, &QAction::triggered, this,
                                [this, selectedOffset]() {
                                    m_wideGraph->setFilterCenter(
                                        selectedOffset);
                                });
                    }

                    int submode = m_callActivity[selectedCall].submode;
                    auto speed = JS8::Submode::name(submode);
                    if (submode != m_nSubMode) {
                        auto qrqAction =
                            menu->addAction(QString("Jump to %1%2 speed")
                                                .arg(speed.left(1))
                                                .arg(speed.mid(1).toLower()));
                        connect(qrqAction, &QAction::triggered, this,
                                [this, submode]() { setSubmode(submode); });
                    }

                    int tdrift =
                        -int(m_callActivity[selectedCall].tdrift * 1000);
                    auto qtrAction = menu->addAction(
                        QString("Jump to %1 ms time drift").arg(tdrift));
                    connect(qtrAction, &QAction::triggered, this,
                            [this, tdrift]() { setDrift(tdrift); });

                    menu->addSeparator();
                }
            }

            menu->addAction(logAction);
            logAction->setDisabled(missingCallsign || isAllCall);

            menu->addAction(historyAction);
            historyAction->setDisabled(missingCallsign || isAllCall ||
                                       !hasMessageHistory(selectedCall));

            menu->addAction(localMessageAction);
            localMessageAction->setDisabled(missingCallsign || isAllCall);

            menu->addSeparator();

            auto savedMenu = menu->addMenu("Saved Messages...");
            buildSavedMessagesMenu(savedMenu);

            auto directedMenu =
                menu->addMenu(QString("Directed to %1...").arg(selectedCall));
            directedMenu->setDisabled(missingCallsign);
            buildQueryMenu(directedMenu, selectedCall);

            auto relayAction = buildRelayAction(selectedCall);
            relayAction->setText(QString("Relay via %1...").arg(selectedCall));
            relayAction->setDisabled(missingCallsign || isAllCall);
            menu->addActions({relayAction});

            auto deselect =
                menu->addAction(QString("Deselect %1").arg(selectedCall));
            deselect->setDisabled(missingCallsign);
            connect(deselect, &QAction::triggered, this, [this]() {
                ui->tableWidgetRXAll->clearSelection();
                ui->tableWidgetCalls->clearSelection();
            });

            menu->addSeparator();

            menu->addAction(addStation);
            removeStation->setDisabled(missingCallsign);
            removeStation->setText(selectedCall.startsWith("@")
                                       ? "Remove This Group"
                                       : "Remove This Station");
            menu->addAction(removeStation);

            menu->addSeparator();
            menu->addAction(clearAction4);
            menu->addAction(clearActionAll);

            menu->popup(ui->tableWidgetCalls->mapToGlobal(point));
        });

    connect(ui->tableWidgetRXAll->selectionModel(),
            &QItemSelectionModel::selectionChanged, this,
            &MainWindow::tableSelectionChanged);
    connect(ui->tableWidgetCalls->selectionModel(),
            &QItemSelectionModel::selectionChanged, this,
            &MainWindow::tableSelectionChanged);

    auto p = ui->tableWidgetRXAll->palette();
    p.setColor(QPalette::Inactive, QPalette::Highlight,
               p.color(QPalette::Active, QPalette::Highlight));
    ui->tableWidgetRXAll->setPalette(p);

    p = ui->tableWidgetCalls->palette();
    p.setColor(QPalette::Inactive, QPalette::Highlight,
               p.color(QPalette::Active, QPalette::Highlight));
    ui->tableWidgetCalls->setPalette(p);

    ui->hbMacroButton->setContextMenuPolicy(Qt::CustomContextMenu);
    connect(ui->hbMacroButton, &QPushButton::customContextMenuRequested, this,
            [this](QPoint const &point) {
                QMenu *menu = new QMenu(ui->hbMacroButton);

                buildHeartbeatMenu(menu);

                menu->popup(ui->hbMacroButton->mapToGlobal(point));
            });

    ui->cqMacroButton->setContextMenuPolicy(Qt::CustomContextMenu);
    connect(ui->cqMacroButton, &QPushButton::customContextMenuRequested, this,
            [this](QPoint const &point) {
                QMenu *menu = new QMenu(ui->cqMacroButton);

                buildCQMenu(menu);

                menu->popup(ui->cqMacroButton->mapToGlobal(point));
            });

    // Don't block heartbeat's first run...
    m_lastTxStartTime = DriftingDateTime::currentDateTimeUtc().addSecs(-300);

    // But do block the decoder's first run until 50% through next transmit
    // period
    m_lastTxStopTime = nextTransmitCycle().addSecs(-m_TRperiod / 2);

    int width = 75;
    /*
    QList<QPushButton*> btns;
    foreach(auto child, ui->buttonGrid->children()){
        if(!child->isWidgetType()){
            continue;
        }

        if(!child->objectName().contains("Button")){
            continue;
        }

        auto b = qobject_cast<QPushButton*>(child);
        width = qMax(width, b->geometry().width());
        btns.append(b);
    }
    */
    foreach (auto child, ui->buttonGrid->children()) {
        if (!child->isWidgetType()) {
            continue;
        }

        if (!child->objectName().contains("Button")) {
            continue;
        }

        auto b = qobject_cast<QPushButton *>(child);
        b->setCursor(QCursor(Qt::PointingHandCursor));
    }
    auto buttonLayout = ui->buttonGrid->layout();
    auto gridButtonLayout = qobject_cast<QGridLayout *>(buttonLayout);
    gridButtonLayout->setColumnMinimumWidth(0, width);
    gridButtonLayout->setColumnMinimumWidth(1, width);
    gridButtonLayout->setColumnMinimumWidth(2, width);
    gridButtonLayout->setColumnStretch(0, 1);
    gridButtonLayout->setColumnStretch(1, 1);
    gridButtonLayout->setColumnStretch(2, 1);

    // dial up and down buttons sizes
    ui->dialFreqUpButton->setFixedSize(30, 24);
    ui->dialFreqDownButton->setFixedSize(30, 24);

    // Prepare spotting configuration...
    prepareApi();
    prepareSpotting();

    displayActivity(true);

    m_txTextDirtyDebounce.setSingleShot(true);
    connect(&m_txTextDirtyDebounce, &QTimer::timeout, this,
            &MainWindow::refreshTextDisplay);
    qCDebug(mainwindow_js8)
        << "Main window constructor has done all connect (aka plumbing) work.";

    m_TxDelay = m_config.txDelay();
    m_hb_loop->onTxDelayChange(llround(m_TxDelay * 1000.0));
    m_cq_loop->onTxDelayChange(llround(m_TxDelay * 1000.0));
    m_hb_loop->onPlumbingCompleted();
    m_cq_loop->onPlumbingCompleted();
    DriftingDateTimeSingleton::getSingleton().onPlumbingCompleted();
    qCDebug(mainwindow_js8)
        << "Initialization with onPlumbingCompleted() has completed.";

    QTimer::singleShot(500, this, &MainWindow::initializeDummyData);
    QTimer::singleShot(500, this, &MainWindow::initializeGroupMessageDummyData);

    // this must be the last statement of constructor
    if (!m_valid)
        throw std::runtime_error{"Fatal initialization exception"};
}

void MainWindow::checkVersion(bool const alertOnUpToDate) {
    auto m = new QNetworkAccessManager(this);
    connect(m, &QNetworkAccessManager::finished, this,
            [this, alertOnUpToDate](QNetworkReply *reply) {
                if (reply->error()) {
                    qCDebug(mainwindow_js8) << "Checking for Updates Error:"
                                            << reply->errorString();
                    return;
                }

                QString content = reply->readAll().trimmed();

                auto const currentVersion =
                    QVersionNumber::fromString(version());
                auto const networkVersion = QVersionNumber::fromString(content);

                qCDebug(mainwindow_js8) << "Checking Version" << currentVersion
                                        << "with" << networkVersion;

                if (currentVersion < networkVersion) {

                    SelfDestructMessageBox *m = new SelfDestructMessageBox(
                        60, "New Updates Available",
                        QString("A new version (%1) of JS8Call is now "
                                "available. Please see the <a "
                                "href='https://github.com/JS8Call-improved/"
                                "JS8Call-improved/releases'>GitHub "
                                "Releases</a> for more details.")
                            .arg(content),
                        QMessageBox::Information, QMessageBox::Ok,
                        QMessageBox::Ok, false, this);

                    m->show();

                } else if (alertOnUpToDate) {

                    SelfDestructMessageBox *m = new SelfDestructMessageBox(
                        60, "No Updates Available",
                        QString("Your version (%1) of JS8Call is up-to-date.")
                            .arg(version()),
                        QMessageBox::Information, QMessageBox::Ok,
                        QMessageBox::Ok, false, this);

                    m->show();
                }
            });

    qCDebug(mainwindow_js8) << "Checking for Updates...";
    QUrl url("https://github.com/JS8Call-improved/JS8Call-improved/releases/"
             "latest/download/version.txt");
    QNetworkRequest r(url);
    m->get(r);
}

void MainWindow::checkStartupWarnings() {
    if (m_config.check_for_updates()) {
        checkVersion(false);
    }
    ensureCallsignSet(false);
}

void MainWindow::initializeDummyData() {
    if (!QApplication::applicationName().contains("dummy")) {
        return;
    }

#if 0
    Codeword all;
    foreach(CodewordPair p, JSC::compress("")){
        all.append(p.first);
    }
    qCDebug(mainwindow_js8) << all;
    qCDebug(mainwindow_js8) << JSC::decompress(all) << (JSC::decompress(all) == "HELLO WORLD ");
    exit(-1);
#endif

#if 0
    auto t = new QTimer(this);
    t->setInterval(150);
    connect(t, &QTimer::timeout, this, [this](){
        if(!ui->extFreeTextMsgEdit->hasFocus()){
            return;
        }

        auto c = ui->extFreeTextMsgEdit->textCursor();
        int pos = qMin(c.selectionStart(), c.selectionEnd());

        if(pos <= 5 && c.selectionStart() != c.selectionEnd()){
            c.clearSelection();
            ui->extFreeTextMsgEdit->setTextCursor(c);
        }
    });
    t->start();

    auto kpe = new KeyPressEater();
    connect(kpe, &KeyPressEater::keyPressed, this, [this](QObject *obj, QKeyEvent * e, bool *pProcessed){
        auto t = e->text();
        auto c = ui->extFreeTextMsgEdit->textCursor();
        int pos = qMin(c.selectionStart(), c.selectionEnd());

        if(e->key() == Qt::Key_Escape){
            *pProcessed = false;
            return;
        }

        QTextCursor cc(c);
        cc.setPosition(5);
        cc.movePosition(QTextCursor::NextWord);
        int cpos = qMax(cc.selectionStart(), cc.selectionEnd());
        if(e->key() == Qt::Key_Backspace && e->modifiers().testFlag(Qt::ControlModifier) && pos < cpos){
            *pProcessed = true;
            return;
        }

        if((e->key() == Qt::Key_Backspace && pos <= 5) ||
           (e->key() == Qt::Key_Delete && pos < 5)){
            *pProcessed = true;
            return;
        }

        if(e->key() == Qt::Key_V && e->modifiers().testFlag(Qt::ControlModifier) && pos <= 5){
            *pProcessed = true;
            return;
        }

        if(!t.isEmpty() && pos < 5){
            *pProcessed = true;
            return;
        }
    });
    ui->extFreeTextMsgEdit->installEventFilter(kpe);

    connect(ui->extFreeTextMsgEdit, &QTextEdit::copyAvailable, this, [this](bool available){
        if(!available){
            return;
        }
        auto c = ui->extFreeTextMsgEdit->textCursor();

        qCDebug(mainwindow_js8) << "select" << c.selectionStart() << c.selectionEnd();

        int pos = qMin(c.selectionStart(), c.selectionEnd());
        if(pos <= 5){
            auto text = c.selectedText();
            if(!text.isEmpty()){
                c.clearSelection();
                ui->extFreeTextMsgEdit->setTextCursor(c);
            }
        }
    });

    /*
    connect(ui->extFreeTextMsgEdit->document(), &QTextDocument::cursorPositionChanged, this, [this](const QTextCursor &){
        auto c = ui->extFreeTextMsgEdit->textCursor();
        int pos = qMin(c.selectionStart(), c.selectionEnd());
        if(pos <= 5){
            auto text = c.selectedText();
            if(!text.isEmpty()){
                c.clearSelection();
                ui->extFreeTextMsgEdit->setTextCursor(c);
            }
        }
    });

    connect(ui->extFreeTextMsgEdit, &QTextEdit::cursorPositionChanged, this, [this](){
        auto c = ui->extFreeTextMsgEdit->textCursor();
        int pos = qMin(c.selectionStart(), c.selectionEnd());

        if(pos <= 5){
            auto text = c.selectedText();
            if(!text.isEmpty()){
                c.clearSelection();
                ui->extFreeTextMsgEdit->setTextCursor(c);
            }
        }
    });
    */

    /*connect(ui->extFreeTextMsgEdit->document(), &QTextDocument::contentsChange, this, [this](int from, int removed, int added){
        if(from < 5 && removed == 1){
            ui->extFreeTextMsgEdit->document()->blockSignals(true);
            ui->extFreeTextMsgEdit->document()->undo();
            ui->extFreeTextMsgEdit->document()->clearUndoRedoStacks(QTextDocument::RedoStack);
            ui->extFreeTextMsgEdit->document()->blockSignals(false);
        }
    });*/

    ui->extFreeTextMsgEdit->setText("HELLO BRAVE NEW WORLD");
    auto c = ui->extFreeTextMsgEdit->textCursor();
    c.setPosition(0);
    c.setPosition(5, QTextCursor::KeepAnchor);
    auto f = c.charFormat();
    f.setFontStrikeOut(true);
    c.setCharFormat(f);
#endif

    // this causes a segfault!
    // processDecodedLine("223000 -15 -0.3 1681 B  6t++yk+aJbaE         6 \n");

    ui->extFreeTextMsgEdit->setPlainText("HELLOBRAVE NEW WORLD");
    ui->extFreeTextMsgEdit->setCharsSent(6);

    logHeardGraph("KN4CRD", "OH8STN");
    logHeardGraph("KN4CRD", "K0OG");
    logHeardGraph("K0OG", "KN4CRD");

    auto path = QDir::toNativeSeparators(
        m_config.writeable_data_dir().absoluteFilePath(QString("test.db3")));
    auto inbox = Inbox(path);
    if (inbox.open()) {
        qCDebug(mainwindow_js8) << "test inbox opened"
                                << inbox.count("test", "$", "%") << "messages";

        // int i = inbox.append(Message("test", "booya1"));

        int i = inbox.append(Message("test", "booya2"));
        qCDebug(mainwindow_js8) << "i" << i;

        qCDebug(mainwindow_js8) << inbox.set(i, Message("test", "booya3"));

        auto m = inbox.value(i);
        qCDebug(mainwindow_js8) << QString(m.toJson());

        qCDebug(mainwindow_js8) << inbox.del(i);

        foreach (auto pair, inbox.values("test", "$", "%", 0, 5)) {
            qCDebug(mainwindow_js8)
                << pair.first << QString(pair.second.toJson());
        }
    }

    auto d = DecodedText("SN5-lUuJkby0", Varicode::JS8CallFirst, 1);
    qCDebug(mainwindow_js8) << "KN4CRD: K0OG ===>" << d.message();

    // qCDebug(mainwindow_js8) << Varicode::isValidCallsign("@GROUP1", nullptr);
    // qCDebug(mainwindow_js8) << Varicode::packAlphaNumeric50("VE7/KN4CRD");
    // qCDebug(mainwindow_js8) <<
    // Varicode::unpackAlphaNumeric50(Varicode::packAlphaNumeric50("VE7/KN4CRD"));
    // qCDebug(mainwindow_js8) <<
    // Varicode::unpackAlphaNumeric50(Varicode::packAlphaNumeric50("@GROUP/42"));
    // qCDebug(mainwindow_js8) <<
    // Varicode::unpackAlphaNumeric50(Varicode::packAlphaNumeric50("SP1ATOM"));

    if (!m_config.my_groups().contains("@GROUP42")) {
        m_config.addGroup("@GROUP42");
    }

    QList<QString> calls = {"KN4CRD", "VE7/KN4CRD", "KN4CRD/P", "KC9QNE",
                            "KI6SSI", "K0OG",       "LB9YH",    "VE7/LB9YH",
                            "M0IAX",  "N0JDS",      "OH8STN",   "VA3OSO",
                            "VK1MIC", "W0FW"};

    auto dt = DriftingDateTime::currentDateTimeUtc().addSecs(-300);

    int i = 0;
    foreach (auto call, calls) {
        CallDetail cd = {};
        cd.call = call;
        cd.through = i == 2 ? "KN4CRD" : "";
        cd.dial = 7078000;
        cd.offset = 500 + 100 * i;
        cd.snr = i == 3 ? -100 : i;
        cd.ackTimestamp = i == 1 ? dt.addSecs(-900) : QDateTime{};
        cd.utcTimestamp = dt;
        cd.grid = i == 5 ? "J042" : i == 6 ? " FN42FN42FN" : "";
        cd.tdrift = 0.1 * i;
        cd.submode = i % 3;
        logCallActivity(cd, false);

        ActivityDetail ad = {};
        ad.bits = Varicode::JS8CallFirst | Varicode::JS8CallLast;
        ad.snr = i == 3 ? -100 : i;
        ad.dial = 7078000;
        ad.offset = 500 + 100 * i;
        ad.text = QString("%1: %2 TEST MESSAGE")
                      .arg(call)
                      .arg(m_config.my_callsign());
        ad.utcTimestamp = dt;
        ad.submode = cd.submode;
        m_bandActivity[500 + 100 * i] = {ad};

        markOffsetDirected(500 + 100 * i, false);

        i++;
    }

    ActivityDetail adHB1 = {};
    adHB1.bits = Varicode::JS8CallFirst;
    adHB1.snr = 0;
    adHB1.dial = 7078000;
    adHB1.offset = 750;
    adHB1.text = QString("KN4CRD: HB AUTO EM73");
    adHB1.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
    adHB1.submode = Varicode::JS8CallNormal;
    m_bandActivity[750].append(adHB1);

    ActivityDetail adHB2 = {};
    adHB2.bits = Varicode::JS8CallLast;
    adHB2.snr = 0;
    adHB2.dial = 7078000;
    adHB2.offset = 750;
    adHB2.text = QString(" MSG ID 1");
    adHB2.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
    adHB2.submode = Varicode::JS8CallNormal;
    m_bandActivity[750].append(adHB2);

    CommandDetail cmd = {};
    cmd.cmd = ">";
    cmd.to = m_config.my_callsign();
    cmd.from = "N0JDS";
    cmd.relayPath = "N0JDS>OH8STN";
    cmd.text = "HELLO BRAVE SOUL";
    cmd.utcTimestamp = dt;
    cmd.submode = Varicode::JS8CallNormal;
    addCommandToMyInbox(cmd);

    QString eot = m_config.eot();

    displayTextForFreq(QString("KN4CRD: @ALLCALL? %1 ").arg(eot), 42,
                       DriftingDateTime::currentDateTimeUtc().addSecs(-315),
                       true, true, true);
    displayTextForFreq(QString("J1Y: KN4CRD SNR -05 %1 ").arg(eot), 42,
                       DriftingDateTime::currentDateTimeUtc().addSecs(-300),
                       false, true, true);
    displayTextForFreq(QString("HELLO BRAVE  NEW   WORLD    %1 ").arg(eot), 42,
                       DriftingDateTime::currentDateTimeUtc().addSecs(-300),
                       false, true, true);

    auto now = DriftingDateTime::currentDateTimeUtc();
    displayTextForFreq(QString("KN4CRD: JY1 ACK -12 %1 ").arg(eot), 780, now,
                       false, true, true);
    displayTextForFreq(QString("KN4CRD: JY1 ACK -12 %1 ").arg(eot), 780, now,
                       false, true, true); // should be hidden (duplicate)
    displayTextForFreq(QString("OH8STN: JY1 ACK -12 %1 ").arg(eot), 780, now,
                       false, true, true);

    displayTextForFreq(QString("KN4CRD: JY1 ACK -10 %1 ").arg(eot), 800, now,
                       false, true, true);
    displayTextForFreq(QString("KN4CRD: JY1 ACK -12 %1 ").arg(eot), 780,
                       now.addSecs(120), false, true, true);

    displayTextForFreq(QString("HELLO\\nBRAVE\\nNEW\\nWORLD %1 ").arg(eot),
                       1500, now, false, true, true);

    displayActivity(true);
}

void MainWindow::initializeGroupMessageDummyData() {
    if (!QApplication::applicationName().contains("dummy")) {
        return;
    }

    if (!m_config.my_groups().contains("@GROUP42")) {
        m_config.addGroup("@GROUP42");
    }

    auto dt = DriftingDateTime::currentDateTimeUtc().addSecs(-300);

    CommandDetail cmd = {};
    cmd.cmd = " MSG TO:";
    cmd.from = "KN4CRD";
    cmd.to = "@GROUP42";
    cmd.utcTimestamp = dt;
    cmd.submode = Varicode::JS8CallNormal;
    cmd.text = "@GROUP42 TEST MESSAGE TO GROUP";

    m_rxCommandQueue.append(cmd);

    processCommandActivity();

    dt = DriftingDateTime::currentDateTimeUtc().addSecs(-150);

    CommandDetail cmd1 = {};
    cmd1.cmd = " MSG TO:";
    cmd1.from = "KN4CRD";
    cmd1.to = "@GROUP42";
    cmd1.utcTimestamp = dt;
    cmd1.submode = Varicode::JS8CallNormal;
    cmd1.text = "@GROUP42 ANOTHER TEST MESSAGE TO GROUP";

    m_rxCommandQueue.append(cmd1);

    processCommandActivity();

    CommandDetail cmd2 = {};
    cmd2.cmd = " QUERY MSGS";
    cmd2.from = "W1AW";
    cmd2.to = "@GROUP42";
    cmd2.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
    cmd2.submode = Varicode::JS8CallNormal;

    m_rxCommandQueue.append(cmd2);

    processCommandActivity();

    int mid = getNextGroupMessageIdForCallsign("@GROUP42", "W1AW");

    qCDebug(mainwindow_js8) << "Testing group messaging";
    qCDebug(mainwindow_js8) << "Test message ID: " << mid;

    std::string textString = "MSG ";
    textString += std::to_string(mid);

    qCDebug(mainwindow_js8) << "Text string: " << textString.c_str();

    CommandDetail cmd3 = {};
    cmd3.cmd = " QUERY";
    cmd3.from = "W1AW";
    cmd3.to = "@GROUP42";
    cmd3.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
    cmd3.submode = Varicode::JS8CallNormal;
    cmd3.text = textString.c_str();

    m_rxCommandQueue.append(cmd3);

    processCommandActivity();

    dt = DriftingDateTime::currentDateTimeUtc().addSecs(-300);

    CommandDetail cmd4 = {};
    cmd4.cmd = " MSG TO:";
    cmd4.from = "KN4CRD";
    cmd4.to = "K4RWR";
    cmd4.utcTimestamp = dt;
    cmd4.submode = Varicode::JS8CallNormal;
    cmd4.text = "W1AW TEST MESSAGE TO STATION";

    m_rxCommandQueue.append(cmd4);

    processCommandActivity();

    dt = DriftingDateTime::currentDateTimeUtc().addSecs(-150);

    CommandDetail cmd5 = {};
    cmd5.cmd = " MSG TO:";
    cmd5.from = "KN4CRD";
    cmd5.to = "K4RWR";
    cmd5.utcTimestamp = dt;
    cmd5.submode = Varicode::JS8CallNormal;
    cmd5.text = "W1AW ANOTHER TEST MESSAGE TO STATION";

    m_rxCommandQueue.append(cmd5);

    processCommandActivity();

    CommandDetail cmd6 = {};
    cmd6.cmd = " QUERY MSGS";
    cmd6.from = "W1AW";
    cmd6.to = "K4RWR";
    cmd6.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
    cmd6.submode = Varicode::JS8CallNormal;

    m_rxCommandQueue.append(cmd6);

    processCommandActivity();

    mid = getNextMessageIdForCallsign("W1AW");

    qCDebug(mainwindow_js8) << "Testing group messaging";
    qCDebug(mainwindow_js8) << "Test message ID: " << mid;

    textString = "MSG ";
    textString += std::to_string(mid);

    qCDebug(mainwindow_js8) << "Text string: " << textString.c_str();

    CommandDetail cmd7 = {};
    cmd7.cmd = " QUERY";
    cmd7.from = "W1AW";
    cmd7.to = "K4RWR";
    cmd7.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
    cmd7.submode = Varicode::JS8CallNormal;
    cmd7.text = textString.c_str();

    m_rxCommandQueue.append(cmd7);

    processCommandActivity();

    displayActivity(true);
}

void MainWindow::initialize_fonts() {
    set_application_font(m_config.text_font());

    setTextEditFont(ui->textEditRX, m_config.rx_text_font());
    setTextEditFont(ui->extFreeTextMsgEdit, m_config.tx_text_font());

    displayActivity(true);
}

void MainWindow::on_the_minute() {
    if (minuteTimer.isSingleShot()) {
        minuteTimer.setSingleShot(false);
        minuteTimer.start(60 * 1000); // run free
    } else {
        auto const &ms_error = ms_minute_error();
        if (qAbs(ms_error) > 1000) // keep drift within +-1s
        {
            minuteTimer.setSingleShot(true);
            minuteTimer.start(ms_error + 60 * 1000);
        }
    }

    if (m_config.watchdog()) {
        incrementIdleTimer();
    } else {
        tx_watchdog(false);
    }
}

void MainWindow::tryBandHop() {
    // see if we need to hop bands...
    if (!m_config.auto_switch_bands()) {
        return;
    }

    // make sure we're not transmitting
    if (isMessageQueuedForTransmit()) {
        return;
    }

    // get the current band
    auto dialFreq = dialFrequency();

    auto currentBand = m_config.bands()->find(dialFreq);

    // get the stations list
    auto stations = m_config.stations()->station_list();

    // order stations by (switch_at, switch_until) time tuple
    std::stable_sort(
        stations.begin(), stations.end(),
        [](StationList::Station const &a, StationList::Station const &b) {
            return (a.switch_at_ < b.switch_at_) ||
                   (a.switch_at_ == b.switch_at_ &&
                    a.switch_until_ < b.switch_until_);
        });

    // we just set the date to a known y/m/d to make the comparisons easier
    QDateTime d = DriftingDateTime::currentDateTimeUtc();
    d.setDate(QDate(2000, 1, 1));

    QDateTime startOfDay =
        QDateTime(QDate(2000, 1, 1), QTime(0, 0), QTimeZone::utc());
    QDateTime endOfDay =
        QDateTime(QDate(2000, 1, 1), QTime(23, 59, 59, 999), QTimeZone::utc());

    StationList::Station *hopStation = nullptr;

    // See if we can find a needed band switch...
    // In the case of overlapping windows, choose the latest one
    foreach (auto station, stations) {
        // we can switch to this frequency if we're in the time range, inclusive
        // of switch_at, exclusive of switch_until and if we are switching to a
        // different frequency than the last hop. this allows us to switch bands
        // at that time, but then later we can later switch to a different band
        // if needed without the automatic band switching to take over
        bool inTimeRange =
            ((station.switch_at_ <= d &&
              d <= station.switch_until_) || // <- normal range, 12-16 && 6-8,
                                             // evaluated as 12 <= d <= 16 || 6
                                             // <= d <= 8

             (station.switch_until_ < station.switch_at_ &&
              ( // <- say for a range of 12->2 & 2->12;  12->2,
                  (station.switch_at_ <= d &&
                   d <= endOfDay) || //    should be evaluated as 12 <= d <=
                                     //    23:59 || 00:00 <= d <= 2
                  (startOfDay <= d && d <= station.switch_until_))));

        if (inTimeRange) {
            delete hopStation;
            hopStation = new StationList::Station(station);
        }
    }

    // If we have a candidate station, see if the hop is valid, and if so, do it
    if (hopStation != nullptr) {
        bool noOverride =
            (m_bandHopped ||
             (!m_bandHopped && hopStation->frequency_ != m_bandHoppedFreq));

        bool freqIsDifferent = (hopStation->frequency_ != dialFreq);

        bool canSwitch = (noOverride && freqIsDifferent);

        // switch, if we can and the band is different than our current band
        if (canSwitch) {
            Frequency frequency = hopStation->frequency_;

            m_bandHopped = false;
            m_bandHoppedFreq = frequency;

            SelfDestructMessageBox *m = new SelfDestructMessageBox(
                30, "Scheduled Frequency Change",
                QString("A scheduled frequency change has arrived. The rig "
                        "frequency will be changed to %1 MHz in %2 second(s).")
                    .arg(Radio::frequency_MHz_string(frequency)),
                QMessageBox::Information, QMessageBox::Ok | QMessageBox::Cancel,
                QMessageBox::Ok, true, this);

            connect(m, &SelfDestructMessageBox::finished, this,
                    [this, m, frequency]() {
                        if (m->result() == QMessageBox::Ok) {
                            m_bandHopped = true;
                            setRig(frequency);
                        }
                        m->deleteLater();
                    });

            m->show();

#if 0
		  // TODO: jsherer - this is totally a hack because of the signal that gets emitted to clearActivity on band change...
          QTimer *t = new QTimer(this);
          t->setInterval(250);
          t->setSingleShot(true);
          connect(t, &QTimer::timeout, this, [this, frequency, dialFreq](){
              auto message = QString("Scheduled frequency switch from %1 MHz to %2 MHz");
              message = message.arg(Radio::frequency_MHz_string(dialFreq));
              message = message.arg(Radio::frequency_MHz_string(frequency));
              writeNoticeTextToUI(DriftingDateTime::currentDateTimeUtc(), message);
          });
          t->start();
#endif

            return;
        }

        delete hopStation;
    }
}

void MainWindow::manualBandHop(const StationList::Station station) {
    // make sure we're not transmitting
    if (isMessageQueuedForTransmit()) {
        return;
    }

    Frequency frequency = station.frequency_;

    m_bandHopped = true;
    m_bandHoppedFreq = frequency;
    setRig(frequency);
}

//--------------------------------------------------- MainWindow destructor
MainWindow::~MainWindow() {
    {
        std::lock_guard<std::mutex> lock(fftw_mutex);
        fftwf_export_wisdom_to_filename(wisdomFileName());
    }

    m_networkThread.quit();
    m_networkThread.wait();

    m_audioThread.quit();
    m_audioThread.wait();

    m_notificationAudioThread.quit();
    m_notificationAudioThread.wait();

    m_decoder.quit();

    remove_child_from_event_filter(this);
}

//-------------------------------------------------------- writeSettings()
void MainWindow::writeSettings() {
    m_settings->beginGroup("MainWindow");
    m_settings->setValue("geometry", saveGeometry());
    m_settings->setValue("geometryNoControls", m_geometryNoControls);
    m_settings->setValue("state", saveState());

    m_settings->setValue("MainSplitter", ui->mainSplitter->saveState());
    m_settings->setValue("TextHorizontalSplitter",
                         ui->textHorizontalSplitter->saveState());
    m_settings->setValue("BandActivityVisible",
                         ui->tableWidgetRXAll->isVisible());
    m_settings->setValue("BandHBActivityVisible",
                         ui->actionShow_Band_Heartbeats_and_ACKs->isChecked());
    m_settings->setValue("TextVerticalSplitter",
                         ui->textVerticalSplitter->saveState());
    m_settings->setValue("TimeDrift", DriftingDateTime::drift());
    m_settings->setValue("ShowTooltips", ui->actionShow_Tooltips->isChecked());
    m_settings->setValue("ShowStatusbar", ui->statusBar->isVisible());
    m_settings->setValue("RXActivity", ui->textEditRX->toHtml());

    m_settings->endGroup();

    m_settings->beginGroup("Common");
    m_settings->setValue("Freq", freq());
    m_settings->setValue("SubMode", m_nSubMode);
    m_settings->setValue("SubModeHB", ui->actionModeJS8HB->isChecked());
    m_settings->setValue("SubModeHBAck",
                         ui->actionHeartbeatAcknowledgements->isChecked());
    m_settings->setValue("SubModeMultiDecode",
                         ui->actionModeMultiDecoder->isChecked());
    m_settings->setValue("DialFreq",
                         QVariant::fromValue(m_lastMonitoredFrequency));
    m_settings->setValue("OutAttenuation", ui->outAttenuation->value());
    m_settings->setValue("pwrBandTxMemory", m_pwrBandTxMemory);
    m_settings->setValue("pwrBandTuneMemory", m_pwrBandTuneMemory);
    m_settings->setValue("SortBy", QVariant(m_sortCache));
    m_settings->setValue("ShowColumns", QVariant(m_showColumnsCache));
    m_settings->setValue("HBInterval", m_hbInterval);
    m_settings->setValue("CQInterval", m_cqInterval);

    // TODO: jsherer - need any other customizations?
    /*m_settings->setValue("PanelLeftGeometry",
    ui->tableWidgetRXAll->geometry());
    m_settings->setValue("PanelRightGeometry",
    ui->tableWidgetCalls->geometry()); m_settings->setValue("PanelTopGeometry",
    ui->extFreeTextMsg->geometry()); m_settings->setValue("PanelBottomGeometry",
    ui->extFreeTextMsgEdit->geometry());
    m_settings->setValue("PanelWaterfallGeometry",
    ui->bandHorizontalWidget->geometry());*/
    // m_settings->setValue("MainSplitter",
    // QVariant::fromValue(ui->mainSplitter->sizes()));

    m_settings->endGroup();

    auto now = DriftingDateTime::currentDateTimeUtc();
    int callsignAging = m_config.callsign_aging();

    m_settings->beginGroup("CallActivity");
    m_settings->remove(""); // remove all keys in current group
    foreach (auto cd, m_callActivity.values()) {
        if (cd.call.trimmed().isEmpty()) {
            continue;
        }
        if (callsignAging &&
            cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
            continue;
        }
        m_settings->setValue(
            cd.call.trimmed(),
            QVariantMap{
                {"snr", QVariant(cd.snr)},
                {"grid", QVariant(cd.grid)},
                {"dial", QVariant(cd.dial)},
                {"freq", QVariant(cd.offset)},
                {"tdrift", QVariant(cd.tdrift)},
#if CACHE_CALL_DATETIME_AS_STRINGS
                {"ackTimestamp",
                 QVariant(cd.ackTimestamp.toString("yyyy-MM-dd hh:mm:ss"))},
                {"utcTimestamp",
                 QVariant(cd.utcTimestamp.toString("yyyy-MM-dd hh:mm:ss"))},
#else
                {"ackTimestamp", QVariant(cd.ackTimestamp)},
                {"utcTimestamp", QVariant(cd.utcTimestamp)},
#endif
                {"submode", QVariant(cd.submode)},
            });
    }
    m_settings->endGroup();
}

//---------------------------------------------------------- readSettings()
void MainWindow::readSettings() {
    m_settings->beginGroup("MainWindow");
    setMinimumSize(800, 400);
    restoreGeometry(
        m_settings->value("geometry", saveGeometry()).toByteArray());
    setMinimumSize(800, 400);

    m_geometryNoControls =
        m_settings->value("geometryNoControls", saveGeometry()).toByteArray();
    restoreState(m_settings->value("state", saveState()).toByteArray());

    auto mainSplitterState = m_settings->value("MainSplitter").toByteArray();
    if (!mainSplitterState.isEmpty()) {
        ui->mainSplitter->restoreState(mainSplitterState);
    }
    auto horizontalState =
        m_settings->value("TextHorizontalSplitter").toByteArray();
    if (!horizontalState.isEmpty()) {
        ui->textHorizontalSplitter->restoreState(horizontalState);
        auto hsizes = ui->textHorizontalSplitter->sizes();

        ui->tableWidgetRXAll->setVisible(hsizes.at(0) > 0);
        ui->tableWidgetCalls->setVisible(hsizes.at(2) > 0);
    }

    m_bandActivityWasVisible =
        m_settings->value("BandActivityVisible", true).toBool();
    ui->tableWidgetRXAll->setVisible(m_bandActivityWasVisible);

    auto verticalState =
        m_settings->value("TextVerticalSplitter").toByteArray();
    if (!verticalState.isEmpty()) {
        ui->textVerticalSplitter->restoreState(verticalState);
    }
    setDrift(m_settings->value("TimeDrift", 0).toInt());
    ui->actionShow_Waterfall_Controls->setChecked(
        m_wideGraph->controlsVisible());
    ui->actionShow_Waterfall_Time_Drift_Controls->setChecked(
        m_wideGraph->timeControlsVisible());
    ui->actionShow_Tooltips->setChecked(
        m_settings->value("ShowTooltips", true).toBool());
    ui->actionShow_Statusbar->setChecked(
        m_settings->value("ShowStatusbar", true).toBool());
    ui->statusBar->setVisible(ui->actionShow_Statusbar->isChecked());
    ui->textEditRX->setHtml(
        m_config.reset_activity()
            ? ""
            : m_settings->value("RXActivity", "").toString());
    ui->actionShow_Band_Heartbeats_and_ACKs->setChecked(
        m_settings->value("BandHBActivityVisible", true).toBool());
    m_settings->endGroup();

    m_settings->beginGroup("Common");

    // set the frequency offset
    setFreqOffsetForRestore(
        m_settings->value("Freq", Default::FREQUENCY).toInt(), false); // XXX

    setSubmode(m_settings->value("SubMode", Default::SUBMODE).toInt());
    ui->actionModeJS8HB->setChecked(
        m_settings->value("SubModeHB", false).toBool());
    ui->actionHeartbeatAcknowledgements->setChecked(
        m_settings->value("SubModeHBAck", false).toBool());
    ui->actionModeMultiDecoder->setChecked(
        m_settings->value("SubModeMultiDecode", true).toBool());

    m_lastMonitoredFrequency =
        m_settings
            ->value("DialFreq",
                    QVariant::fromValue<Frequency>(Default::DIAL_FREQUENCY))
            .value<Frequency>();
    setFreq(0); // ensure a change is signaled
    setFreq(m_settings->value("Freq", Default::FREQUENCY).toInt());
    // setup initial value of tx attenuator
    m_block_pwr_tooltip = true;
    ui->outAttenuation->setValue(
        m_settings->value("OutAttenuation", 0).toInt());
    m_block_pwr_tooltip = false;
    m_pwrBandTxMemory = m_settings->value("pwrBandTxMemory").toHash();
    m_pwrBandTuneMemory = m_settings->value("pwrBandTuneMemory").toHash();

    m_sortCache = m_settings->value("SortBy").toMap();
    m_showColumnsCache = m_settings->value("ShowColumns").toMap();
    m_hbInterval = m_settings->value("HBInterval", 0).toInt();
    m_cqInterval = m_settings->value("CQInterval", 0).toInt();

    // TODO: jsherer - any other customizations?
    // ui->mainSplitter->setSizes(m_settings->value("MainSplitter",
    // QVariant::fromValue(ui->mainSplitter->sizes())).value<QList<int> >());
    // ui->tableWidgetRXAll->restoreGeometry(m_settings->value("PanelLeftGeometry",
    // ui->tableWidgetRXAll->saveGeometry()).toByteArray());
    // ui->tableWidgetCalls->restoreGeometry(m_settings->value("PanelRightGeometry",
    // ui->tableWidgetCalls->saveGeometry()).toByteArray());
    // ui->extFreeTextMsg->setGeometry( m_settings->value("PanelTopGeometry",
    // ui->extFreeTextMsg->geometry()).toRect());
    // ui->extFreeTextMsgEdit->setGeometry(
    // m_settings->value("PanelBottomGeometry",
    // ui->extFreeTextMsgEdit->geometry()).toRect());
    // ui->bandHorizontalWidget->setGeometry(
    // m_settings->value("PanelWaterfallGeometry",
    // ui->bandHorizontalWidget->geometry()).toRect()); qCDebug(mainwindow_js8)
    // << m_settings->value("PanelTopGeometry") << ui->extFreeTextMsg;

    setTextEditStyle(ui->textEditRX, m_config.color_rx_foreground(),
                     m_config.color_rx_background(), m_config.rx_text_font());
    setTextEditStyle(
        ui->extFreeTextMsgEdit, m_config.color_compose_foreground(),
        m_config.color_compose_background(), m_config.compose_text_font());
    ui->extFreeTextMsgEdit->setFont(m_config.compose_text_font(),
                                    m_config.color_compose_foreground(),
                                    m_config.color_compose_background());

    m_settings->endGroup();

    // use these initialisation settings to tune the audio o/p buffer
    // size and audio thread priority
    m_settings->beginGroup("Tune");
    m_msAudioOutputBuffered = m_settings->value("Audio/OutputBufferMs").toInt();
    m_framesAudioInputBuffered =
        m_settings->value("Audio/InputBufferFrames", JS8_RX_SAMPLE_RATE / 10)
            .toInt();
    m_audioThreadPriority = static_cast<QThread::Priority>(
        m_settings->value("Audio/ThreadPriority", QThread::TimeCriticalPriority)
            .toInt() %
        8);
    m_notificationAudioThreadPriority = static_cast<QThread::Priority>(
        m_settings
            ->value("Audio/NotificationThreadPriority", QThread::LowPriority)
            .toInt() %
        8);
    m_decoderThreadPriority = static_cast<QThread::Priority>(
        m_settings->value("Audio/DecoderThreadPriority", QThread::HighPriority)
            .toInt() %
        8);
    m_networkThreadPriority = static_cast<QThread::Priority>(
        m_settings->value("Network/NetworkThreadPriority", QThread::LowPriority)
            .toInt() %
        8);
    m_settings->endGroup();

    if (m_config.reset_activity()) {
        // NOOP
    } else {
        m_settings->beginGroup("CallActivity");
        foreach (auto call, m_settings->allKeys()) {

            auto values = m_settings->value(call).toMap();

            auto snr = values.value("snr", -64).toInt();
            auto grid = values.value("grid", "").toString();
            auto dial = values.value("dial", 0).toInt();
            auto freq = values.value("freq", 0).toInt();
            auto tdrift = values.value("tdrift", 0).toFloat();

#if CACHE_CALL_DATETIME_AS_STRINGS
            auto ackTimestampStr = values.value("ackTimestamp", "").toString();
            auto ackTimestamp =
                QDateTime::fromString(ackTimestampStr, "yyyy-MM-dd hh:mm:ss");
            ackTimestamp.setUtcOffset(0);

            auto utcTimestampStr = values.value("utcTimestamp", "").toString();
            auto utcTimestamp =
                QDateTime::fromString(utcTimestampStr, "yyyy-MM-dd hh:mm:ss");
            utcTimestamp.setUtcOffset(0);
#else
            auto ackTimestamp = values.value("ackTimestamp").toDateTime();
            auto utcTimestamp = values.value("utcTimestamp").toDateTime();
#endif
            auto submode =
                values.value("submode", Varicode::JS8CallNormal).toInt();

            CallDetail cd = {};
            cd.call = call;
            cd.snr = snr;
            cd.grid = grid;
            cd.dial = dial;
            cd.offset = freq;
            cd.tdrift = tdrift;
            cd.ackTimestamp = ackTimestamp;
            cd.utcTimestamp = utcTimestamp;
            cd.submode = submode;

            logCallActivity(cd, false);
        }
        m_settings->endGroup();
    }

    m_settings_read = true;
}

void MainWindow::set_application_font(QFont const &font) {
    qApp->setFont(font);
    // set font in the application style sheet as well in case it has
    // been modified in the style sheet which has priority
    qApp->setStyleSheet(qApp->styleSheet() + "* {" + font_as_stylesheet(font) +
                        '}');
    for (auto &widget : qApp->topLevelWidgets()) {
        widget->updateGeometry();
    }
}

//-------------------------------------------------------------- dataSink()
void MainWindow::dataSink(qint64 frames) {
    constexpr int NMAX = JS8_NTMAX * 12000;
    constexpr int nfft3 = 16384;
    constexpr std::array nch = {1, 2, 4, 9, 18, 36, 72};

    // symspec global vars
    static int ja = 0;
    static int k0 = 999999999;
    static WF::SPlot ssum = {};
    static WF::SPlot s = {};

    int k(frames);
    if (k0 == 999999999) {
        m_ihsym = int((float)frames / (float)JS8_NSPS) * 2;
        ja = k;
        k0 = k;
    }

    // qCDebug(mainwindow_js8) << "k" << k << "k0" << k0 << "delta" << k-k0;

    // Get power, spectrum, and ihsym
    int const nsmo = m_wideGraph->smoothYellow() - 1;

    // make sure the ssum global is reset every period cycle
    static int lastCycle = -1;
    int const cycle = JS8::Submode::computeCycleForDecode(m_nSubMode, k);

    if (cycle != lastCycle) {
        qCDebug(decoder_js8) << "period loop, resetting ssum";
        ssum.fill(0.0f);
    }

    lastCycle = cycle;

    // cap ihsym based on the period max
    m_ihsym = m_ihsym % (m_TRperiod * JS8_RX_SAMPLE_RATE / JS8_NSPS * 2);

    int const jstep = JS8_NSPS / 2;

    if (k >= 2048 && k <= NMAX) {
        if (k < k0) {
            // Start a new data block
            ja = 0;
            ssum.fill(0.0f);
            m_ihsym = 0;
            std::fill(std::begin(dec_data.d2) + k, std::end(dec_data.d2), 0);
        }

        float gain = pow(10.0f, 0.1f * m_inGain);
        float sq = 0.0f;
        float pxmax = 0.0f;

        for (int i = k0; i < k; ++i) {
            float x1 = dec_data.d2[i];
            pxmax = std::max(pxmax, fabs(x1));
            sq += x1 * x1;
        }

        m_px = sq > 0.0f ? 10.0f * log10(sq / (k - k0)) : 0.0f;
        m_pxmax = pxmax > 0.0f ? 20.0f * log10(pxmax) : 0.0f;

        k0 = k;
        ja += jstep;

        // Perform real to complex FFT; note that the Fortran code performed
        // this operation in the four2a subroutine, which contains a cache
        // of plans. However, since the FFTW3 library itself caches, this
        // resulted in double caching, so as an experiment we're allowing
        // the library to handle caching.
        //
        // Note that the FFTW library requires all calls but for the plan
        // execution, i.e., fftwf_execute(), to be serialized. The library
        // doesn't require prescriptive serialization, anything will do so
        // long as the result is one thread at a time.
        //
        // Providing room for an extra complex value, i.e., a pair of floats,
        // real and imaginary parts, allows us to use the same buffer for the
        // FFT input and output.
        //
        // While the memory for the FFT can come from anywhere, if we ask the
        // library for it, it'll guarantee that it's aligned for use of SIMD
        // instructions, which will in turn allow it to use them.

        fftwf_complex *fftw_complex;
        float *fftw_real;
        fftwf_plan fftw_plan;
        {
            std::lock_guard<std::mutex> lock(fftw_mutex);

            fftw_complex = fftwf_alloc_complex(nfft3 / 2 + 1);

            if (!fftw_complex) {
                throw std::runtime_error("Failed to allocate FFT data");
            }

            fftw_real = reinterpret_cast<float *>(fftw_complex);
            fftw_plan = fftwf_plan_dft_r2c_1d(nfft3, fftw_real, fftw_complex,
                                              FFTW_ESTIMATE_PATIENT);

            if (!fftw_plan) {
                fftwf_free(fftw_complex);
                throw std::runtime_error("Failed to create FFT plan");
            }
        }

        // Copy data and apply the window, then execute the FFT.

        for (int i = 0; i < nfft3; ++i) {
            if (int j = ja + i - nfft3; j >= 0 && j < NMAX)
                fftw_real[i] = 0.1f * dec_data.d2[j];
        }

        ++m_ihsym;

        fftwf_execute(fftw_plan);

        // Process the resulting spectrum.

        m_df3 = 12000.0f / nfft3;

        auto const iz = std::min(JS8_NSMAX, static_cast<int>(5000.0f / m_df3));
        auto const cx = reinterpret_cast<std::complex<float> *>(fftw_complex);
        auto const fac = std::pow(1.0f / nfft3, 2.0f);

        for (int i = 0; i < iz; ++i) {
            auto const sx = fac * std::norm(cx[i]);
            ssum[i] += sx;
            s[i] = 1000.0f * gain * sx;
        }

        // We're now done with the plan and the FFT storage. Even though
        // the plan is being 'destroyed' here, the library will cache it
        // at its discretion. As above, the calls must be serialized.

        {
            std::lock_guard<std::mutex> lock(fftw_mutex);
            fftwf_destroy_plan(fftw_plan);
            fftwf_free(fftw_complex);
        }

        // Update average spectra.

        for (int i = 0; i < iz; ++i)
            specData.savg[i] = ssum[i] / m_ihsym;

        if (m_ihsym % 10 == 0) {
            auto const mode4 = nch[nsmo];
            auto const nsmo = 4 * std::min(10 * mode4, 150);

            flat1(specData.savg, iz, nsmo, specData.slin);

            if (mode4 >= 2) {
                std::array<float, JS8_NSMAX> tmp;

                smo(specData.slin, tmp.data(), iz, mode4);
                smo(tmp.data(), specData.slin, iz, mode4);
            }

            std::fill(std::begin(specData.slin),
                      std::begin(specData.slin) + 250, 0.0f);

            auto const ia = static_cast<int>(500.0 / m_df3);
            auto const ib = static_cast<int>(2700.0 / m_df3);
            auto const smin = *std::min_element(std::begin(specData.slin) + ia,
                                                std::begin(specData.slin) + ib);
            auto const smax = *std::max_element(std::begin(specData.slin),
                                                std::begin(specData.slin) + iz);
            auto const scale = (smax > smin) ? 50.0f / (smax - smin) : 0.0f;

            for (auto &val : specData.slin)
                val = std::max(0.0f, scale * (val - smin));
        }
    } else if (k < 2048)
        m_ihsym = 0;

    // make sure ja is equal to k so if we jump ahead in the buffer, everything
    // resolves correctly
    ja = k;

    if (m_ihsym <= 0)
        return;

    if (ui)
        ui->signal_meter_widget->setValue(m_px, m_pxmax); // Update thermometer

    if (m_monitoring)
        m_wideGraph->dataSink(s, m_df3);

    decode(k);
}

void MainWindow::showSoundInError(const QString &errorMsg) {
    JS8MessageBox::critical_message(this, tr("Error in Sound Input"), errorMsg);
}

void MainWindow::showSoundOutError(const QString &errorMsg) {
    JS8MessageBox::critical_message(this, tr("Error in Sound Output"),
                                    errorMsg);
}

void MainWindow::showStatusMessage(const QString &statusMsg) {
    statusBar()->showMessage(statusMsg, 5000);
}

void MainWindow::on_menuModeJS8_aboutToShow() {
    bool canChangeMode =
        !m_transmitting && m_txFrameCount == 0 && m_txFrameQueue.isEmpty();
    ui->actionModeJS8Normal->setEnabled(canChangeMode);
    ui->actionModeJS8Fast->setEnabled(canChangeMode);
    ui->actionModeJS8Turbo->setEnabled(canChangeMode);
    ui->actionModeJS8Slow->setEnabled(canChangeMode);
    ui->actionModeJS8Ultra->setEnabled(canChangeMode);

    // dynamically replace the autoreply menu item text
    auto autoreplyText = ui->actionModeAutoreply->text();
    if (m_config.autoreply_confirmation() &&
        !autoreplyText.contains(" with Confirmation")) {
        autoreplyText.replace("Autoreply", "Autoreply with Confirmation");
        autoreplyText.replace("&AUTO", "&AUTO+CONF");
        ui->actionModeAutoreply->setText(autoreplyText);
    } else if (!m_config.autoreply_confirmation() &&
               autoreplyText.contains(" with Confirmation")) {
        autoreplyText.replace(" with Confirmation", "");
        autoreplyText.replace("+CONF", "");
        ui->actionModeAutoreply->setText(autoreplyText);
    }
}

void MainWindow::on_menuControl_aboutToShow() {
    auto freqMenu = new QMenu(this->menuBar());
    buildFrequencyMenu(freqMenu);
    ui->actionSetFrequency->setMenu(freqMenu);

    auto heartbeatMenu = new QMenu(this->menuBar());
    buildHeartbeatMenu(heartbeatMenu);
    ui->actionHeartbeat->setMenu(heartbeatMenu);

    auto cqMenu = new QMenu(this->menuBar());
    buildCQMenu(cqMenu);
    ui->actionCQ->setMenu(cqMenu);

    ui->actionEnable_Monitor_RX->setChecked(ui->monitorButton->isChecked());
    ui->actionEnable_Transmitter_TX->setChecked(
        ui->monitorTxButton->isChecked());
    ui->actionEnable_Reporting_SPOT->setChecked(ui->spotButton->isChecked());
    ui->actionEnable_Tuning_Tone_TUNE->setChecked(ui->tuneButton->isChecked());
}

void MainWindow::on_actionCheck_for_Updates_triggered() { checkVersion(true); }

void MainWindow::on_actionUser_Guide_triggered() {
    QDesktopServices::openUrl(
        QUrl("https://js8call-improved.com/downloads/JS8Call_User_Guide.pdf"));
}

void MainWindow::on_actionEnable_Monitor_RX_toggled(bool checked) {
    ui->monitorButton->setChecked(checked);
}

void MainWindow::on_actionEnable_Transmitter_TX_toggled(bool checked) {
    ui->monitorTxButton->setChecked(checked);
}

void MainWindow::on_actionEnable_Reporting_SPOT_toggled(bool checked) {
    ui->spotButton->setChecked(checked);
}

void MainWindow::on_actionEnable_Tuning_Tone_TUNE_toggled(bool checked) {
    ui->tuneButton->setChecked(checked);
    on_tuneButton_clicked(checked);
}

void MainWindow::on_menuWindow_aboutToShow() {
    ui->actionShow_Fullscreen->setChecked(
        (windowState() & Qt::WindowFullScreen) == Qt::WindowFullScreen);

    ui->actionShow_Statusbar->setChecked(ui->statusBar &&
                                         ui->statusBar->isVisible());

    auto hsizes = ui->textHorizontalSplitter->sizes();
    ui->actionShow_Band_Activity->setChecked(hsizes.at(0) > 0);
    ui->actionShow_Call_Activity->setChecked(hsizes.at(2) > 0);

    auto vsizes = ui->mainSplitter->sizes();
    ui->actionShow_Frequency_Clock->setChecked(vsizes.first() > 0);
    ui->actionShow_Waterfall->setChecked(vsizes.last() > 0);
    ui->actionShow_Waterfall_Controls->setChecked(
        ui->actionShow_Waterfall->isChecked() &&
        m_wideGraph->controlsVisible());
    ui->actionShow_Waterfall_Time_Drift_Controls->setChecked(
        ui->actionShow_Waterfall->isChecked() &&
        m_wideGraph->timeControlsVisible());

    QMenu *sortBandMenu = new QMenu(this->menuBar()); // ui->menuWindow);
    buildBandActivitySortByMenu(sortBandMenu);
    ui->actionSort_Band_Activity->setMenu(sortBandMenu);
    ui->actionSort_Band_Activity->setEnabled(
        ui->actionShow_Band_Activity->isChecked());

    QMenu *sortCallMenu = new QMenu(this->menuBar()); // ui->menuWindow);
    buildCallActivitySortByMenu(sortCallMenu);
    ui->actionSort_Call_Activity->setMenu(sortCallMenu);
    ui->actionSort_Call_Activity->setEnabled(
        ui->actionShow_Call_Activity->isChecked());

    QMenu *showBandMenu = new QMenu(this->menuBar()); // ui->menuWindow);
    buildShowColumnsMenu(showBandMenu, "band");
    ui->actionShow_Band_Activity_Columns->setMenu(showBandMenu);
    ui->actionShow_Band_Activity_Columns->setEnabled(
        ui->actionShow_Band_Activity->isChecked());

    QMenu *showCallMenu = new QMenu(this->menuBar()); // ui->menuWindow);
    buildShowColumnsMenu(showCallMenu, "call");
    ui->actionShow_Call_Activity_Columns->setMenu(showCallMenu);
    ui->actionShow_Call_Activity_Columns->setEnabled(
        ui->actionShow_Call_Activity->isChecked());

    ui->actionShow_Band_Heartbeats_and_ACKs->setEnabled(
        ui->actionShow_Band_Activity->isChecked());
}

void MainWindow::on_actionFocus_Message_Receive_Area_triggered() {
    ui->textEditRX->setFocus();
}

void MainWindow::on_actionFocus_Message_Reply_Area_triggered() {
    ui->extFreeTextMsgEdit->setFocus();
}

void MainWindow::on_actionFocus_Band_Activity_Table_triggered() {
    ui->tableWidgetRXAll->setFocus();
}

void MainWindow::on_actionFocus_Call_Activity_Table_triggered() {
    ui->tableWidgetCalls->setFocus();
}

void MainWindow::on_actionClear_All_Activity_triggered() { clearActivity(); }

void MainWindow::on_actionClear_Band_Activity_triggered() {
    clearBandActivity();
}

void MainWindow::on_actionClear_RX_Activity_triggered() { clearRXActivity(); }

void MainWindow::on_actionClear_Call_Activity_triggered() {
    clearCallActivity();
}

void MainWindow::on_actionSetOffset_triggered() {
    bool ok = false;
    auto const currentFreq = freq();
    QString newFreq =
        QInputDialog::getText(this, tr("Set Frequency Offset"),
                              tr("Offset in Hz:"), QLineEdit::Normal,
                              QString("%1").arg(currentFreq), &ok)
            .toUpper()
            .trimmed();
    int offset = newFreq.toInt(&ok);
    if (!ok) {
        return;
    }

    setFreqOffsetForRestore(offset, false);
}

void MainWindow::on_actionShow_Fullscreen_triggered(bool checked) {
    auto state = windowState();
    if (checked) {
        state |= Qt::WindowFullScreen;
    } else {
        state &= ~Qt::WindowFullScreen;
    }
    setWindowState(state);
}

void MainWindow::on_actionShow_Statusbar_triggered(bool checked) {
    if (!ui->statusBar) {
        return;
    }

    ui->statusBar->setVisible(checked);
}

void MainWindow::on_actionShow_Frequency_Clock_triggered(bool checked) {
    auto vsizes = ui->mainSplitter->sizes();
    vsizes[0] = checked ? ui->logHorizontalWidget->minimumHeight() : 0;
    ui->logHorizontalWidget->setVisible(checked);
    ui->mainSplitter->setSizes(vsizes);
}

void MainWindow::on_actionShow_Band_Activity_triggered(bool checked) {
    auto hsizes = ui->textHorizontalSplitter->sizes();

    if (m_bandActivityWidth == 0) {
        m_bandActivityWidth = ui->textHorizontalSplitter->width() / 4;
    }

    if (m_callActivityWidth == 0) {
        m_callActivityWidth = ui->textHorizontalSplitter->width() / 4;
    }

    if (m_textActivityWidth == 0) {
        m_textActivityWidth = ui->textHorizontalSplitter->width() / 2;
    }

    if (checked) {
        hsizes[0] = m_bandActivityWidth;
        hsizes[1] = m_textActivityWidth;
        if (hsizes[2])
            hsizes[2] = m_callActivityWidth;

    } else {
        if (hsizes[0])
            m_bandActivityWidth = hsizes[0];
        if (hsizes[1])
            m_textActivityWidth = hsizes[1];
        if (hsizes[2])
            m_callActivityWidth = hsizes[2];
        hsizes[0] = 0;
    }

    ui->textHorizontalSplitter->setSizes(hsizes);
    ui->tableWidgetRXAll->setVisible(checked);
    m_bandActivityWasVisible = checked;
}

void MainWindow::on_actionShow_Band_Heartbeats_and_ACKs_triggered(bool) {
    displayBandActivity();
}

void MainWindow::on_actionShow_Call_Activity_triggered(bool checked) {
    auto hsizes = ui->textHorizontalSplitter->sizes();

    if (m_bandActivityWidth == 0) {
        m_bandActivityWidth = ui->textHorizontalSplitter->width() / 4;
    }

    if (m_callActivityWidth == 0) {
        m_callActivityWidth = ui->textHorizontalSplitter->width() / 4;
    }

    if (m_textActivityWidth == 0) {
        m_textActivityWidth = ui->textHorizontalSplitter->width() / 2;
    }

    if (checked) {
        if (hsizes[0])
            hsizes[0] = m_bandActivityWidth;
        hsizes[1] = m_textActivityWidth;
        hsizes[2] = m_callActivityWidth;

    } else {
        if (hsizes[0])
            m_bandActivityWidth = hsizes[0];
        if (hsizes[1])
            m_textActivityWidth = hsizes[1];
        if (hsizes[2])
            m_callActivityWidth = hsizes[2];
        hsizes[2] = 0;
    }

    ui->textHorizontalSplitter->setSizes(hsizes);
    ui->tableWidgetCalls->setVisible(checked);
}

void MainWindow::on_actionShow_Waterfall_triggered(bool checked) {
    auto vsizes = ui->mainSplitter->sizes();

    if (m_waterfallHeight == 0) {
        m_waterfallHeight = ui->mainSplitter->height() / 4;
    }

    if (checked) {
        vsizes[vsizes.length() - 1] = m_waterfallHeight;

    } else {
        m_waterfallHeight = vsizes[vsizes.length() - 1];
        vsizes[1] += m_waterfallHeight;
        vsizes[vsizes.length() - 1] = 0;
    }

    ui->mainSplitter->setSizes(vsizes);
    ui->bandHorizontalWidget->setVisible(checked);
}

void MainWindow::on_actionShow_Waterfall_Controls_triggered(bool checked) {
    m_wideGraph->setControlsVisible(checked);
    if (checked && !ui->bandHorizontalWidget->isVisible()) {
        on_actionShow_Waterfall_triggered(checked);
    }
}

void MainWindow::on_actionShow_Waterfall_Time_Drift_Controls_triggered(
    bool checked) {
    m_wideGraph->setTimeControlsVisible(checked);
    if (checked && !ui->bandHorizontalWidget->isVisible()) {
        on_actionShow_Waterfall_triggered(checked);
    }
}

void MainWindow::on_actionReset_Window_Sizes_triggered() {
    // auto size = this->centralWidget()->size();

    ui->mainSplitter->setSizes({ui->logHorizontalWidget->minimumHeight(),
                                ui->mainSplitter->height() / 2,
                                ui->macroHorizonalWidget->minimumHeight(),
                                ui->mainSplitter->height() / 4});

    ui->textHorizontalSplitter->setSizes(
        {ui->textHorizontalSplitter->width() / 4,
         ui->textHorizontalSplitter->width() / 2,
         ui->textHorizontalSplitter->width() / 4});

    ui->textVerticalSplitter->setSizes(
        {ui->textVerticalSplitter->height() / 2,
         ui->textVerticalSplitter->height() / 2});
}

void MainWindow::on_actionSettings_triggered() { openSettings(); }

void MainWindow::openSettings(int tab) {
    m_config.select_tab(tab);

    // things that might change that we need know about
    auto callsign = m_config.my_callsign();
    auto my_grid = m_config.my_grid();
    auto spot_on = m_config.spot_to_reporting_networks();
    if (QDialog::Accepted == m_config.exec()) {
        if (m_config.my_callsign() != callsign) {
            m_baseCall = Radio::base_callsign(m_config.my_callsign());
        }
        if (m_config.my_callsign() != callsign ||
            m_config.my_grid() != my_grid) {
            statusUpdate();
        }

        enable_DXCC_entity(m_config.DXCC()); // sets text window proportions and
                                             // (re)inits the logbook

        prepareApi();
        prepareSpotting();

        // this will close the connection to PSKReporter if it has been
        // disabled
        if (spot_on && !m_config.spot_to_reporting_networks()) {
            Q_EMIT pskReporterSendReport(true);
        }

        if (m_config.restart_audio_input() &&
            !m_config.audio_input_device().isNull()) {
            Q_EMIT startAudioInputStream(m_config.audio_input_device(),
                                         m_framesAudioInputBuffered, m_detector,
                                         m_config.audio_input_channel());
        }

        if (m_config.restart_audio_output() &&
            !m_config.audio_output_device().isNull()) {
            Q_EMIT initializeAudioOutputStream(
                m_config.audio_output_device(),
                AudioDevice::Mono == m_config.audio_output_channel() ? 1 : 2,
                m_msAudioOutputBuffered);
        }

        if (m_config.restart_notification_audio_output() &&
            !m_config.notification_audio_output_device().isNull()) {
            Q_EMIT initializeNotificationAudioOutputStream(
                m_config.notification_audio_output_device(),
                m_msAudioOutputBuffered);
        }

        displayDialFrequency();
        displayActivity(true);

        setup_status_bar();
        setupJS8();

        m_config.transceiver_online();

        setXIT(freq());

        m_opCall = m_config.opCall();
    }
}

void MainWindow::prepareApi() {
    // the udp api is prepared by default (always listening)

    // so, we just need to prepare the tcp api
    bool enabled = m_config.tcpEnabled();
    if (enabled) {
        emit apiSetMaxConnections(m_config.tcp_max_connections());
        emit apiSetServer(m_config.tcp_server_name(),
                          m_config.tcp_server_port());
        emit apiStartServer();
    } else {
        emit apiStopServer();
    }
}

void MainWindow::prepareSpotting() {
    if (m_config.spot_to_reporting_networks()) {
        spotSetLocal();
        pskSetLocal();
        aprsSetLocal();
        emit aprsClientSetSkipPercent(0.25);
        emit aprsClientSetServer(m_config.aprs_server_name(),
                                 m_config.aprs_server_port());
        emit aprsClientSetPaused(false);
        ui->spotButton->setChecked(true);
    } else {
        emit aprsClientSetPaused(true);
        ui->spotButton->setChecked(false);
    }
}

void MainWindow::on_spotButton_clicked(bool checked) {
    // 1. save setting
    m_config.set_spot_to_reporting_networks(checked);

    // 2. prepare
    prepareApi();
    prepareSpotting();
}

void MainWindow::on_monitorButton_clicked(bool checked) {
    if (!m_transmitting) {
        auto prior = m_monitoring;
        monitor(checked);
        if (checked && !prior) {
            if (m_config.monitor_last_used()) {
                // put rig back where it was when last in control
                setRig(m_lastMonitoredFrequency);
                setXIT(freq());
            }
            setFreq(freq()); // ensure FreqCal triggers
        }
        // Get Configuration in/out of strict split and mode checking
        Q_EMIT m_config.sync_transceiver(true, checked);
    } else {
        ui->monitorButton->setChecked(false); // disallow
    }
}

void MainWindow::monitor(bool state) {
    ui->monitorButton->setChecked(state);

    // make sure widegraph is running if we are monitoring, otherwise pause it.
    m_wideGraph->setPaused(!state);

    if (state) {
        if (!m_monitoring)
            Q_EMIT resumeAudioInputStream();
    } else {
        Q_EMIT suspendAudioInputStream();
    }
    m_monitoring = state;
}

void MainWindow::on_actionAbout_triggered() // Display "About"
{
    CAboutDlg{this}.exec();
}

void MainWindow::on_monitorButton_toggled(bool) {
    resetPushButtonToggleText(ui->monitorButton);
}

void MainWindow::on_monitorTxButton_toggled(bool checked) {
    resetPushButtonToggleText(ui->monitorTxButton);

    if (!checked) {
        qCDebug(mainwindow_js8)
            << "on_monitorTxButton_toggled(" << checked << ") to stop TX.";
        on_stopTxButton_clicked();
    }
}

void MainWindow::on_tuneButton_toggled(bool) {
    resetPushButtonToggleText(ui->tuneButton);
}

void MainWindow::on_spotButton_toggled(bool) {
    resetPushButtonToggleText(ui->spotButton);
}

void MainWindow::auto_tx_mode(bool state) {
    qCDebug(mainwindow_js8) << "auto_tx_mode(" << state << ")";
    m_auto = state;
    statusUpdate();
    if (state) {
        // Let us not wait until the next polling slot, but prepare transmission
        // now, even though that may waste a few CPU cycles through double work
        // that will be done soon anyway:
        prepareSending(DriftingDateTime::currentMSecsSinceEpoch());
    } else {
        // This function is called recursively from on_stopTxButton_clicked()!
        bool previous_stopTxButtonisLongterm = m_stopTxButtonIsLongterm;
        m_stopTxButtonIsLongterm = false;
        on_stopTxButton_clicked();
        m_stopTxButtonIsLongterm = previous_stopTxButtonisLongterm;
    }
    qCDebug(mainwindow_js8) << "auto_tx_mode(" << state << ") completed.";
}

void MainWindow::keyPressEvent(QKeyEvent *e) {
    switch (e->key()) {
    case Qt::Key_Escape:
        on_stopTxButton_clicked();
        stopTx();
        return;
    case Qt::Key_F5:
        on_logQSOButton_clicked();
        return;
    }

    QMainWindow::keyPressEvent(e);
}

void MainWindow::f11f12(int const n) {
    if (n == 11)
        setFreq(freq() - 1);
    if (n == 12)
        setFreq(freq() + 1);
}

Radio::Frequency MainWindow::dialFrequency() {
    return Frequency{m_rigState.ptt() && m_rigState.split()
                         ? m_rigState.tx_frequency()
                         : m_rigState.frequency()};
}

void MainWindow::setSubmode(int submode) {
    m_nSubMode = submode;
    ui->actionModeJS8Normal->setChecked(submode == Varicode::JS8CallNormal);
    ui->actionModeJS8Fast->setChecked(submode == Varicode::JS8CallFast);
    ui->actionModeJS8Turbo->setChecked(submode == Varicode::JS8CallTurbo);
    ui->actionModeJS8Slow->setChecked(submode == Varicode::JS8CallSlow);
    ui->actionModeJS8Ultra->setChecked(submode == Varicode::JS8CallUltra);
    setupJS8();
    Q_EMIT submodeChanged(Varicode::intToSubmode(submode));
}

void MainWindow::updateCurrentBand() {
    QVariant state = ui->readFreq->property("state");
    if (!state.isValid()) {
        return;
    }

    auto dial_frequency = dialFrequency();
    auto const &band_name = m_config.bands()->find(dial_frequency);

    if (m_lastBand == band_name) {
        return;
    }

    cacheActivity(m_lastBand);

    // clear activity on startup if asked or on when the previous band is not
    // empty
    if (m_config.reset_activity() || !m_lastBand.isEmpty()) {
        clearActivity();
    }

    m_wideGraph->setBand(band_name);

    qCDebug(mainwindow_js8) << "setting band" << band_name;

    /**
     * @brief Send WSJT-X Status message on band change
     *
     * When the band changes, send a status update to WSJT-X protocol clients
     * and native JSON API clients (if not conflicting).
     */
    // Send WSJT-X Status message if protocol is enabled (band change triggers
    // status update)
    if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) {
        QString dx_call = callsignSelected();
        QString dx_grid = "";
        if (!dx_call.isEmpty() && m_callActivity.contains(dx_call)) {
            dx_grid = m_callActivity[dx_call].grid;
        }
        QString tx_message = m_transmitting ? m_currentMessage : "";

        m_wsjtxMessageMapper->sendStatusUpdate(
            dialFrequency(), freq(),
            "JS8", // mode
            dx_call, m_config.my_callsign(), m_config.my_grid(), dx_grid,
            true, // tx_enabled
            m_transmitting,
            m_decoderBusy || m_monitoring, // decoding
            tx_message);
    }

    // Send native JSON message only if not conflicting with WSJT-X
    bool skip_json = false;
    if (m_config.wsjtx_protocol_enabled() &&
        m_config.wsjtx_server_port() == m_config.udp_server_port() &&
        m_config.wsjtx_server_name() == m_config.udp_server_name()) {
        skip_json = true;
    }

    if (!skip_json) {
        sendNetworkMessage(
            "RIG.FREQ", "",
            {{"_ID", QVariant(-1)},
             {"BAND", QVariant(band_name)},
             {"FREQ", QVariant((quint64)dialFrequency() + freq())},
             {"DIAL", QVariant((quint64)dialFrequency())},
             {"OFFSET", QVariant((quint64)freq())}});
    }
    m_lastBand = band_name;

    band_changed();
    restoreActivity(m_lastBand);
}

void MainWindow::displayDialFrequency() {
#if 0
    qCDebug(mainwindow_js8) << "rx nominal" << m_freqNominal;
    qCDebug(mainwindow_js8) << "tx nominal" << m_freqTxNominal;
    qCDebug(mainwindow_js8) << "offset set to" << freq() << freq();
#endif

    auto dial_frequency = dialFrequency();
    auto audio_frequency = freq();

    // lookup band
    auto const &band_name = m_config.bands()->find(dial_frequency);

    auto sFreq = Radio::pretty_frequency_MHz_string(dial_frequency);
    ui->currentFreq->setDigitCount(sFreq.length());
    ui->currentFreq->display(sFreq);

    if (m_splitMode && m_transmitting) {
        audio_frequency += m_XIT;
    }
    ui->labDialFreqOffset->setText(QString("%1 Hz").arg(audio_frequency));
}

void MainWindow::statusChanged() { statusUpdate(); }

bool MainWindow::eventFilter(QObject *object, QEvent *event) {
    switch (event->type()) {
    case QEvent::KeyPress:
        // fall through
    case QEvent::MouseButtonPress:
        // reset the Tx watchdog
        resetIdleTimer();
        tx_watchdog(false);
        break;

    case QEvent::ChildAdded:
        // ensure our child widgets get added to our event filter
        add_child_to_event_filter(static_cast<QChildEvent *>(event)->child());
        break;

    case QEvent::ChildRemoved:
        // ensure our child widgets get d=removed from our event filter
        remove_child_from_event_filter(
            static_cast<QChildEvent *>(event)->child());
        break;

    case QEvent::ToolTip:
        if (!ui->actionShow_Tooltips->isChecked()) {
            return true;
        }

        break;

    default:
        break;
    }
    return QObject::eventFilter(object, event);
}

void MainWindow::createStatusBar() // createStatusBar
{
    tx_status_label.setAlignment(Qt::AlignCenter);
    tx_status_label.setMinimumSize(QSize{150, 18});
    tx_status_label.setStyleSheet("QLabel{background-color: #22ff22}");
    tx_status_label.setFrameStyle(QFrame::Panel | QFrame::Sunken);
    statusBar()->addWidget(&tx_status_label);

    config_label.setAlignment(Qt::AlignCenter);
    config_label.setMinimumSize(QSize{80, 18});
    config_label.setFrameStyle(QFrame::Panel | QFrame::Sunken);
    statusBar()->addWidget(&config_label);
    config_label.hide(); // only shown for non-default configuration

    mode_label.setAlignment(Qt::AlignCenter);
    mode_label.setMinimumSize(QSize{80, 18});
    mode_label.setStyleSheet("QLabel{background-color: #6699ff}");
    mode_label.setFrameStyle(QFrame::Panel | QFrame::Sunken);
    mode_label.setText("JS8");
    statusBar()->addWidget(&mode_label);

    last_tx_label.setAlignment(Qt::AlignCenter);
    last_tx_label.setMinimumSize(QSize{150, 18});
    last_tx_label.setFrameStyle(QFrame::Panel | QFrame::Sunken);
    statusBar()->addWidget(&last_tx_label);

    statusBar()->addPermanentWidget(&progressBar);
    progressBar.setMinimumSize(QSize{100, 18});
    progressBar.setFormat("%v/%m");

    statusBar()->addPermanentWidget(&wpm_label);
    wpm_label.setMinimumSize(QSize{120, 18});
    wpm_label.setFrameStyle(QFrame::Panel | QFrame::Sunken);
    wpm_label.setAlignment(Qt::AlignCenter);
}

void MainWindow::setup_status_bar() { last_tx_label.clear(); }

void MainWindow::closeEvent(QCloseEvent *e) {
    m_valid = false; // suppresses subprocess errors
    m_config.transceiver_offline();
    writeSettings();
    m_guiTimer.stop();
    m_prefixes.reset();
    m_shortcuts.reset();
    m_mouseCmnds.reset();
    Q_EMIT finished();

    QMainWindow::closeEvent(e);
}

void MainWindow::on_dialFreqUpButton_clicked() { setRig(m_freqNominal + 250); }

void MainWindow::on_dialFreqDownButton_clicked() {
    setRig(m_freqNominal - 250);
}

void MainWindow::on_actionAdd_Log_Entry_triggered() {
    on_logQSOButton_clicked();
}

void MainWindow::on_actionCopyright_Notice_triggered() {
    auto const &message = tr(
        "If you make fair use of any part of this program under terms of the "
        "GNU "
        "General Public License, you must display the following copyright "
        "notice prominently in your derivative work:\n\n"
        "\"The algorithms, source code, look-and-feel of WSJT-X and related "
        "programs, and protocol specifications for the modes FSK441, FT8, JT4, "
        "JT6M, JT9, JT65, JTMS, QRA64, ISCAT, MSK144 are Copyright (C) "
        "2001-2018 by one or more of the following authors: Joseph Taylor, "
        "K1JT; Bill Somerville, G4WJS; Steven Franke, K9AN; Nico Palermo, "
        "IV3NWV; Greg Beam, KI7MT; Michael Black, W9MDB; Edson Pereira, "
        "PY2SDR; "
        "Philip Karn, KA9Q; and other members of the WSJT Development "
        "Group.\n\n"
        "Further, the source code of JS8Call contains material Copyright (C) "
        "2018-2019 by Jordan Sherer, KN4CRD.\"");
    JS8MessageBox::warning_message(this, message);
}

/**
 * @brief MainWindow::isDecodeReady
 *        determine if decoding is ready for a given submode
 * @param submode - submode to test
 * @param k - current frame count
 * @param k0 - previous frame count
 * @param pCurrentDecodeStart - input pointer to a static integer with the
 * current decode start position
 * @param pNextDecodeStart - input pointer to a static integer with the next
 * decode start position
 * @param pStart - output pointer to the next start position when decode is
 * ready
 * @param pSz - output pointer to the next size when decode is ready
 * @param pCycle - output pointer to the next cycle when decode is ready
 * @return true if decode is ready for this submode, false otherwise
 */
bool MainWindow::isDecodeReady(int const submode, qint32 const k,
                               qint32 const k0, qint32 *pCurrentDecodeStart,
                               qint32 *pNextDecodeStart, qint32 *pStart,
                               qint32 *pSz, qint32 *pCycle) {
    if (pCurrentDecodeStart == nullptr || pNextDecodeStart == nullptr) {
        return false;
    }

    qint32 const cycleFrames = JS8::Submode::samplesPerPeriod(submode);
    qint32 const framesNeeded = JS8::Submode::samplesNeeded(submode);
    qint32 const currentCycle = JS8::Submode::computeCycleForDecode(submode, k);
    qint32 const delta = qAbs(k - k0);

    if (delta > cycleFrames) {
        qCDebug(decoder_js8) << "-->" << JS8::Submode::name(submode)
                             << "buffer advance delta" << delta;
    }

    // say, current decode start is 360000 and the next is 540000 (right before
    // we loop) frames needed are 150000 and then we turn off rx until k is
    // 110000 and the cycle frames are 180000 and k0 is a proper 100000 we need
    // to still reset... so, if k is less than the last decode start - cycle
    // frames (in this case 360000-180000, or 180000), then we should reset.
    // but, what if k is now 182000??

    // k=182000
    // k < current (182000 < 360000) true
    // k < max(0, current-cycleframes+framesNeeded) (k < 180000+150000) true

    // k=6000
    // k < current (6000<360000) true
    // k < max(0, 360000-180000+150000) true

    // k=350000
    // k < current (350000<360000) true
    // k < max(0, 360000-350000+150000) false

    // are we in the space between the end of the last decode and the start of
    // the next decode?
    bool const deadAir =
        (k < *pCurrentDecodeStart &&
         k < qMax(0, *pCurrentDecodeStart - cycleFrames + framesNeeded));

    // on buffer loop or init, prepare proper next decode start
    if ((deadAir) || (k < k0) || (delta > cycleFrames) ||
        (*pCurrentDecodeStart == -1) || (*pNextDecodeStart == -1)) {
        *pCurrentDecodeStart = currentCycle * cycleFrames;
        *pNextDecodeStart = *pCurrentDecodeStart + cycleFrames;
    }

    bool const ready = *pCurrentDecodeStart + framesNeeded <= k;

    if (ready) {
        qCDebug(decoder_js8)
            << "-->" << JS8::Submode::name(submode) << "from"
            << *pCurrentDecodeStart << "to"
            << *pCurrentDecodeStart + framesNeeded << "k" << k << "k0" << k0;

        if (pCycle)
            *pCycle = currentCycle;
        if (pStart)
            *pStart = *pCurrentDecodeStart;
        if (pSz)
            *pSz = qMax(framesNeeded, k - (*pCurrentDecodeStart));

        *pCurrentDecodeStart = *pNextDecodeStart;
        *pNextDecodeStart = *pCurrentDecodeStart + cycleFrames;
    }

    return ready;
}

/**
 * @brief MainWindow::decode
 *        try decoding
 * @return true if the decoder was activated, false otherwise
 */
bool MainWindow::decode(qint32 k) {
    static int k0 = 9999999;
    int kZero = k0;
    k0 = k;
    qCDebug(decoder_js8)
        << "decoder checking if ready..."
        << "k" << k << "k0" << kZero << "busy?" << m_decoderBusy
        << "lock exists?"
        << (QFile{m_config.temp_dir().absoluteFilePath(".lock")}.exists());

    if (k == kZero) {
        qCDebug(decoder_js8) << "--> decoder stream has not advanced";
        return false;
    }

    if (!m_monitoring) {
        qCDebug(decoder_js8) << "--> decoder stream is not active";
        return false;
    }

    bool ready = false;

#if JS8_USE_EXPERIMENTAL_DECODE_TIMING
    ready = decodeEnqueueReady(k, kZero);
    if (ready || !m_decoderQueue.isEmpty()) {
        qCDebug(decoder_js8) << "--> decoder is ready to be run with"
                             << m_decoderQueue.count() << "decode periods";
    }
#else
    ready = decodeEnqueueReadyExperiment(k, kZero);
    if (ready || !m_decoderQueue.isEmpty()) {
        qCDebug(decoder_js8) << "--> decoder is ready to be run with"
                             << m_decoderQueue.count() << "decode periods";
    }
#endif

    //
    // TODO: what follows can likely be pulled out to an async process
    //

    // pause decoder if we are currently transmitting
    if (m_transmitting) {
        // We used to use isMessageQueuedForTransmit, and some form of checking
        // for queued messages but, that just caused problems with missing
        // decodes, so we only pause if we are actually actively transmitting.
        qCDebug(decoder_js8) << "--> decoder paused during transmit";
        return false;
    }

    if (m_decoderBusyStartTime.isValid() &&
        m_decoderBusyStartTime.msecsTo(QDateTime::currentDateTimeUtc()) <
            1000) {
        qCDebug(decoder_js8)
            << "--> decoder paused for 1000 ms after last decode start";
        return false;
    }

    int threshold =
        m_nSubMode == Varicode::JS8CallSlow ? 4000 : 2000; // two seconds
    if (isInDecodeDelayThreshold(threshold)) {
        qCDebug(decoder_js8) << "--> decoder paused for" << threshold
                             << "ms after transmit stop";
        return false;
    }

    // critical section (modifying dec_data)

    qint32 submode = -1;
    if (!decodeProcessQueue(&submode)) {
        return false;
    }

    decodeStart();

    return true;
}

/**
 * @brief MainWindow::decodeEnqueueReady
 *        compute the available decoder ranges that can be processed and
 *        place them in the decode queue
 * @param k - the current frame count
 * @param k0 - the previous frame count
 * @return true if decoder ranges were queued, false otherwise
 */
bool MainWindow::decodeEnqueueReady(qint32 k, qint32 k0) {
    // compute the next decode for each submode
    // enqueue those decodes that are "ready"
    // on an interval, issue a decode
    int decodes = 0;

    bool couldDecodeA = false;
    qint32 startA = -1;
    qint32 szA = -1;
    qint32 cycleA = -1;

    bool couldDecodeB = false;
    qint32 startB = -1;
    qint32 szB = -1;
    qint32 cycleB = -1;

    bool couldDecodeC = false;
    qint32 startC = -1;
    qint32 szC = -1;
    qint32 cycleC = -1;

    bool couldDecodeE = false;
    qint32 startE = -1;
    qint32 szE = -1;
    qint32 cycleE = -1;

#if JS8_ENABLE_JS8I
    bool couldDecodeI = false;
    qint32 startI = -1;
    qint32 szI = -1;
    qint32 cycleI = -1;
#endif

    static qint32 currentDecodeStartA = -1;
    static qint32 nextDecodeStartA = -1;
    qCDebug(decoder_js8) << "? NORMAL   " << currentDecodeStartA
                         << nextDecodeStartA;
    couldDecodeA =
        isDecodeReady(Varicode::JS8CallNormal, k, k0, &currentDecodeStartA,
                      &nextDecodeStartA, &startA, &szA, &cycleA);

    static qint32 currentDecodeStartB = -1;
    static qint32 nextDecodeStartB = -1;
    qCDebug(decoder_js8) << "? FAST     " << currentDecodeStartB
                         << nextDecodeStartB;
    couldDecodeB =
        isDecodeReady(Varicode::JS8CallFast, k, k0, &currentDecodeStartB,
                      &nextDecodeStartB, &startB, &szB, &cycleB);

    static qint32 currentDecodeStartC = -1;
    static qint32 nextDecodeStartC = -1;
    qCDebug(decoder_js8) << "? TURBO    " << currentDecodeStartC
                         << nextDecodeStartC;
    couldDecodeC =
        isDecodeReady(Varicode::JS8CallTurbo, k, k0, &currentDecodeStartC,
                      &nextDecodeStartC, &startC, &szC, &cycleC);

    static qint32 currentDecodeStartE = -1;
    static qint32 nextDecodeStartE = -1;
    qCDebug(decoder_js8) << "? SLOW     " << currentDecodeStartE
                         << nextDecodeStartE;
    couldDecodeE =
        isDecodeReady(Varicode::JS8CallSlow, k, k0, &currentDecodeStartE,
                      &nextDecodeStartE, &startE, &szE, &cycleE);

#if JS8_ENABLE_JS8I
    static qint32 currentDecodeStartI = -1;
    static qint32 nextDecodeStartI = -1;
    qCDebug(decoder_js8) << "? ULTRA    " << currentDecodeStartI
                         << nextDecodeStartI;
    couldDecodeI =
        isDecodeReady(Varicode::JS8CallUltra, k, k0, &currentDecodeStartI,
                      &nextDecodeStartI, &startI, &szI, &cycleI);
#endif

    if (couldDecodeA) {
        DecodeParams d;
        d.submode = Varicode::JS8CallNormal;
        d.start = startA;
        d.sz = szA;
        m_decoderQueue.append(d);
        decodes++;
    }

    if (couldDecodeB) {
        DecodeParams d;
        d.submode = Varicode::JS8CallFast;
        d.start = startB;
        d.sz = szB;
        m_decoderQueue.append(d);
        decodes++;
    }

    if (couldDecodeC) {
        DecodeParams d;
        d.submode = Varicode::JS8CallTurbo;
        d.start = startC;
        d.sz = szC;
        m_decoderQueue.append(d);
        decodes++;
    }

    if (couldDecodeE) {
        DecodeParams d;
        d.submode = Varicode::JS8CallSlow;
        d.start = startE;
        d.sz = szE;
        m_decoderQueue.append(d);
        decodes++;
    }

#if JS8_ENABLE_JS8I
    if (couldDecodeI) {
        DecodeParams d;
        d.submode = Varicode::JS8CallUltra;
        d.start = startI;
        d.sz = szI;
        m_decoderQueue.append(d);
        decodes++;
    }
#endif

    return decodes > 0;
}

/**
 * @brief MainWindow::decodeEnqueueReadyExperiment
 *        compute the available decoder ranges that can be processed and
 *        place them in the decode queue
 *
 *        experiment with decoding on a much shorter interval than usual
 *
 * @param k - the current frame count
 * @param k0 - the previous frame count
 * @return true if decoder ranges were queued, false otherwise
 */
bool MainWindow::decodeEnqueueReadyExperiment(qint32 k, qint32 /*k0*/) {
    // TODO: make this non-static field of MainWindow?
    // map of last decode positions for each submode
    // static QMap<qint32, qint32> m_lastDecodeStartMap;

    // TODO: make this non-static field of MainWindow?
    // map of submodes to decode + optional alternate decode positions
    static QMap<qint32, QList<qint32>> submodes = {
        {Varicode::JS8CallSlow, {0}},
        {Varicode::JS8CallNormal, {0}},
        {Varicode::JS8CallFast, {0}},  // NORMAL: 0, 10, 20    --- ALT: 15, 25
        {Varicode::JS8CallTurbo, {0}}, // NORMAL: 0, 6, 12, 18 --- ALT: 15, 21,
                                       // 27
#if JS8_ENABLE_JS8I
        {Varicode::JS8CallUltra, {0}},
#endif
    };

    static qint32 maxSamples = JS8_RX_SAMPLE_SIZE;
    static qint32 oneSecondSamples = JS8_RX_SAMPLE_RATE;

    int decodes = 0;

    // do we have a better way to check this?
    bool multi = ui->actionModeMultiDecoder->isChecked();

    // do we need to process alternate positions?
    bool skipAlt = true;

    foreach (auto submode, submodes.keys()) {
        // do we have a better way to check this?
        bool everySecond = m_wideGraph->shouldAutoSyncSubmode(submode);

        // skip if multi is disabled and this mode is not the current submode
        // and we're not autosyncing this mode
        if (!everySecond && !multi && submode != m_nSubMode) {
            continue;
        }

        // check all alternate decode positions
        foreach (auto alt, submodes.value(submode)) {
            // skip alt decode positions if needed
            if (skipAlt && alt != 0) {
                continue;
            }

            // skip alts if we are decoding every second
            if (everySecond && alt != 0) {
                continue;
            }

            qint32 const cycle = JS8::Submode::computeAltCycleForDecode(
                submode, k, alt * oneSecondSamples);
            qint32 const cycleFrames = JS8::Submode::samplesPerPeriod(submode);
            qint32 const cycleFramesNeeded = JS8::Submode::samplesForSymbols(
                submode); // computeFramesNeededForDecode(submode)
                          // - oneSecondSamples;
            qint32 cycleFramesReady = k - (cycle * cycleFrames);
            if (cycleFramesReady < 0) {
                cycleFramesReady = k + (maxSamples - (cycle * cycleFrames));
            }

            if (!m_lastDecodeStartMap.contains(submode)) {
                m_lastDecodeStartMap[submode] = cycle * cycleFrames;
            }

            qint32 lastDecodeStart = m_lastDecodeStartMap[submode];
            qint32 incrementedBy = k - lastDecodeStart;
            if (k < lastDecodeStart) {
                incrementedBy = maxSamples - lastDecodeStart + k;
            }
            qCDebug(decoder_js8)
                << JS8::Submode::name(submode) << "alt" << alt << "cycle"
                << cycle << "cycle frames" << cycleFrames << "cycle start"
                << cycle * cycleFrames << "cycle end"
                << (cycle + 1) * cycleFrames << "k" << k << "frames ready"
                << cycleFramesReady << "incremeted by" << incrementedBy;

            if (everySecond && incrementedBy >= oneSecondSamples) {
                DecodeParams d;
                d.submode = submode;
                d.sz = cycleFrames;
                d.start = k - d.sz;
                if (d.start < 0) {
                    d.start += maxSamples;
                }
                m_decoderQueue.append(d);
                decodes++;

                // keep track of last decode position
                m_lastDecodeStartMap[submode] = k;
            } else if ((incrementedBy >= 1.5 * oneSecondSamples &&
                        cycleFramesReady >=
                            cycleFramesNeeded) || // within every 3/2 seconds
                                                  // for normal positions
                       (incrementedBy >= oneSecondSamples &&
                        cycleFramesReady >=
                            cycleFramesNeeded -
                                1.5 * oneSecondSamples) || // within the last
                                                           // 3/2 seconds of a
                                                           // new cycle
                       (incrementedBy >= oneSecondSamples &&
                        cycleFramesReady <
                            1.5 * oneSecondSamples) // within the first 3/2
                                                    // seconds of a new cycle
            ) {
                DecodeParams d;
                d.submode = submode;
                d.start = cycle * cycleFrames;
                d.sz = cycleFramesReady;
                m_decoderQueue.append(d);
                decodes++;

                // keep track of last decode position
                m_lastDecodeStartMap[submode] = k;
            }
        }
    }

    return decodes > 0;
}

/**
 * @brief MainWindow::decodeProcessQueue
 *        process the decode queue by merging available decode ranges
 *        into the dec_data shared structure for the decoder to process
 * @param pSubmode - the lowest speed submode in this iteration
 * @return true if the decoder is ready to be run, false otherwise
 */
bool MainWindow::decodeProcessQueue(qint32 *pSubmode) {
    // critical section
    QMutexLocker mutex(m_detector->getMutex());

    if (m_decoderBusy) {
        int seconds =
            m_decoderBusyStartTime.secsTo(QDateTime::currentDateTimeUtc());
        if (seconds > 60) {
            qCDebug(decoder_js8) << "--> decoder should be killed!"
                                 << QString("(%1 seconds)").arg(seconds);
        } else if (seconds > 30) {
            qCDebug(decoder_js8) << "--> decoder is hanging!"
                                 << QString("(%1 seconds)").arg(seconds);
        } else {
            qCDebug(decoder_js8) << "--> decoder is busy!";
        }

        return false;
    }

    if (m_decoderQueue.isEmpty()) {
        qCDebug(decoder_js8) << "--> decoder has nothing to process!";
        return false;
    }

    int submode = -1;
    int maxDecodes = 1;

    bool multi = ui->actionModeMultiDecoder->isChecked();
    if (multi) {
        maxDecodes = JS8_ENABLE_JS8I ? 5 : 4;
    }

    int count = m_decoderQueue.count();
    if (count > maxDecodes) {
        qCDebug(decoder_js8) << "--> decoder skipping at least 1 decode cycle"
                             << "count" << count << "max" << maxDecodes;
    }

    // default to no submodes being decoded, then bitwise OR the modes together
    // to decode them all at once
    dec_data.params.nsubmodes = 0;

    while (!m_decoderQueue.isEmpty()) {
        auto params = m_decoderQueue.front();
        m_decoderQueue.removeFirst();

        // skip if we are not in multi mode and the submode doesn't equal the
        // global submode
        if (!multi && params.submode != m_nSubMode) {
            continue;
        }

        if (submode == -1 || params.submode < submode) {
            submode = params.submode;
        }

        switch (params.submode) {
        case Varicode::JS8CallNormal:
            dec_data.params.kposA = params.start;
            dec_data.params.kszA = params.sz;
            dec_data.params.nsubmodes |= (params.submode + 1);
            break;
        case Varicode::JS8CallFast:
            dec_data.params.kposB = params.start;
            dec_data.params.kszB = params.sz;
            dec_data.params.nsubmodes |= (params.submode << 1);
            break;
        case Varicode::JS8CallTurbo:
            dec_data.params.kposC = params.start;
            dec_data.params.kszC = params.sz;
            dec_data.params.nsubmodes |= (params.submode << 1);
            break;
        case Varicode::JS8CallSlow:
            dec_data.params.kposE = params.start;
            dec_data.params.kszE = params.sz;
            dec_data.params.nsubmodes |= (params.submode << 1);
            break;
#if JS8_ENABLE_JS8I
        case Varicode::JS8CallUltra:
            dec_data.params.kposI = params.start;
            dec_data.params.kszI = params.sz;
            dec_data.params.nsubmodes |= (params.submode << 1);
            break;
#endif
        }
    }

    if (submode == -1) {
        qCDebug(decoder_js8) << "--> decoder has no segments to decode!";
        return false;
    }

    dec_data.params.syncStats = (m_wideGraph->shouldDisplayDecodeAttempts() ||
                                 m_wideGraph->isAutoSyncEnabled());
    dec_data.params.newdat = 1;

    auto const period_unsigned = JS8::Submode::period(submode);
    // Need to use a signed integer here,
    auto const period_signed = (int)period_unsigned;
    // as (2 - period_unsigned) results in an enourmeous number close to 2**32.
    auto const t =
        DriftingDateTime::currentDateTimeUtc().addSecs(2 - period_signed);
    auto const ihr = t.toString("hh").toInt();
    auto const imin = t.toString("mm").toInt();
    auto const isec = t.toString("ss").toInt();

    dec_data.params.nutc = code_time(ihr, imin, isec - isec % period_unsigned);
    dec_data.params.nfqso = freq();
    dec_data.params.nfa =
        m_wideGraph->filterEnabled() ? m_wideGraph->filterMinimum() : 0;
    dec_data.params.nfb =
        m_wideGraph->filterEnabled() ? m_wideGraph->filterMaximum() : 5000;

    if (dec_data.params.nutc < m_nutc0)
        m_RxLog = 1; // Date and Time to ALL.TXT
    if (dec_data.params.newdat == 1)
        m_nutc0 = dec_data.params.nutc;

    // keep track of the minimum submode
    if (pSubmode)
        *pSubmode = submode;

    return true;
}

/**
 * @brief MainWindow::decodeStart
 *        copy the dec_data structure to shared memory and
 *        remove the lock file to start the decoding process
 */
void MainWindow::decodeStart() {
    // critical section
    QMutexLocker mutex(m_detector->getMutex());

    if (m_decoderBusy) {
        qCDebug(decoder_js8) << "--> decoder cannot start...busy (busy flag)";
        return;
    }

    // Mark the decoder busy; decodeDone is responsible for marking
    // the decode _not_ busy

    decodeBusy(true);
    qCDebug(decoder_js8) << "--> decoder starting"
                         << " --> kin:" << dec_data.params.kin
                         << " --> newdat:" << dec_data.params.newdat
                         << " --> nsubmodes:" << dec_data.params.nsubmodes
                         << " --> A:" << dec_data.params.kposA
                         << dec_data.params.kposA + dec_data.params.kszA
                         << QString("(%1)").arg(dec_data.params.kszA)
                         << " --> B:" << dec_data.params.kposB
                         << dec_data.params.kposB + dec_data.params.kszB
                         << QString("(%1)").arg(dec_data.params.kszB)
                         << " --> C:" << dec_data.params.kposC
                         << dec_data.params.kposC + dec_data.params.kszC
                         << QString("(%1)").arg(dec_data.params.kszC)
                         << " --> E:" << dec_data.params.kposE
                         << dec_data.params.kposE + dec_data.params.kszE
                         << QString("(%1)").arg(dec_data.params.kszE)
                         << " --> I:" << dec_data.params.kposI
                         << dec_data.params.kposI + dec_data.params.kszI
                         << QString("(%1)").arg(dec_data.params.kszI);

    m_decoder.decode();
}

/**
 * @brief MainWindow::decodeBusy
 *        mark the decoder as currently busy (to prevent overlapping decodes)
 * @param b - true if busy, false otherwise
 */
void MainWindow::decodeBusy(bool b) // decodeBusy()
{
    m_decoderBusy = b;

    if (m_decoderBusy) {
        tx_status_label.setText("Decoding");

        m_decoderBusyStartTime = QDateTime::
            currentDateTimeUtc(); // DriftingDateTime::currentDateTimeUtc();
        m_decoderBusyFreq = dialFrequency();
        m_decoderBusyBand = m_config.bands()->find(m_decoderBusyFreq);
    }
}

/**
 * @brief MainWindow::decodeDone
 *        clean up after a decode is finished
 */
void MainWindow::decodeDone() {
    // critical section
    QMutexLocker mutex(m_detector->getMutex());

    dec_data.params.newdat = false;
    m_RxLog = 0;

    // cleanup old cached messages (messages > submode period old)

    std::erase_if(m_messageDupeCache, [](auto const &it) {
        return it.second.secsTo(QDateTime::currentDateTimeUtc()) >
               JS8::Submode::period(it.first.submode);
    });

    decodeBusy(false);
}

QDateTime MainWindow::nextTransmitCycle() {
    auto timestamp = DriftingDateTime::currentDateTimeUtc();

    // remove milliseconds
    auto t = timestamp.time();
    t.setHMS(t.hour(), t.minute(), t.second());
    timestamp.setTime(t);

    // round to 15 second increment
    int secondsSinceEpoch = (timestamp.toMSecsSinceEpoch() / 1000);
    int delta = roundUp(secondsSinceEpoch, m_TRperiod) + 1 - secondsSinceEpoch;
    timestamp = timestamp.addSecs(delta);

    return timestamp;
}

QList<int> generateOffsets(int minOffset, int maxOffset) {
    QList<int> offsets;

    // TODO: these offsets aren't ordered correctly...

    for (int i = minOffset; i <= maxOffset; i++) {
        offsets.append(i);
    }
    return offsets;
}

void MainWindow::processDecodeEvent(JS8::Event::Variant const &event) {
    static QList<qint32> driftQueue;
    static qint32 syncStart = -1;

    std::visit(
        [this](auto &&e) {
            using T = std::decay_t<decltype(e)>;

            if constexpr (std::is_same_v<T, JS8::Event::DecodeStarted>) {
                if (m_wideGraph->shouldDisplayDecodeAttempts()) {
                    m_wideGraph->drawHorizontalLine(QColor(Qt::yellow), 0, 5);
                }
            } else if constexpr (std::is_same_v<T, JS8::Event::SyncStart>) {
                syncStart = e.position;
            } else if constexpr (std::is_same_v<T, JS8::Event::SyncState>) {
                if (m_wideGraph->shouldDisplayDecodeAttempts()) {
                    auto const drawDecodeLine =
                        [this, freq = static_cast<int>(e.frequency),
                         mode = e.mode](QColor const &color) {
                            m_wideGraph->drawDecodeLine(
                                color, freq,
                                freq + JS8::Submode::bandwidth(mode));
                        };

                    if (e.type == JS8::Event::SyncState::Type::DECODED) {
                        drawDecodeLine(Qt::red);
                    } else if (auto const xdtMs = static_cast<int>(e.dt * 1000);
                               std::abs(xdtMs) <= 2000) {
                        if (e.sync.candidate < 10)
                            drawDecodeLine(Qt::darkCyan);
                        else if (e.sync.candidate <= 15)
                            drawDecodeLine(Qt::cyan);
                        else if (e.sync.candidate <= 21)
                            drawDecodeLine(Qt::white);
                    }
                }
            } else if constexpr (std::is_same_v<T,
                                                JS8::Event::DecodeFinished>) {
                qCDebug(decoder_js8) << "decode duration"
                                     << m_decoderBusyStartTime.msecsTo(
                                            QDateTime::currentDateTimeUtc())
                                     << "ms";

                // TODO: move this into a function
                if (!driftQueue.isEmpty()) {
                    if (m_driftMsMMA_N == 0) {
                        m_driftMsMMA_N = 1;
                        m_driftMsMMA = DriftingDateTime::drift();
                    }

                    // let the widegraph know for timing control
                    m_wideGraph->notifyDriftedSignalsDecoded(
                        driftQueue.count());

                    while (!driftQueue.isEmpty()) {
                        qint32 newDrift = driftQueue.first();
                        driftQueue.removeFirst();

                        m_driftMsMMA =
                            (((m_driftMsMMA_N - 1) * m_driftMsMMA) + newDrift) /
                            m_driftMsMMA_N;
                        if (m_driftMsMMA_N < 60)
                            m_driftMsMMA_N++; // cap it to 60 observations
                    }

                // XXX The following lines do nothing; it's a completely dead
                // store. For
                //     now, just #ifdefing them out, but they were in
                //     the 2.2.1-devel code, and presumably they were important;
                //     need to see what the intent was here.
#if 0
          qint32 driftLimitMs = JS8::Submode::period(Varicode::JS8CallNormal) * 1000;
          qint32 newDriftMs   = m_driftMsMMA;
          if(newDriftMs < 0){
              newDriftMs = -((-newDriftMs) % driftLimitMs);
          } else {
              newDriftMs = ((newDriftMs) % driftLimitMs);
          }
#endif

                    setDrift(m_driftMsMMA);
                    // writeNoticeTextToUI(QDateTime::currentDateTimeUtc(),
                    // QString("Automatic Drift: %1").arg(driftAvg));
                }

                m_bDecoded = e.decoded > 0;
                decodeDone();
            } else if constexpr (std::is_same_v<T, JS8::Event::Decoded>) {
                // A frame is valid if we haven't seen the same frame in the
                // past 1/2 decode period.
                //
                // Note: Success here depends on decodes ordered such that
                // frequencies
                //       near `dec_data.params.nfqso` arrive here first, so it's
                //       key to process the decode candidates in an ordered
                //       manner, likely by sorting the raw take from the initial
                //       selection pass.

                DecodedText decodedtext(e);
                FrameCacheKey dedupeKey(decodedtext.submode(),
                                        decodedtext.frame());

                if (auto const it = m_messageDupeCache.find(dedupeKey);
                    it != m_messageDupeCache.end()) {
                    if (it->second.secsTo(QDateTime::currentDateTimeUtc()) <
                        0.5 * JS8::Submode::period(decodedtext.submode())) {
                        qCDebug(mainwindow_js8)
                            << "duplicate frame at" << it->second << "using key"
                            << QString("%1:%2")
                                   .arg(dedupeKey.submode)
                                   .arg(dedupeKey.frame);
                        return;
                    }
                }
#if 0
        // frames are valid if they meet our minimum rx threshold for the submode
        bool bValidFrame = decodedtext.snr() >= JS8::Submode::rxSNRThreshold(decodedtext.submode());

        qCDebug(mainwindow_js8) << "valid" << bValidFrame << JS8::Submode::name(decodedtext.submode()) << "decoded text" << decodedtext.message();

        // skip if invalid
        if(!bValidFrame) {
            return;
        }
#else
                qCDebug(mainwindow_js8)
                    << JS8::Submode::name(decodedtext.submode())
                    << "decoded text" << decodedtext.message();
#endif
                // TODO: move this into a function
                // compute time drift for non-dupe messages
                if (m_wideGraph->shouldAutoSyncSubmode(decodedtext.submode())) {
                    int m = decodedtext.submode();
                    float xdt = decodedtext.dt();

                    // if we're here at this point, we _should_ be operating a
                    // decode every second
                    //
                    // so we need to figure out where:
                    //
                    //   1) this current decode started
                    //   2) when that cycle _should_ have started
                    //   3) compute the delta
                    //   4) apply the drift

                    qint32 periodMs = 1000 * JS8::Submode::period(m);

                    // writeNoticeTextToUI(now, QString("Decode at %1 (kin: %2,
                    // lastDecoded:
                    // %3)").arg(syncStart).arg(dec_data.params.kin).arg(m_lastDecodeStartMap.value(m)));

                    float expectedStartDelay =
                        JS8::Submode::startDelayMS(m) / 1000.0;

                    float decodedSignalTime =
                        (float)syncStart / (float)JS8_RX_SAMPLE_RATE;

                    // writeNoticeTextToUI(now, QString("--> started at %1
                    // seconds into the start of my drifted
                    // minute").arg(decodedSignalTime));

                    // writeNoticeTextToUI(now, QString("--> we add a time delta
                    // of %1 seconds into the start of the cycle").arg(xdt));

                    // adjust for expected start delay
                    decodedSignalTime -= expectedStartDelay;

                    // adjust for time delta
                    decodedSignalTime += xdt;

                    // ensure that we are within a 60 second minute
                    if (decodedSignalTime < 0) {
                        decodedSignalTime += 60.0f;
                    } else if (decodedSignalTime > 60) {
                        decodedSignalTime -= 60.0f;
                    }

                    // writeNoticeTextToUI(now, QString("--> so signal adjusted
                    // started at %1 seconds into the start of my drifted
                    // minute").arg(decodedSignalTime));

                    qint32 decodedSignalTimeMs = 1000 * decodedSignalTime;
                    qint32 cycleStartTimeMs =
                        (decodedSignalTimeMs / periodMs) * periodMs;
                    qint32 driftMs = cycleStartTimeMs - decodedSignalTimeMs;

                    // writeNoticeTextToUI(now, QString("--> which is a drift
                    // adjustment of %1 milliseconds").arg(driftMs));

                    // if we have a large negative offset (say -14000), use the
                    // positive inverse of +1000
                    if (driftMs + periodMs < qAbs(driftMs)) {
                        driftMs += periodMs;
                    }
                    // if we have a large positive offset (say 14000, use the
                    // negative inverse of -1000)
                    else if (qAbs(driftMs - periodMs) < driftMs) {
                        driftMs -= periodMs;
                    }

                    // writeNoticeTextToUI(now, QString("--> which is a
                    // corrected drift adjustment of %1
                    // milliseconds").arg(driftMs));

                    qint32 newDrift = DriftingDateTime::drift() + driftMs;
                    if (newDrift < 0) {
                        newDrift %= -periodMs;
                    } else {
                        newDrift %= periodMs;
                    }

                    // writeNoticeTextToUI(now, QString("--> which is rounded to
                    // a total drift of %1 milliseconds for this
                    // period").arg(newDrift));

                    driftQueue.append(newDrift);
                }

                // if the frame is valid, cache it!
                m_messageDupeCache.insert_or_assign(
                    dedupeKey, QDateTime::currentDateTimeUtc());

                // log valid frames to ALL.txt (and correct their timestamp
                // format)
                auto freq = dialFrequency();

                // if we changed frequencies, use the old frequency that we
                // started the decode with
                if (m_decoderBusyFreq != freq) {
                    freq = m_decoderBusyFreq;
                }

                auto date = DriftingDateTime::currentDateTimeUtc().toString(
                    "yyyy-MM-dd");
                writeAllTxt(date + " " + decodedtext.string() + " " +
                            decodedtext.message());

                /**
                 * @brief Send decode to WSJT-X protocol
                 *
                 * Converts JS8Call decode events to WSJT-X Decode messages and
                 * sends them to WSJT-X protocol clients. For HeartBeat
                 * messages, ensures the message text includes callsign and grid
                 * so clients can properly associate them with grid plots.
                 */
                if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) {
                    // Convert decode time from JS8Call format to QTime
                    auto const hms = decode_time(decodedtext.time());
                    QTime decode_time = QTime(hms.hour, hms.minute, hms.second);

                    // Send decode message
                    // Use "JS8" as the mode string (WSJT-X expects mode names
                    // like "FT8", "FT4", "JT9", etc.)
                    m_wsjtxMessageMapper->sendDecode(
                        true, // is_new - always true for new decodes
                        decode_time, decodedtext.snr(), decodedtext.dt(),
                        static_cast<quint32>(decodedtext.frequencyOffset()),
                        "JS8", // mode string
                        decodedtext.message(), decodedtext.isLowConfidence());
                }

                ActivityDetail d = {};
                CallDetail cd = {};
                CommandDetail cmd = {};
                CallDetail td = {};

            // Parse General Activity
#if 1
                bool shouldParseGeneralActivity = true;
                if (shouldParseGeneralActivity &&
                    !decodedtext.messageWords().isEmpty()) {
                    int offset = decodedtext.frequencyOffset();

                    if (!m_bandActivity.contains(offset)) {
                        int const range =
                            JS8::Submode::rxThreshold(decodedtext.submode());

                        QList<int> offsets =
                            generateOffsets(offset - range, offset + range);

                        foreach (int prevOffset, offsets) {
                            if (!m_bandActivity.contains(prevOffset)) {
                                continue;
                            }
                            m_bandActivity[offset] = m_bandActivity[prevOffset];
                            m_bandActivity.remove(prevOffset);
                            break;
                        }
                    }

                    // ActivityDetail d = {};
                    d.isLowConfidence = decodedtext.isLowConfidence();
                    d.isCompound = decodedtext.isCompound();
                    d.isDirected = decodedtext.isDirectedMessage();
                    d.bits = decodedtext.bits();
                    d.dial = freq;
                    d.offset = offset;
                    d.text = decodedtext.message();
                    d.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
                    d.snr = decodedtext.snr();
                    d.isBuffered = false;
                    d.submode = decodedtext.submode();
                    d.tdrift = m_wideGraph->shouldAutoSyncSubmode(d.submode)
                                   ? DriftingDateTime::drift() / 1000.0
                                   : decodedtext.dt();

                    // if we have any "first" frame, and a buffer is already
                    // established, clear it...
                    int prevBufferOffset = -1;
                    if (((d.bits & Varicode::JS8CallFirst) ==
                         Varicode::JS8CallFirst) &&
                        hasExistingMessageBuffer(decodedtext.submode(),
                                                 d.offset, true,
                                                 &prevBufferOffset)) {
                        qCDebug(mainwindow_js8) << "first message encountered, "
                                                   "clearing existing buffer"
                                                << prevBufferOffset;
                        m_messageBuffer.remove(d.offset);
                    }

                    // if we have a data frame, and a message buffer has been
                    // established, buffer it...
                    if (hasExistingMessageBuffer(decodedtext.submode(),
                                                 d.offset, true,
                                                 &prevBufferOffset) &&
                        !decodedtext.isCompound() &&
                        !decodedtext.isDirectedMessage()) {
                        qCDebug(mainwindow_js8)
                            << "buffering data" << d.dial << d.offset << d.text;
                        d.isBuffered = true;
                        m_messageBuffer[d.offset].msgs.append(d);
                        // TODO: incremental display if it's "to" me.
                    }

                    m_rxActivityQueue.append(d);
                    m_bandActivity[offset].append(d);
                    while (m_bandActivity[offset].count() > 10) {
                        m_bandActivity[offset].removeFirst();
                    }
                }
#endif

            // Process compound callsign commands (put them in cache)"
#if 1
                qCDebug(mainwindow_js8) << "decoded" << decodedtext.frameType()
                                        << decodedtext.isCompound()
                                        << decodedtext.isDirectedMessage()
                                        << decodedtext.isHeartbeat();
                bool shouldProcessCompound = true;
                if (shouldProcessCompound && decodedtext.isCompound() &&
                    !decodedtext.isDirectedMessage()) {
                    cd.call = decodedtext.compoundCall();
                    cd.grid = decodedtext.extra(); // compound calls via pings
                                                   // may contain grid...
                    cd.snr = decodedtext.snr();
                    cd.dial = freq;
                    cd.offset = decodedtext.frequencyOffset();
                    cd.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
                    cd.bits = decodedtext.bits();
                    cd.submode = decodedtext.submode();
                    cd.tdrift = m_wideGraph->shouldAutoSyncSubmode(d.submode)
                                    ? DriftingDateTime::drift() / 1000.0
                                    : decodedtext.dt();

                    // Only respond to HEARTBEATS...remember that CQ messages
                    // are "Alt" pings
                    if (decodedtext.isHeartbeat()) {
                        if (decodedtext.isAlt()) {
                            // this is a cq with a standard or compound call,
                            // ala "KN4CRD/P: @ALLCALL CQ CQ CQ"
                            cd.cqTimestamp =
                                DriftingDateTime::currentDateTimeUtc();

                            // convert CQ to a directed command and process...
                            cmd.from = cd.call;
                            cmd.to = "@ALLCALL";
                            cmd.cmd = " CQ";
                            cmd.snr = cd.snr;
                            cmd.bits = cd.bits;
                            cmd.grid = cd.grid;
                            cmd.dial = cd.dial;
                            cmd.offset = cd.offset;
                            cmd.utcTimestamp = cd.utcTimestamp;
                            cmd.tdrift = cd.tdrift;
                            cmd.submode = cd.submode;
                            cmd.text = decodedtext.message();

                            // TODO: check bits so we only auto respond to
                            // "finished" cqs
                            m_rxCommandQueue.append(cmd);

                            // since this is no longer processed here we omit
                            // logging it here. if we change this behavior, we'd
                            // change this back to logging here.
                            // logCallActivity(cd, true);

                            // notification for cq
                            tryNotify("cq");

                        } else {
                            // convert HEARTBEAT to a directed command and
                            // process...
                            cmd.from = cd.call;
                            cmd.to = "@HB";
                            cmd.cmd = " HEARTBEAT";
                            cmd.snr = cd.snr;
                            cmd.bits = cd.bits;
                            cmd.grid = cd.grid;
                            cmd.dial = cd.dial;
                            cmd.offset = cd.offset;
                            cmd.utcTimestamp = cd.utcTimestamp;
                            cmd.tdrift = cd.tdrift;
                            cmd.submode = cd.submode;

                            // TODO: check bits so we only auto respond to
                            // "finished" heartbeats
                            m_rxCommandQueue.append(cmd);

                            // notification for hb
                            tryNotify("hb");
                        }

                    } else {
                        qCDebug(mainwindow_js8)
                            << "buffering compound call" << cd.offset << cd.call
                            << cd.bits;

                        hasExistingMessageBuffer(cd.submode, cd.offset, true,
                                                 nullptr);
                        m_messageBuffer[cd.offset].compound.append(cd);
                    }
                }
#endif

            // Parse commands
            // KN4CRD K1JT ?
#if 1
                bool shouldProcessDirected = true;
                if (shouldProcessDirected && decodedtext.isDirectedMessage()) {
                    auto parts = decodedtext.directedMessage();

                    cmd.from = parts.at(0);
                    cmd.to = parts.at(1);
                    cmd.cmd = parts.at(2);
                    cmd.dial = freq;
                    cmd.offset = decodedtext.frequencyOffset();
                    cmd.snr = decodedtext.snr();
                    cmd.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
                    cmd.bits = decodedtext.bits();
                    cmd.extra =
                        parts.length() > 2 ? parts.mid(3).join(" ") : "";
                    cmd.submode = decodedtext.submode();
                    cmd.tdrift = m_wideGraph->shouldAutoSyncSubmode(cmd.submode)
                                     ? DriftingDateTime::drift() / 1000.0
                                     : decodedtext.dt();

                    // if the command is a buffered command and its not the last
                    // frame OR we have from or to in a separate message
                    // (compound call)
                    if ((Varicode::isCommandBuffered(cmd.cmd) &&
                         (cmd.bits & Varicode::JS8CallLast) !=
                             Varicode::JS8CallLast) ||
                        cmd.from == "<....>" || cmd.to == "<....>") {
                        qCDebug(mainwindow_js8)
                            << "buffering cmd" << cmd.dial << cmd.offset
                            << cmd.cmd << cmd.from << cmd.to;

                        // log complete buffered callsigns immediately
                        if (cmd.from != "<....>" && cmd.to != "<....>") {
                            CallDetail cmdcd = {};
                            cmdcd.call = cmd.from;
                            cmdcd.bits = cmd.bits;
                            cmdcd.snr = cmd.snr;
                            cmdcd.dial = cmd.dial;
                            cmdcd.offset = cmd.offset;
                            cmdcd.utcTimestamp = cmd.utcTimestamp;
                            cmdcd.ackTimestamp =
                                cmd.to == m_config.my_callsign()
                                    ? cmd.utcTimestamp
                                    : QDateTime{};
                            cmdcd.tdrift = cmd.tdrift;
                            cmdcd.submode = cmd.submode;
                            logCallActivity(cmdcd, false);
                            logHeardGraph(cmd.from, cmd.to);
                        }

                        // merge any existing buffer to this frequency
                        hasExistingMessageBuffer(cmd.submode, cmd.offset, true,
                                                 nullptr);

                        if (cmd.to == m_config.my_callsign()) {
                            d.shouldDisplay = true;
                        }

                        m_messageBuffer[cmd.offset].cmd = cmd;
                        m_messageBuffer[cmd.offset].msgs.clear();
                    } else {
                        m_rxCommandQueue.append(cmd);
                    }

                    // check to see if this is a station we've heard 3rd party
                    bool shouldCaptureThirdPartyCallsigns = false;
                    if (shouldCaptureThirdPartyCallsigns &&
                        Radio::base_callsign(cmd.to) !=
                            Radio::base_callsign(m_config.my_callsign())) {
                        QString relayCall =
                            QString("%1|%2")
                                .arg(Radio::base_callsign(cmd.from))
                                .arg(Radio::base_callsign(cmd.to));
                        int snr = -100;
                        if (parts.length() == 4) {
                            snr = QString(parts.at(3)).toInt();
                        }

                        // CallDetail td = {};
                        td.through = cmd.from;
                        td.call = cmd.to;
                        td.grid = "";
                        td.snr = snr;
                        td.dial = cmd.dial;
                        td.offset = cmd.offset;
                        td.utcTimestamp = cmd.utcTimestamp;
                        td.tdrift = cmd.tdrift;
                        td.submode = cmd.submode;
                        logCallActivity(td, true);
                        logHeardGraph(cmd.from, cmd.to);
                    }
                }
#endif
            }
        },
        event);
}

bool MainWindow::hasExistingMessageBufferToMe(int *const pOffset) {
    for (auto const [offset, buffer] : m_messageBuffer.asKeyValueRange()) {
        // if this is a valid buffer and it's to me...
        if (buffer.cmd.utcTimestamp.isValid() &&
            (buffer.cmd.to == m_config.my_callsign() ||
             buffer.cmd.to == Radio::base_callsign(m_config.my_callsign()))) {
            if (pOffset)
                *pOffset = offset;
            return true;
        }
    }

    return false;
}

bool MainWindow::hasExistingMessageBuffer(int submode, int offset, bool drift,
                                          int *pPrevOffset) {
    if (m_messageBuffer.contains(offset)) {
        if (pPrevOffset)
            *pPrevOffset = offset;
        return true;
    }

    int const range = JS8::Submode::rxThreshold(submode);

    QList<int> offsets = generateOffsets(offset - range, offset + range);

    foreach (int prevOffset, offsets) {
        if (!m_messageBuffer.contains(prevOffset)) {
            continue;
        }

        if (drift) {
            m_messageBuffer[offset] = m_messageBuffer[prevOffset];
            m_messageBuffer.remove(prevOffset);
        }

        if (pPrevOffset)
            *pPrevOffset = prevOffset;
        return true;
    }

    return false;
}

bool MainWindow::hasClosedExistingMessageBuffer(int offset) {
#if 0
    int range = 10;
    if(m_nSubMode == Varicode::JS8CallFast){ range = 16; }
    if(m_nSubMode == Varicode::JS8CallTurbo){ range = 32; }

    return offset - range <= m_lastClosedMessageBufferOffset && m_lastClosedMessageBufferOffset <= offset + range;
#elif 0
    int range = 10;
    if (m_nSubMode == Varicode::JS8CallFast) {
        range = 16;
    }
    if (m_nSubMode == Varicode::JS8CallTurbo) {
        range = 32;
    }

    return m_lastClosedMessageBufferOffset - range <= offset &&
           offset <= m_lastClosedMessageBufferOffset + range;
#else
    Q_UNUSED(offset);
#endif
    return false;
}

void MainWindow::logCallActivity(CallDetail d, bool spot) {
    // don't log empty calls
    if (d.call.trimmed().isEmpty()) {
        return;
    }

    // don't log relay calls
    if (d.call.contains(">")) {
        return;
    }

    if (m_callActivity.contains(d.call)) {
        // update (keep grid)
        CallDetail old = m_callActivity[d.call];
        if (d.grid.isEmpty() && !old.grid.isEmpty()) {
            d.grid = old.grid;
        }
        if (!d.ackTimestamp.isValid() && old.ackTimestamp.isValid()) {
            d.ackTimestamp = old.ackTimestamp;
        }
        if (!d.cqTimestamp.isValid() && old.cqTimestamp.isValid()) {
            d.cqTimestamp = old.cqTimestamp;
        }
        m_callActivity[d.call] = d;
    } else {
        // create
        m_callActivity[d.call] = d;

        // notification of old and new callsigns
        if (m_logBook.hasWorkedBefore(d.call, "")) {
            tryNotify("call_old");
        } else {
            tryNotify("call_new");
        }
    }

    // enqueue for spotting to psk reporter
    if (spot) {
        m_rxCallQueue.append(d);
    }
}

void MainWindow::logHeardGraph(QString from, QString to) {
    auto my_callsign = m_config.my_callsign();

    // hearing
    if (m_heardGraphOutgoing.contains(my_callsign)) {
        m_heardGraphOutgoing[my_callsign].insert(from);
    } else {
        m_heardGraphOutgoing[my_callsign].insert(from);
    }

    // heard by
    if (m_heardGraphIncoming.contains(from)) {
        m_heardGraphIncoming[from].insert(my_callsign);
    } else {
        m_heardGraphIncoming[from] = {my_callsign};
    }

    if (to == "@ALLCALL") {
        return;
    }

    // hearing
    if (m_heardGraphOutgoing.contains(from)) {
        m_heardGraphOutgoing[from].insert(to);
    } else {
        m_heardGraphOutgoing[from] = {to};
    }

    // heard by
    if (m_heardGraphIncoming.contains(to)) {
        m_heardGraphIncoming[to].insert(from);
    } else {
        m_heardGraphIncoming[to] = {from};
    }
}

QString MainWindow::lookupCallInCompoundCache(QString const &call) {
    QString myBaseCall = Radio::base_callsign(m_config.my_callsign());
    if (call == myBaseCall) {
        return m_config.my_callsign();
    }
    return m_compoundCallCache.value(call, call);
}

void MainWindow::spotReport(int const submode, int const dial, int const offset,
                            int const snr, QString const &callsign,
                            QString const &grid) {
    if (!m_config.spot_to_reporting_networks() ||
        (m_config.spot_blacklist().contains(callsign) ||
         m_config.spot_blacklist().contains(Radio::base_callsign(callsign))))
        return;

    Q_EMIT spotClientEnqueueSpot(callsign, grid, submode, dial, offset, snr);
}

void MainWindow::spotCmd(CommandDetail const &cmd) {
    if (!m_config.spot_to_reporting_networks() ||
        (m_config.spot_blacklist().contains(cmd.from) ||
         m_config.spot_blacklist().contains(Radio::base_callsign(cmd.from))))
        return;

    QString cmdStr = cmd.cmd;

    if (!cmdStr.trimmed().isEmpty()) {
        cmdStr = Varicode::lstrip(cmd.cmd);
    }

    Q_EMIT spotClientEnqueueCmd(cmdStr, cmd.from, cmd.to, cmd.relayPath,
                                cmd.text, cmd.grid, cmd.extra, cmd.submode,
                                cmd.dial, cmd.offset, cmd.snr);
}

// KN4CRD: @APRSIS CMD :EMAIL-2  :email@domain.com booya{1
void MainWindow::spotAprsCmd(CommandDetail const &cmd) {
    if (!m_config.spot_to_reporting_networks())
        return;
    if (!m_config.spot_to_aprs())
        return;
    if (m_config.spot_blacklist().contains(cmd.from) ||
        m_config.spot_blacklist().contains(Radio::base_callsign(cmd.from)))
        return;

    if (cmd.cmd != " CMD")
        return;

    qCDebug(mainwindow_js8)
        << "APRSISClient Enqueueing Third Party Text" << cmd.from << cmd.text;

    auto by_call = APRSISClient::replaceCallsignSuffixWithSSID(
        m_config.my_callsign(), Radio::base_callsign(m_config.my_callsign()));
    auto from_call = APRSISClient::replaceCallsignSuffixWithSSID(
        cmd.from, Radio::base_callsign(cmd.from));

    // we use a queued signal here so we can process these spots in a network
    // thread to prevent blocking the gui/decoder while waiting on TCP
    emit aprsClientEnqueueThirdParty(by_call, from_call, cmd.text);
}

void MainWindow::spotAprsGrid(int dial, int offset, int snr, QString callsign,
                              QString grid) {
    if (!m_config.spot_to_reporting_networks())
        return;
    if (!m_config.spot_to_aprs())
        return;
    if (m_config.spot_blacklist().contains(callsign) ||
        m_config.spot_blacklist().contains(Radio::base_callsign(callsign)))
        return;
    if (grid.length() < 4)
        return;

    Frequency frequency = dial + offset;

    auto comment = QString("%1MHz %2dB")
                       .arg(Radio::frequency_MHz_string(frequency))
                       .arg(Varicode::formatSNR(snr));
    if (callsign.contains("/")) {
        comment = QString("%1 %2").arg(callsign).arg(comment);
    }

    auto by_call = APRSISClient::replaceCallsignSuffixWithSSID(
        m_config.my_callsign(), Radio::base_callsign(m_config.my_callsign()));
    auto from_call = APRSISClient::replaceCallsignSuffixWithSSID(
        callsign, Radio::base_callsign(callsign));

    // we use a queued signal here so we can process these spots in a network
    // thread to prevent blocking the gui/decoder while waiting on TCP
    emit aprsClientEnqueueSpot(by_call, from_call, grid, comment);
}

void MainWindow::pskLogReport(QString const &mode, int const dial,
                              int const offset, int const snr,
                              QString const &callsign, QString const &grid,
                              QDateTime const &utcTimestamp) {
    if (!m_config.spot_to_reporting_networks() ||
        (m_config.spot_blacklist().contains(callsign) ||
         m_config.spot_blacklist().contains(Radio::base_callsign(callsign))))
        return;

    Q_EMIT pskReporterAddRemoteStation(callsign, grid, dial + offset, mode, snr,
                                       utcTimestamp);
}

void MainWindow::refuseToSendIn30mWSPRBand() {
    if (m_transmitting or m_auto or m_tune) {
        m_dateTimeLastTX = DriftingDateTime::currentDateTimeLocal();

        // Don't transmit another mode in the 30 m WSPR sub-band
        Frequency onAirFreq = m_freqNominal + freq();

        // qCDebug(mainwindow_js8) << "transmitting on" << onAirFreq;

        if (10139900 <= onAirFreq && onAirFreq <= 10140320) {
            qCWarning(mainwindow_js8)
                << "QRG" << onAirFreq
                << "found to be in WSPR guard band 10139.9 - 10140.32 kHz "
                   "where this programm will not transmit, so  canceling all "
                   "transmissions.";
            m_isTimeToSend = false;
            if (m_auto)
                auto_tx_mode(false);
            if (m_hb_loop->isActive()) {
                m_hb_loop->onLoopCancel();
            }
            if (m_cq_loop->isActive()) {
                m_cq_loop->onLoopCancel();
            }
            if (onAirFreq != m_onAirFreq0) {
                m_onAirFreq0 = onAirFreq;
                QTimer::singleShot(0, [this] {
                    JS8MessageBox::warning_message(
                        this, tr("WSPR Guard Band"),
                        tr("Please choose another Tx frequency."
                           " The app will not knowingly transmit another"
                           " mode in the WSPR sub-band on 30m."));
                });
            }
        }
    }
}

void MainWindow::prepareSending(qint64 nowMS) {
    // TX Duration in seconds.
    const double tx_duration = JS8::Submode::txDuration(m_nSubMode);
    const unsigned period = JS8::Submode::period(m_nSubMode);

    const double seconds_into_the_period = (nowMS % (period * 1000)) / 1000.0;
    const double tx_delay = m_TxDelay;

    const bool time_is_in_tx_delay =
        (period - tx_delay) <= seconds_into_the_period;

    // Are we during the time we might be sending?
    const bool m_timeToSend = ((0 <= seconds_into_the_period) and
                               (seconds_into_the_period < tx_duration)) or
                              time_is_in_tx_delay or m_tune;

    auto const msgLength = QStringView(m_nextFreeTextMsg).trimmed().length();

    // TODO: stop
    if (msgLength == 0 && !m_tune) {
        m_stopTxButtonIsLongterm = false;
        this->on_stopTxButton_clicked();
        m_stopTxButtonIsLongterm = true;
    }

    double const fraction_of_tx_slot = seconds_into_the_period / period;

    // 15.0 - 12.6
    double const ratio = JS8::Submode::computeRatio(m_nSubMode, m_TRperiod);

    // the late threshold is the dead air time minus the tx delay time
    float lateThreshold = ratio - (m_config.txDelay() / m_TRperiod);
    if (m_nSubMode == Varicode::JS8CallFast) {
        // for the faster mode, only allow 3/4 late threshold
        lateThreshold *= 0.75;
    } else if (m_nSubMode == Varicode::JS8CallTurbo ||
               m_nSubMode == Varicode::JS8CallUltra) {
        // for the turbo and ultra mode, only allow 1/2 late threshold
        lateThreshold *= 0.5;
    };

    // qCDebug(mainwindow_js8) << "nowMS" << nowMS << "period" << period <<
    // "tx_delay" << tx_delay
    //                         << "seconds_into_the_period" <<
    //                         seconds_into_the_period
    //                         << "time_is_in_tx_delay" << time_is_in_tx_delay
    //                         << "fraction_of_tx_slot" << fraction_of_tx_slot
    //                         << "m_iptt" << m_iptt << "m_timeToSend" <<
    //                         m_timeToSend << "msgLength" << msgLength <<
    //                         "m_tune" << m_tune;

    if (m_iptt == 0 &&
        ((m_timeToSend &&
          (fraction_of_tx_slot < lateThreshold or time_is_in_tx_delay) &&
          0 < msgLength) ||
         m_tune)) {
        // This signals the transmitter to switch to sending.
        // When that has happened, we get a callback from
        // handle_transceiver_update, which will start the audio.
        m_iptt = 1;
        m_generateAudioWhenPttConfirmedByTX = true;
        setRig();
        setXIT(freq());
        emitPTT(true);
    }

    // TODO: stop
    if (!m_timeToSend and !m_tune)
        m_btxok = false; // Time to stop transmitting

    // Calculate Tx tones when needed
    if ((m_iptt == 1 && m_iptt0 == 0) || m_restart) {
        //----------------------------------------------------------------------

        copyMessage(m_nextFreeTextMsg, message);

        if (m_lastMessageSent != m_currentMessage ||
            m_lastMessageType != m_currentMessageType) {
            m_lastMessageSent = m_currentMessage;
            m_lastMessageType = m_currentMessageType;
        }

        m_currentMessageType = 0;

        if (m_tune) {
            itone[0] = 0;
        } else {
            JS8::encode(m_i3bit,
                        JS8::Costas::array(JS8::Submode::costas(m_nSubMode)),
                        message,
                        const_cast<int *>(reinterpret_cast<volatile int *>(
                            itone))); // XXX ick...

            std::fill_n(std::begin(msgsent), 22, ' ');
            std::copy_n(std::begin(message), 12, std::begin(msgsent));

            if (mainwindow_js8().isDebugEnabled()) {
                qCDebug(mainwindow_js8) << "-> msg:" << message;
                qCDebug(mainwindow_js8) << "-> bit:" << m_i3bit;
                for (int i = 0; i < 7; ++i)
                    qCDebug(mainwindow_js8)
                        << "-> tone" << i << "=" << itone[i];
                for (int i = JS8_NUM_SYMBOLS - 7; i < JS8_NUM_SYMBOLS; ++i)
                    qCDebug(mainwindow_js8)
                        << "-> tone" << i << "=" << itone[i];
            }

            msgibits = m_i3bit;
            msgsent[22] = 0;
            m_currentMessage = QString::fromLatin1(msgsent).trimmed();
            m_currentMessageBits = msgibits;

            emitTones();
        }

        if (m_tune) {
            m_currentMessage = "TUNE";
            m_currentMessageType = -1;
        }
        if (m_restart) {
            write_transmit_entry("ALL.TXT");
        }

        auto msg_parts = m_currentMessage.split(' ', Qt::SkipEmptyParts);
        if (msg_parts.size() > 2) {
            // clean up short code forms
            msg_parts[0].remove(QChar{'<'});
            msg_parts[1].remove(QChar{'>'});
        }

        if ((m_currentMessageType < 6 || 7 == m_currentMessageType) &&
            msg_parts.length() >= 3 &&
            (msg_parts[1] == m_config.my_callsign() ||
             msg_parts[1] == m_baseCall)) {
            int i1;
            bool ok;
            i1 = msg_parts[2].toInt(&ok);
            if (ok and i1 >= -50 and i1 < 50) {
                m_rptSent = msg_parts[2];
            } else {
                if (msg_parts[2].mid(0, 1) == "R") {
                    i1 = msg_parts[2].mid(1).toInt(&ok);
                    if (ok and i1 >= -50 and i1 < 50) {
                        m_rptSent = msg_parts[2].mid(1);
                    }
                }
            }
        }
        m_restart = false;
        //----------------------------------------------------------------------
    }

    if (m_iptt == 1 && m_iptt0 == 0) {
        auto const &current_message = QString::fromLatin1(msgsent);
        if (m_config.watchdog() && current_message != m_msgSent0) {
            // new messages don't reset the idle timer :|
            // tx_watchdog (false);  // in case we are auto sequencing
            m_msgSent0 = current_message;
        }

        if (!m_tune) {
            write_transmit_entry("ALL.TXT");
        }

        // TODO: jsherer - perhaps an on_transmitting signal?
        m_lastTxStartTime = DriftingDateTime::currentDateTimeUtc();

        m_transmitting = true;
        transmitDisplay(true);
        statusUpdate();
    }

    // TODO: stop
    if (!m_btxok && m_btxok0 && m_iptt == 1)
        stopTx();
}

void MainWindow::updateClockUI(const QDateTime &now) {
    qint64 drift = DriftingDateTime::drift();
    QStringList parts;
    parts
        << (now.time().toString() +
            (!drift
                 ? " "
                 : QString(" (%1%2ms)").arg(drift > 0 ? "+" : "").arg(drift)));
    parts << now.date().toString("yyyy MMM dd");
    ui->labUTC->setText(parts.join("\n"));
}

//------------------------------------------------------------- //guiUpdate()
void MainWindow::guiUpdate() {

    unsigned period = JS8::Submode::period(m_nSubMode);

    m_TRperiod = period; // Investigate: Does anyone need this?

    // Propagate any tx delay change to m_hb_loop and m_cq_loop.
    double tx_delay_now = m_config.txDelay();
    if (tx_delay_now != m_TxDelay) {
        m_TxDelay = tx_delay_now;
        qint64 tx_delay_ms = std::lround(tx_delay_now * 1000);
        m_hb_loop->onTxDelayChange(tx_delay_ms);
        m_cq_loop->onTxDelayChange(tx_delay_ms);
    }

    const QDateTime now = DriftingDateTime::currentDateTimeUtc();
    const qint64 seconds_since_epoch = now.toSecsSinceEpoch();

    if (m_transmitting or m_auto or m_tune) {
        refuseToSendIn30mWSPRBand();
        prepareSending(now.toMSecsSinceEpoch());
    }

    // Once per second:
    if (seconds_since_epoch != m_sec0) {
        m_sec0 = seconds_since_epoch;

        updateClockUI(now);

        if (m_monitoring or m_transmitting) {
            // We are lucky that TX delay starts well into the second
            // and lasts less than a second. So as long as we
            // do this near the begining of a second, we will never hit
            // the confusing "progress" of tx delay.
            progressBar.setMaximum(period);
            int progress = seconds_since_epoch % period;
            progressBar.setValue(progress);
        } else {
            progressBar.setValue(0);
        }

        if (m_transmitting) {
            tx_status_label.setStyleSheet(
                "QLabel{background-color: #ff2222; color:#000}");
            if (m_tune) {
                tx_status_label.setText("Tx: TUNE");
            } else {
                auto message =
                    DecodedText(msgsent, msgibits, m_nSubMode).message();
                tx_status_label.setText(
                    QString("Tx: %1").arg(message).left(40).trimmed());
            }
            transmitDisplay(true);
        } else if (m_monitoring) {
            if (m_tx_watchdog) {
                tx_status_label.setStyleSheet(
                    "QLabel{background-color: #000; color:#fff}");
                tx_status_label.setText("Idle timeout");
            } else {
                tx_status_label.setStyleSheet(
                    "QLabel{background-color: #22ff22}");
                tx_status_label.setText(m_decoderBusy ? "Decoding"
                                                      : "Receiving");
            }
            transmitDisplay(false);
        } else if (!m_tx_watchdog) {
            tx_status_label.setStyleSheet("");
            tx_status_label.setText("");
        }

        auto callLabel = m_config.my_callsign();
        if (m_config.use_dynamic_grid() && !m_config.my_grid().isEmpty()) {
            callLabel =
                QString("%1 - %2").arg(callLabel).arg(m_config.my_grid());
        }
        ui->labCallsign->setText(callLabel);

        if (!m_monitoring) {
            ui->signal_meter_widget->setValue(0, 0);
        }

        // once per period
        if (seconds_since_epoch % period == 0) {
            tryBandHop();
        }

        // Need to do processing at the end of the period
        // or when there is something in m_rxActivityQueue.
        bool forceDirty = (seconds_since_epoch % period == 0) ||
                          ((seconds_since_epoch - 1) % period == 0) ||
                          !m_rxActivityQueue.isEmpty();

        // update the dial frequency once per second..
        displayDialFrequency();
        updateHBButtonDisplay();
        updateCQButtonDisplay();

        // once per second...but not when we're transmitting, unless it's in the
        // first second...
        if (!m_transmitting || (seconds_since_epoch % period == 0)) {
            // process all received activity...
            processActivity(forceDirty);

            // process outgoing tx queue...
            processTxQueue();

            // once processed, lets update the display...
            displayActivity(forceDirty);
            updateButtonDisplay();
            updateTextDisplay();
        }
    } // end of stuff we do once per second.

    displayTransmit();

    m_iptt0 = m_iptt;
    m_btxok0 = m_btxok;

    // Set the time to hit the start of the next UI_POLL_INTERVAL_MS slot.
    // This automatically hits close to the start of each second
    // and hence close to the start of each transmit period.
    qint64 now_at_end_ms = DriftingDateTime::currentMSecsSinceEpoch();
    qint64 time_into_poll_slot = now_at_end_ms % UI_POLL_INTERVAL_MS;
    qint64 until_start_of_next_poll_slot =
        UI_POLL_INTERVAL_MS - time_into_poll_slot;
    m_guiTimer.start(until_start_of_next_poll_slot);
} // End of guiUpdate

void MainWindow::startTx() {
#if IDLE_BLOCKS_TX
    if (m_tx_watchdog) {
        return;
    }
#endif

    auto text = ui->extFreeTextMsgEdit->toPlainText();
    if (!ensureCreateMessageReady(text)) {
        return;
    }

    if (!prepareNextMessageFrame()) {
        return;
    }

    m_dateTimeQSOOn = QDateTime{};
    if (m_transmitting)
        m_restart = true;

    if (!m_auto)
        auto_tx_mode(true);

    // disallow editing of the text while transmitting
    // ui->extFreeTextMsgEdit->setReadOnly(true);
    update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", true);

    // update the tx button display
    updateTxButtonDisplay();
}

void MainWindow::transmit() {
    if (m_modulator->isIdle()) {
        qDebug(mainwindow_js8) << "Asking the modulator to emit audio.";
        Q_EMIT sendMessage(freq() + m_XIT, m_nSubMode, m_TxDelay, m_soundOutput,
                           m_config.audio_output_channel());
        ui->signal_meter_widget->setValue(0, 0);
    } else {
        qDebug(mainwindow_js8) << "Not asking the modulator to emit audio as "
                                  "modulator isn't idle.";
    }
}

void MainWindow::stopTx() {
    Q_EMIT endTransmitMessage();

    auto dt = DecodedText(m_currentMessage.trimmed(), m_currentMessageBits,
                          m_nSubMode);
    last_tx_label.setText("Last Tx: " +
                          dt.message()); // m_currentMessage.trimmed());

    // TODO: uncomment if we want to mark after the frame is sent.
    //// // start message marker
    //// // - keep track of the total message sent so far, and mark it having
    /// been sent / m_totalTxMessage.append(dt.message()); /
    /// ui->extFreeTextMsgEdit->setCharsSent(m_totalTxMessage.length()); /
    /// qCDebug(mainwindow_js8) << "total sent:\n" << m_totalTxMessage; / // end
    /// message marker

    m_btxok = false;
    m_transmitting = false;
    m_iptt = 0;
    m_lastTxStopTime = DriftingDateTime::currentDateTimeUtc();
    if (!m_tx_watchdog) {
        tx_status_label.setStyleSheet("");
        tx_status_label.setText("");
    }

#if IDLE_BLOCKS_TX
    bool shouldContinue = !m_tx_watchdog && prepareNextMessageFrame();
#else
    bool shouldContinue = prepareNextMessageFrame();
#endif
    if (!shouldContinue) {
        // TODO: jsherer - split this up...
        ui->extFreeTextMsgEdit->clear();
        ui->extFreeTextMsgEdit->setReadOnly(false);
        update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false);
        bool previous_stopTxButtonIsLongterm = m_stopTxButtonIsLongterm;
        m_stopTxButtonIsLongterm = false;
        on_stopTxButton_clicked();
        m_stopTxButtonIsLongterm = previous_stopTxButtonIsLongterm;
        tryRestoreFreqOffset();
    }

    pttReleaseTimer.start(
        TX_SWITCHOFF_DELAY); // end-of-transmission sequencer delay stopTx2
    monitor(true);
    statusUpdate();
}

/**
 *  stopTx2 is called from stopTx to open the PTT
 */
void MainWindow::stopTx2() {
    // GM8JCF: m_txFrameCount is set to the number of frames to be transmitted
    // when the send button is pressed and remains at that count until the last
    // frame is transmitted. So, we keep the PTT ON so long as m_txFrameCount is
    // non-zero

    qCDebug(mainwindow_js8) << "stopTx2 frames left" << m_txFrameCount;

    // If we're holding the PTT and there are more frames to transmit, do not
    // emit the PTT signal
    if (!m_tune && m_config.hold_ptt() && m_txFrameCount > 0) {
        return;
    }

    // Otherwise, emit the PTT signal
    emitPTT(false);
}

void MainWindow::TxAgain() { auto_tx_mode(true); }

void MainWindow::cacheActivity(QString key) {
    m_callActivityBandCache[key] = m_callActivity;
    m_bandActivityBandCache[key] = m_bandActivity;
    m_rxTextBandCache[key] = ui->textEditRX->toHtml();
    m_heardGraphIncomingBandCache[key] = m_heardGraphIncoming;
    m_heardGraphOutgoingBandCache[key] = m_heardGraphOutgoing;
}

void MainWindow::restoreActivity(QString key) {
    if (m_callActivityBandCache.contains(key)) {
        m_callActivity = m_callActivityBandCache[key];
    }

    if (m_bandActivityBandCache.contains(key)) {
        m_bandActivity = m_bandActivityBandCache[key];
    }

    if (m_rxTextBandCache.contains(key)) {
        ui->textEditRX->setHtml(m_rxTextBandCache[key]);
    }

    if (m_heardGraphIncomingBandCache.contains(key)) {
        m_heardGraphIncoming = m_heardGraphIncomingBandCache[key];
    }

    if (m_heardGraphOutgoingBandCache.contains(key)) {
        m_heardGraphOutgoing = m_heardGraphOutgoingBandCache[key];
    }

    displayActivity(true);
}

void MainWindow::clearActivity() {
    qCDebug(mainwindow_js8) << "clear activity";

    m_callSeenHeartbeat.clear();
    m_compoundCallCache.clear();
    m_rxCallCache.clear();
    m_rxCallQueue.clear();
    m_rxRecentCache.clear();
    m_rxDirectedCache.clear();
    m_rxCommandQueue.clear();
    m_lastTxMessage.clear();

    refreshInboxCounts();
    resetTimeDeltaAverage();

    clearBandActivity();
    clearRXActivity();
    clearCallActivity();

    displayActivity(true);
}

void MainWindow::clearBandActivity() {
    qCDebug(mainwindow_js8) << "clear band activity";
    m_bandActivity.clear();
    ui->tableWidgetRXAll->setRowCount(0);

    resetTimeDeltaAverage();
    displayBandActivity();
}

void MainWindow::clearRXActivity() {
    qCDebug(mainwindow_js8) << "clear rx activity";

    m_rxFrameBlockNumbers.clear();
    m_rxActivityQueue.clear();

    ui->textEditRX->clear();

    // make sure to clear the read only and transmitting flags so there's always
    // a "way out"
    ui->extFreeTextMsgEdit->clear();
    ui->extFreeTextMsgEdit->setReadOnly(false);
    update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false);
}

void MainWindow::clearCallActivity() {
    qCDebug(mainwindow_js8) << "clear call activity";

    m_callActivity.clear();

    m_heardGraphIncoming.clear();
    m_heardGraphOutgoing.clear();

    ui->tableWidgetCalls->setRowCount(0);

    bool showIconColumn = false;
    createGroupCallsignTableRows(ui->tableWidgetCalls, "", showIconColumn);

    resetTimeDeltaAverage();
    displayCallActivity();
}

void MainWindow::createGroupCallsignTableRows(QTableWidget *table,
                                              QString const &selectedCall,
                                              bool &showIconColumn) {
    int count = 0;
    auto now = DriftingDateTime::currentDateTimeUtc();
    int callsignAging = m_config.callsign_aging();

    int startCol = 1;

    foreach (auto cd, m_callActivity.values()) {
        if (cd.call.trimmed().isEmpty()) {
            continue;
        }
        if (callsignAging &&
            cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
            continue;
        }
        count++;
    }

    table->horizontalHeaderItem(startCol)->setText(
        count == 0 ? columnLabel("Callsigns")
                   : QString(columnLabel("Callsigns (%1)")).arg(count));

    if (!m_config.avoid_allcall()) {
        table->insertRow(table->rowCount());

        auto emptyItem = new QTableWidgetItem("");
        emptyItem->setData(Qt::UserRole, QVariant("@ALLCALL"));
        table->setItem(table->rowCount() - 1, 0, emptyItem);

        auto item = new QTableWidgetItem(QString("@ALLCALL"));
        item->setData(Qt::UserRole, QVariant("@ALLCALL"));

        table->setItem(table->rowCount() - 1, startCol, item);
        table->setSpan(table->rowCount() - 1, startCol, 1,
                       table->columnCount());
        if (selectedCall == "@ALLCALL") {
            table->item(table->rowCount() - 1, 0)->setSelected(true);
            table->item(table->rowCount() - 1, startCol)->setSelected(true);
        }
    }

    auto groups = m_config.my_groups().values();
    std::sort(groups.begin(), groups.end());
    foreach (auto group, groups) {
        int col = 0;
        table->insertRow(table->rowCount());

        bool hasMessage = m_rxInboxCountCache.value(group, 0) > 0;

        auto iconItem = new QTableWidgetItem(hasMessage ? "\u2691" : "");
        iconItem->setData(Qt::UserRole, QVariant(group));
        iconItem->setToolTip(hasMessage ? "Message Available" : "");
        iconItem->setTextAlignment(Qt::AlignHCenter | Qt::AlignVCenter);
        table->setItem(table->rowCount() - 1, col++, iconItem);
        if (hasMessage) {
            showIconColumn = true;
        }

        auto item = new QTableWidgetItem(group);
        item->setData(Qt::UserRole, QVariant(group));
        item->setToolTip(generateCallDetail(group));
        table->setItem(table->rowCount() - 1, col, item);
        table->setSpan(table->rowCount() - 1, col, 1, table->columnCount());

        if (selectedCall == group) {
            table->item(table->rowCount() - 1, 0)->setSelected(true);
            table->item(table->rowCount() - 1, col)->setSelected(true);
        }
    }
}

void MainWindow::displayTextForFreq(QString text, int freq, QDateTime date,
                                    bool isTx, bool isNewLine, bool isLast) {
    int lowFreq = freq / 10 * 10;
    int highFreq = lowFreq + 10;

    int block = -1;

    if (m_rxFrameBlockNumbers.contains(freq)) {
        block = m_rxFrameBlockNumbers[freq];
    } else if (m_rxFrameBlockNumbers.contains(lowFreq)) {
        block = m_rxFrameBlockNumbers[lowFreq];
        freq = lowFreq;
    } else if (m_rxFrameBlockNumbers.contains(highFreq)) {
        block = m_rxFrameBlockNumbers[highFreq];
        freq = highFreq;
    }

    qCDebug(mainwindow_js8) << "existing block?" << block << freq;

    if (isNewLine) {
        m_rxFrameBlockNumbers.remove(freq);
        m_rxFrameBlockNumbers.remove(lowFreq);
        m_rxFrameBlockNumbers.remove(highFreq);
        block = -1;
    }

    block = writeMessageTextToUI(date, text, freq, isTx, block);

    // never cache tx or last lines
    if (/*isTx || */ isLast) {
        // reset the cache so we're always progressing forward
        m_rxFrameBlockNumbers.clear();
    } else {
        m_rxFrameBlockNumbers.insert(freq, block);
        m_rxFrameBlockNumbers.insert(lowFreq, block);
        m_rxFrameBlockNumbers.insert(highFreq, block);
    }
}

void MainWindow::writeNoticeTextToUI(QDateTime date, QString text) {
    auto c = ui->textEditRX->textCursor();
    c.movePosition(QTextCursor::End);
    if (c.block().length() > 1) {
        c.insertBlock();
    }

    text = text.toHtmlEscaped();
    c.insertBlock();
    c.insertHtml(QString("<strong>%1 - %2</strong>")
                     .arg(date.time().toString())
                     .arg(text));

    c.movePosition(QTextCursor::End);

    ui->textEditRX->ensureCursorVisible();
    ui->textEditRX->verticalScrollBar()->setValue(
        ui->textEditRX->verticalScrollBar()->maximum());
}

int MainWindow::writeMessageTextToUI(QDateTime date, QString text, int freq,
                                     bool isTx, int block) {
    auto c = ui->textEditRX->textCursor();

    // find an existing block (that does not contain an EOT marker)
    bool found = false;
    if (block != -1) {
        QTextBlock b = c.document()->findBlockByNumber(block);
        c.setPosition(b.position());
        c.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);

        auto blockText = c.selectedText();
        c.clearSelection();
        c.movePosition(QTextCursor::EndOfBlock, QTextCursor::MoveAnchor);

        if (!blockText.contains(m_config.eot())) {
            found = true;
        }
    }

    if (!found) {
        c.movePosition(QTextCursor::End);
        if (c.block().length() > 1) {
            c.insertBlock();
        }
    }

    // fixup duplicate acks
    auto tc = c.document()->find(text);
    if (!tc.isNull() && tc.selectedText() == text &&
        (text.contains(" ACK ") || text.contains(" HEARTBEAT SNR "))) {
        tc.select(QTextCursor::BlockUnderCursor);

        if (tc.selectedText().trimmed().startsWith(date.time().toString())) {
            qCDebug(mainwindow_js8)
                << "found" << tc.selectedText() << "so not displaying...";
            return tc.blockNumber();
        }
    }

    if (found) {
        c.clearSelection();
        c.insertText(text);
    } else {
        text = text.toHtmlEscaped();
        text = text.replace("\n", "<br/>");
        text = text.replace("  ", "&nbsp;&nbsp;");
        c.insertBlock();
        c.insertHtml(QString("%1 - (%2) - %3")
                         .arg(date.time().toString())
                         .arg(freq)
                         .arg(text));
    }

    if (isTx) {
        c.block().setUserState(State::TX);
        highlightBlock(c.block(), m_config.tx_text_font(),
                       m_config.color_tx_foreground(), QColor(Qt::transparent));
    } else {
        c.block().setUserState(State::RX);
        highlightBlock(c.block(), m_config.rx_text_font(),
                       m_config.color_rx_foreground(), QColor(Qt::transparent));
    }

    ui->textEditRX->ensureCursorVisible();
    ui->textEditRX->verticalScrollBar()->setValue(
        ui->textEditRX->verticalScrollBar()->maximum());

    return c.blockNumber();
}

bool MainWindow::isMessageQueuedForTransmit() {
    return m_transmitting || m_txFrameCount > 0;
}

bool MainWindow::isInDecodeDelayThreshold(int ms) {
    if (!m_lastTxStopTime.isValid() || m_lastTxStopTime.isNull()) {
        return false;
    }

    return m_lastTxStopTime.msecsTo(DriftingDateTime::currentDateTimeUtc()) <
           ms;
}

void MainWindow::prependMessageText(QString text) {
    // don't add message text if we already have a transmission queued...
    if (isMessageQueuedForTransmit()) {
        return;
    }

    auto c = QTextCursor(ui->extFreeTextMsgEdit->textCursor());
    c.movePosition(QTextCursor::Start);
    c.insertText(text);
}

void MainWindow::addMessageText(QString text, bool clear,
                                bool selectFirstPlaceholder) {
    // don't add message text if we already have a transmission queued...
    if (isMessageQueuedForTransmit()) {
        return;
    }

    if (clear) {
        ui->extFreeTextMsgEdit->clear();
    }

    QTextCursor c = ui->extFreeTextMsgEdit->textCursor();
    if (c.hasSelection()) {
        c.removeSelectedText();
    }

    int pos = c.position();
    c.movePosition(QTextCursor::PreviousCharacter, QTextCursor::KeepAnchor);

    bool isSpace =
        c.selectedText().isEmpty() || c.selectedText().at(0).isSpace();
    c.clearSelection();

    c.setPosition(pos);

    if (!isSpace) {
        c.insertText(" ");
    }

    c.insertText(text);

    if (selectFirstPlaceholder) {
        auto match = QRegularExpression("(\\[[^\\]]+\\])")
                         .match(ui->extFreeTextMsgEdit->toPlainText());
        if (match.hasMatch()) {
            c.setPosition(match.capturedStart());
            c.setPosition(match.capturedEnd(), QTextCursor::KeepAnchor);
            ui->extFreeTextMsgEdit->setTextCursor(c);
        }
    }

    ui->extFreeTextMsgEdit->setFocus();
}

void MainWindow::confirmThenEnqueueMessage(int timeout, int priority,
                                           QString message, int offset,
                                           Callback c) {
    SelfDestructMessageBox *m = new SelfDestructMessageBox(
        timeout, "Autoreply Confirmation Required",
        QString("A transmission is queued for autoreply:\n\n%1\n\nWould you "
                "like to send this transmission?")
            .arg(message),
        QMessageBox::Question, QMessageBox::Yes | QMessageBox::No,
        QMessageBox::No, false, this);

    connect(m, &SelfDestructMessageBox::finished, this,
            [this, m, priority, message, offset, c](int) {
                // make sure we delete the message box later...
                m->deleteLater();

                if (m->result() == QMessageBox::Yes) {
                    enqueueMessage(priority, message, offset, c);
                }
            });

    m->setWindowModality(Qt::NonModal);
    m->show();
}

void MainWindow::enqueueMessage(int priority, QString message, int offset,
                                Callback c) {
    m_txMessageQueue.enqueue(PrioritizedMessage{
        DriftingDateTime::currentDateTimeUtc(), priority, message, offset, c});
}

void MainWindow::resetMessage() {
    resetMessageUI();
    resetMessageTransmitQueue();
}

void MainWindow::resetMessageUI() {
    m_nextFreeTextMsg.clear();
    ui->extFreeTextMsgEdit->clear();
    ui->extFreeTextMsgEdit->setReadOnly(false);

    update_dynamic_property(ui->extFreeTextMsgEdit, "transmitting", false);

    if (ui->startTxButton->isChecked()) {
        ui->startTxButton->setChecked(false);
    }
}

bool MainWindow::ensureCallsignSet(bool alert) {
    if (m_config.my_callsign().trimmed().isEmpty()) {
        if (alert)
            JS8MessageBox::warning_message(
                this, tr("Please enter your callsign in the settings."));
        openSettings();
        return false;
    }

    if (m_config.my_grid().trimmed().isEmpty()) {
        if (alert)
            JS8MessageBox::warning_message(
                this, tr("Please enter your grid locator in the settings."));
        openSettings();
        return false;
    }

    return true;
}

bool MainWindow::ensureKeyNotStuck(QString const &text) {
    // be annoying and drop messages with all the same character to reduce
    // spam...
    if (text.length() > 5 &&
        QString(text).replace(text.at(0), "").trimmed().isEmpty()) {
        return false;
    }

    return true;
}

bool MainWindow::ensureNotIdle() {
    if (!m_config.watchdog()) {
        return true;
    }

    if (m_idleMinutes < m_config.watchdog()) {
        return true;
    }

    tx_watchdog(true); // disable transmit and auto replies
    return false;
}

bool MainWindow::ensureCanTransmit() {
    return ui->monitorTxButton->isChecked();
}

bool MainWindow::ensureCreateMessageReady(const QString &text) {
    if (text.isEmpty()) {
        return false;
    }

    if (!ensureCanTransmit()) {
        on_stopTxButton_clicked();
        return false;
    }

    if (!ensureCallsignSet()) {
        on_stopTxButton_clicked();
        return false;
    }

    if (!ensureNotIdle()) {
        on_stopTxButton_clicked();
        return false;
    }

    if (!ensureKeyNotStuck(text)) {
        on_stopTxButton_clicked();

        ui->monitorButton->setChecked(false);
        ui->monitorTxButton->setChecked(false);
        on_monitorButton_clicked(false);
        on_monitorTxButton_toggled(false);

        foreach (auto obj, this->children()) {
            if (obj->isWidgetType()) {
                auto wid = qobject_cast<QWidget *>(obj);
                wid->setEnabled(false);
            }
        }

        return false;
    }

    return true;
}

QString MainWindow::createMessage(QString const &text,
                                  bool *pDisableTypeahead) {
    return createMessageTransmitQueue(
        replaceMacros(text, buildMacroValues(), false), true, false,
        pDisableTypeahead);
}

QString MainWindow::appendMessage(QString const &text, bool isData,
                                  bool *pDisableTypeahead) {
    return createMessageTransmitQueue(
        replaceMacros(text, buildMacroValues(), false), false, isData,
        pDisableTypeahead);
}

QString MainWindow::createMessageTransmitQueue(QString const &text, bool reset,
                                               bool isData,
                                               bool *pDisableTypeahead) {
    if (reset) {
        resetMessageTransmitQueue();
    }

    auto frames = buildMessageFrames(text, isData, pDisableTypeahead);

    QStringList lines;
    foreach (auto frame, frames) {
        auto dt = DecodedText(frame.first, frame.second, m_nSubMode);
        lines.append(dt.message());
    }

    m_txFrameQueue.append(frames);
    m_txFrameCount += frames.length();

    // TODO: jsherer - move this outside of create message transmit queue
    // if we're transmitting a message to be displayed, we should bump the
    // repeat buttons... "Bump the repeat buttons" from 2018 probably translates
    // to "stop automatic transmission loops" in 2025: qCDebug(mainwindow_js8)
    // << "Cancel HB and CQ transmit loops in createMessageTransmitQueue";
    // m_cq_loop->onLoopCancel();
    // m_hb_loop->onLoopCancel();
    // But the loops cause this code to be executed as part of their
    // normal operation, when the first transmission is sent.
    // So the cancelation makes it impossible to iterate through the loop a
    // second time.

    // return the text
    return lines.join("");
}

void MainWindow::restoreMessage() {
    if (m_lastTxMessage.isEmpty()) {
        return;
    }
    addMessageText(Varicode::rstrip(m_lastTxMessage), true);
}

void MainWindow::resetMessageTransmitQueue() {
    m_txFrameCount = 0;
    m_txFrameCountSent = 0;
    m_txFrameQueue.clear();
    m_txMessageQueue.clear();

    // reset the total message sent
    m_totalTxMessage.clear();
}

QPair<QString, int> MainWindow::popMessageFrame() {
    if (m_txFrameQueue.isEmpty()) {
        return QPair<QString, int>{};
    }
    return m_txFrameQueue.dequeue();
}

void MainWindow::currentTextChanged() {
    auto const text = ui->extFreeTextMsgEdit->toPlainText();

    // keep track of dirty flags
    m_txTextDirty = text != m_txTextDirtyLastText;
    m_txTextDirtyLastText = text;

    // immediately update the display
    updateButtonDisplay();
    updateTextDisplay();
}

void MainWindow::tableSelectionChanged(QItemSelection const &,
                                       QItemSelection const &) {
    currentTextChanged();

    auto const selectedCall = callsignSelected();

    if (selectedCall != m_prevSelectedCallsign) {
        callsignSelectedChanged(m_prevSelectedCallsign, selectedCall);
    }
}

QList<QPair<QString, int>>
MainWindow::buildMessageFrames(const QString &text, bool isData,
                               bool *pDisableTypeahead) {
    // prepare selected callsign for directed message
    QString selectedCall = callsignSelected();

    // prepare compound
    QString mycall = m_config.my_callsign();
    QString mygrid = m_config.my_grid().left(4);

    bool forceIdentify = !m_config.avoid_forced_identify();

    // TODO: might want to be more explicit?
    bool forceData = m_txFrameCountSent > 0 && isData;

    Varicode::MessageInfo info;
    auto frames = Varicode::buildMessageFrames(mycall, mygrid, selectedCall,
                                               text, forceIdentify, forceData,
                                               m_nSubMode, &info);

    if (pDisableTypeahead) {
        // checksummed commands should not allow typeahead
        *pDisableTypeahead = (!info.dirCmd.isEmpty() &&
                              Varicode::isCommandChecksumed(info.dirCmd));
    }

#if 0
    qCDebug(mainwindow_js8) << "frames:";
    foreach(auto frame, frames){
        auto dt = DecodedText(frame.frame, frame.bits);
        qCDebug(mainwindow_js8) << "->" << frame << dt.message() << Varicode::frameTypeString(dt.frameType());
    }
#endif

    return frames;
}

bool MainWindow::prepareNextMessageFrame() {
    // check to see if the last i3bit was a last bit
    bool i3bitLast = (m_i3bit & Varicode::JS8CallLast) == Varicode::JS8CallLast;

    // TODO: should this be user configurable?
    bool shouldForceDataForTypeahead = !i3bitLast;

    // reset i3
    m_i3bit = Varicode::JS8Call;

    // typeahead
    bool shouldDisableTypeahead = false;
    if (ui->extFreeTextMsgEdit->isDirty() &&
        !ui->extFreeTextMsgEdit->isEmpty()) {
        // block edit events while computing next frame
        QString newText;
        ui->extFreeTextMsgEdit->setReadOnly(true);
        {
            auto sent = ui->extFreeTextMsgEdit->sentText();
            auto unsent = ui->extFreeTextMsgEdit->unsentText();
            qCDebug(mainwindow_js8) << "text dirty for typeahead\n"
                                    << sent << "\n"
                                    << unsent;
            m_txFrameQueue.clear();
            m_txFrameCount = 0;

            newText = appendMessage(unsent, shouldForceDataForTypeahead,
                                    &shouldDisableTypeahead);

            // if this was the last frame, append a newline
            if (i3bitLast) {
                m_totalTxMessage.append("\n");
                newText.prepend("\n");
            }

            qCDebug(mainwindow_js8) << "unsent replaced to" << "\n" << newText;
        }
        ui->extFreeTextMsgEdit->setReadOnly(shouldDisableTypeahead);
        ui->extFreeTextMsgEdit->replaceUnsentText(newText, true);
        ui->extFreeTextMsgEdit->setClean();
    }

    QPair<QString, int> f = popMessageFrame();
    auto frame = f.first;
    auto bits = f.second;

    // if not the first frame, ensure first bit is not set
    if (m_txFrameCountSent > 0) {
        bits &= ~Varicode::JS8CallFirst;
    }

    // if last frame, ensure the last bit is set
    if (m_txFrameQueue.isEmpty()) {
        bits |= Varicode::JS8CallLast;
    }

    if (frame.isEmpty()) {
        m_nextFreeTextMsg.clear();
        updateTxButtonDisplay();
        return false;
    }

    // append this frame to the total message sent so far
    auto dt = DecodedText(frame, bits, m_nSubMode);
    m_totalTxMessage.append(dt.message());
    ui->extFreeTextMsgEdit->setCharsSent(m_totalTxMessage.length());
    m_txFrameCountSent += 1;
    m_lastTxMessage = m_totalTxMessage;
    qCDebug(mainwindow_js8) << "total sent:" << m_txFrameCountSent << "\n"
                            << m_totalTxMessage;

    // display the frame...
    if (m_txFrameQueue.isEmpty()) {
        displayTextForFreq(
            QString("%1 %2 ").arg(dt.message()).arg(m_config.eot()), freq(),
            DriftingDateTime::currentDateTimeUtc(), true, false, true);
    } else {
        displayTextForFreq(dt.message(), freq(),
                           DriftingDateTime::currentDateTimeUtc(), true,
                           m_txFrameCountSent == 1, false);
    }

    m_nextFreeTextMsg = frame;
    m_i3bit = bits;

    updateTxButtonDisplay();

    return true;
}

bool MainWindow::isFreqOffsetFree(int const f, int const bw) {
    // if this frequency is our current frequency, or it's in our
    // directed cache, it's free.

    if ((freq() == f) || isDirectedOffset(f, nullptr))
        return true;

    // Run through the band activity; if there's no activity for a given
    // offset, or we last received on it more than 30 seconds ago, then
    // it's free. If it's an occupied slot within the bandwidth of where
    // we'd like to transmit, then it's not free.

    auto const now = DriftingDateTime::currentDateTimeUtc();

    for (auto [offset, activity] : m_bandActivity.asKeyValueRange()) {
        if (activity.isEmpty() ||
            activity.last().utcTimestamp.secsTo(now) >= 30)
            continue;

        if (qAbs(offset - f) < bw)
            return false;
    }

    return true;
}

int MainWindow::findFreeFreqOffset(int fmin, int fmax, int bw) {
    int nslots = (fmax - fmin) / bw;

    int f = fmin;
    for (int i = 0; i < nslots; i++) {
        f = fmin + bw * (QRandomGenerator::global()->generate() % nslots);
        if (isFreqOffsetFree(f, bw)) {
            return f;
        }
    }

    for (int i = 0; i < nslots; i++) {
        f = fmin + (QRandomGenerator::global()->generate() % (fmax - fmin));
        if (isFreqOffsetFree(f, bw)) {
            return f;
        }
    }

    // return fmin if there's no free offset
    return fmin;
}

#if 0
// schedulePing
void MainWindow::scheduleHeartbeat(bool first){
    auto timestamp = DriftingDateTime::currentDateTimeUtc();

    // if we have the heartbeat interval disabled, return early, unless this is a "heartbeat now"
    if(!m_config.heartbeat() && !first){
        heartbeatTimer.stop();
        return;
    }

    // remove milliseconds
    auto t = timestamp.time();
    t.setHMS(t.hour(), t.minute(), t.second());
    timestamp.setTime(t);

    // round to 15 second increment
    int secondsSinceEpoch = (timestamp.toMSecsSinceEpoch()/1000);
    int delta = roundUp(secondsSinceEpoch, 15) + 1 + (first ? 0 : qMax(1, m_config.heartbeat()) * 60) - secondsSinceEpoch;
    timestamp = timestamp.addSecs(delta);

    // 25% of the time, switch intervals
    float prob = (float) QRandomGenerator::global()->generate() / (RAND_MAX);
    if(prob < 0.25){
        timestamp = timestamp.addSecs(15);
    }

    m_nextHeartbeat = timestamp;
    m_nextHeartbeatQueued = false;
    m_nextHeartPaused = false;

    if(!heartbeatTimer.isActive()){
        heartbeatTimer.setInterval(1000);
        heartbeatTimer.start();
    }
}

// pausePing
void MainWindow::pauseHeartbeat(){
    m_nextHeartPaused = true;

    if(heartbeatTimer.isActive()){
        heartbeatTimer.stop();
    }
}

// unpausePing
void MainWindow::unpauseHeartbeat(){
    scheduleHeartbeat(false);
}

// checkPing
void MainWindow::checkHeartbeat(){
    if(m_config.heartbeat() <= 0){
        return;
    }
    auto secondsUntilHeartbeat = DriftingDateTime::currentDateTimeUtc().secsTo(m_nextHeartbeat);
    if(secondsUntilHeartbeat > 5 && m_txHeartbeatQueue.isEmpty()){
        return;
    }
    if(m_nextHeartbeatQueued){
        return;
    }
    if(m_tx_watchdog){
        return;
    }

    // idle heartbeat watchdog!
    if (m_config.watchdog() && m_idleMinutes >= m_config.watchdog ()){
      tx_watchdog (true);       // disable transmit
      return;
    }

    prepareHeartbeat();
}

// preparePing
void MainWindow::prepareHeartbeat(){
    QStringList lines;

    QString mycall = m_config.my_callsign();
    QString mygrid = m_config.my_grid().left(4);

    // JS8Call Style
    if(m_txHeartbeatQueue.isEmpty()){
        lines.append(QString("%1: HEARTBEAT %2").arg(mycall).arg(mygrid));
    } else {
        while(!m_txHeartbeatQueue.isEmpty() && lines.length() < 1){
            lines.append(m_txHeartbeatQueue.dequeue());
        }
    }

    // Choose a ping frequency
    auto f = m_config.heartbeat_anywhere() ? -1 : findFreeFreqOffset(500, 1000, 50);

    auto text = lines.join(QChar('\n'));
    if(text.isEmpty()){
        return;
    }

    // Queue the ping
    enqueueMessage(PriorityLow, text, f, [this](){
        m_nextHeartbeatQueued = false;
    });

    m_nextHeartbeatQueued = true;
}
#endif

void MainWindow::on_startTxButton_toggled(bool checked) {
    if (checked) {
        startTx();
    } else {
        resetMessage();
        on_stopTxButton_clicked();
        stopTx();
    }
}

void MainWindow::toggleTx(bool start) {
    if (start && ui->startTxButton->isChecked()) {
        return;
    }
    if (!start && !ui->startTxButton->isChecked()) {
        return;
    }
    qCDebug(mainwindow_js8)
        << "toggleTx(" << start << ") setting the TX button.";
    ui->startTxButton->setChecked(start);
}

void MainWindow::on_logQSOButton_clicked() // Log QSO button
{
    QString call = callsignSelected();
    if (m_callSelectedTime.contains(call)) {
        m_dateTimeQSOOn = m_callSelectedTime[call];
    }
    if (!m_dateTimeQSOOn.isValid()) {
        m_dateTimeQSOOn = DriftingDateTime::currentDateTimeUtc();
    }
    auto dateTimeQSOOff = DriftingDateTime::currentDateTimeUtc();
    if (dateTimeQSOOff < m_dateTimeQSOOn)
        dateTimeQSOOff = m_dateTimeQSOOn;

    if (call.startsWith("@")) {
        call = "";
    }
    QString grid = "";
    if (m_callActivity.contains(call)) {
        grid = m_callActivity[call].grid;
    }
    QString opCall = m_opCall;
    if (opCall.isEmpty()) {
        opCall = m_config.my_callsign();
    }

    QString comments = ui->textEditRX->textCursor().selectedText();

    // don't reset the log window if the call hasn't changed.
    if (!m_logDlg->currentCall().isEmpty() &&
        call.trimmed() == m_logDlg->currentCall()) {
        m_logDlg->show();
        return;
    }

    m_logDlg->initLogQSO(call.trimmed(), grid.trimmed(), "JS8", m_rptSent,
                         m_rptRcvd, m_dateTimeQSOOn, dateTimeQSOOff,
                         m_freqNominal + freq(), m_config.my_callsign(),
                         m_config.my_grid(), opCall, comments);
}

void MainWindow::acceptQSO(QDateTime const &QSO_date_off, QString const &call,
                           QString const &grid, Frequency dial_freq,
                           QString const &mode, QString const &submode,
                           QString const &rpt_sent, QString const &rpt_received,
                           QString const &comments, QString const &name,
                           QDateTime const &QSO_date_on,
                           QString const &operator_call, QString const &my_call,
                           QString const &my_grid, QByteArray const &ADIF,
                           QVariantMap const &additionalFields) {
    QString date = QSO_date_on.toString("yyyyMMdd");
    m_logBook.addAsWorked(m_hisCall, m_config.bands()->find(m_freqNominal),
                          mode, submode, grid, date, name, comments);

    // Log to JS8Call API
    if (canSendNetworkMessage()) {
        sendNetworkMessage(
            "LOG.QSO", QString(ADIF),
            {{"_ID", QVariant(-1)},
             {"UTC.ON", QVariant(QSO_date_on.toMSecsSinceEpoch())},
             {"UTC.OFF", QVariant(QSO_date_off.toMSecsSinceEpoch())},
             {"CALL", QVariant(call)},
             {"GRID", QVariant(grid)},
             {"FREQ", QVariant(dial_freq)},
             {"MODE", QVariant(mode)},
             {"SUBMODE", QVariant(submode)},
             {"RPT.SENT", QVariant(rpt_sent)},
             {"RPT.RECV", QVariant(rpt_received)},
             {"NAME", QVariant(name)},
             {"COMMENTS", QVariant(comments)},
             {"STATION.OP", QVariant(operator_call)},
             {"STATION.CALL", QVariant(my_call)},
             {"STATION.GRID", QVariant(my_grid)},
             {"EXTRA", additionalFields}});
    }

    // Log to N1MM Logger
    if (m_config.broadcast_to_n1mm() && m_config.valid_n1mm_info()) {
        const QHostAddress n1mmhost = QHostAddress(m_config.n1mm_server_name());
        QUdpSocket _sock;
        auto rzult = _sock.writeDatagram(ADIF + " <eor>", n1mmhost,
                                         quint16(m_config.n1mm_server_port()));
        if (rzult == -1) {
            bool hidden = m_logDlg->isHidden();
            m_logDlg->setHidden(true);
            JS8MessageBox::warning_message(
                this, tr("Error sending log to N1MM"),
                tr("Write returned \"%1\"").arg(rzult));
            m_logDlg->setHidden(hidden);
        }
    }

    // Log to N3FJP Logger
    if (m_config.broadcast_to_n3fjp() && m_config.valid_n3fjp_info()) {
        QString data = QString("<CMD>"
                               "<ADDDIRECT>"
                               "<EXCLUDEDUPES>TRUE</EXCLUDEDUPES>"
                               "<STAYOPEN>FALSE</STAYOPEN>"
                               "<fldDateStr>%1</fldDateStr>"
                               "<fldTimeOnStr>%2</fldTimeOnStr>"
                               "<fldCall>%3</fldCall>"
                               "<fldGridR>%4</fldGridR>"
                               "<fldBand>%5</fldBand>"
                               "<fldFrequency>%6</fldFrequency>"
                               "<fldMode>JS8</fldMode>"
                               "<fldOperator>%7</fldOperator>"
                               "<fldNameR>%8</fldNameR>"
                               "<fldComments>%9</fldComments>"
                               "<fldRstS>%10</fldRstS>"
                               "<fldRstR>%11</fldRstR>"
                               "%12"
                               "</CMD>");

        data = data.arg(QSO_date_on.toString("yyyy/MM/dd"));
        data = data.arg(QSO_date_on.toString("H:mm"));
        data = data.arg(call);
        data = data.arg(grid);
        data = data.arg(m_config.bands()->find(dial_freq).replace("m", ""));
        data = data.arg(Radio::frequency_MHz_string(dial_freq));
        data = data.arg(operator_call);
        data = data.arg(name);
        data = data.arg(comments);
        data = data.arg(rpt_sent);
        data = data.arg(rpt_received);

        int other = 0;
        QStringList additional;
        if (!additionalFields.isEmpty()) {
            foreach (auto key, additionalFields.keys()) {
                QString n3key;
                if (N3FJP_ADIF_MAP.contains(key)) {
                    n3key = N3FJP_ADIF_MAP.value(key);
                } else {
                    other++;
                    n3key = N3FJP_ADIF_MAP.value(QString("*%1").arg(other));
                }

                if (n3key.isEmpty()) {
                    break;
                }
                auto value = additionalFields[key].toString();
                additional.append(QString("<%1>%2</%1>").arg(n3key).arg(value));
            }
        }
        data = data.arg(additional.join(""));

        auto host = m_config.n3fjp_server_name();
        auto port = m_config.n3fjp_server_port();

        if (m_n3fjpClient->sendNetworkMessage(host, port, data.toLocal8Bit(),
                                              true, 500)) {
            QTimer::singleShot(300, this, [this, host, port]() {
                m_n3fjpClient->sendNetworkMessage(
                    host, port, "<CMD><CHECKLOG></CMD>", true, 100);
                m_n3fjpClient->sendNetworkMessage(host, port, "\r\n", true,
                                                  100);
            });
        } else {
            bool hidden = m_logDlg->isHidden();
            m_logDlg->setHidden(true);
            JS8MessageBox::warning_message(
                this, tr("Error sending log to N3FJP"),
                tr("Write failed for \"%1:%2\"").arg(host).arg(port));
            m_logDlg->setHidden(hidden);
        }
    }

    /**
     * @brief Log QSO to WSJT-X Protocol
     *
     * Sends QSO logged information to WSJT-X protocol clients. Sends both
     * the QSOLogged message and the LoggedADIF message (type 12) which
     * contains the ADIF formatted record. The ADIF message is what most
     * WSJT-X clients actually use for logging.
     */
    if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) {
        m_wsjtxMessageMapper->sendQSOLogged(QSO_date_off, call, grid, dial_freq,
                                            mode, rpt_sent, rpt_received,
                                            my_call, my_grid);
        // Also send ADIF formatted message (this is what clients actually use)
        if (m_wsjtxMessageClient) {
            m_wsjtxMessageClient->logged_ADIF(ADIF);
        }
    }

    // reload the logbook data
    m_logBook.init();

    clearCallsignSelected();

    displayCallActivity();

    m_dateTimeQSOOn = QDateTime{};
}

void MainWindow::on_actionModeJS8HB_toggled(bool) {
    // prep hb mode

    prepareHeartbeatMode(canCurrentModeSendHeartbeat() &&
                         ui->actionModeJS8HB->isChecked());
    displayActivity(true);

    setupJS8();
}

void MainWindow::on_actionHeartbeatAcknowledgements_toggled(bool) {
    // prep hb ack mode

    prepareHeartbeatMode(canCurrentModeSendHeartbeat() &&
                         ui->actionModeJS8HB->isChecked());
    displayActivity(true);

    setupJS8();
}

void MainWindow::on_actionModeMultiDecoder_toggled(bool checked) {
    Q_UNUSED(checked);

    displayActivity(true);

    setupJS8();
}

void MainWindow::on_actionModeJS8Normal_triggered() { setupJS8(); }

void MainWindow::on_actionModeJS8Fast_triggered() { setupJS8(); }

void MainWindow::on_actionModeJS8Turbo_triggered() { setupJS8(); }

void MainWindow::on_actionModeJS8Slow_triggered() { setupJS8(); }

void MainWindow::on_actionModeJS8Ultra_triggered() { setupJS8(); }

void MainWindow::on_actionModeAutoreply_toggled(bool) {
    // update the HB ack option (needs autoreply on)
    prepareHeartbeatMode(canCurrentModeSendHeartbeat() &&
                         ui->actionModeJS8HB->isChecked());

    // then update the js8 mode
    setupJS8();
}

bool MainWindow::canCurrentModeSendHeartbeat() const {
    return (m_nSubMode == Varicode::JS8CallFast ||
            m_nSubMode == Varicode::JS8CallNormal ||
            m_nSubMode == Varicode::JS8CallSlow);
}

void MainWindow::prepareMonitorControls() {
    // on_monitorButton_toggled(!m_config.monitor_off_at_startup());
    ui->monitorTxButton->setChecked(!m_config.transmit_off_at_startup());
}

void MainWindow::prepareHeartbeatMode(bool enabled) {
    // Not all submodes supports HBs.
    ui->hbMacroButton->setVisible(enabled);
    if (!enabled) {
        m_hb_loop->onLoopCancel();
        ui->hbMacroButton->setChecked(false);
    }
    ui->actionHeartbeat->setEnabled(enabled);
    ui->actionModeJS8HB->setEnabled(canCurrentModeSendHeartbeat());
    ui->actionHeartbeatAcknowledgements->setEnabled(
        enabled && ui->actionModeAutoreply->isChecked());

#if 0
    if(enabled){
        m_config.addGroup("@HB");
    } else {
        m_config.removeGroup("@HB");
    }
#endif

#if 0
    //ui->actionCQ->setEnabled(!enabled);
    //ui->actionFocus_Message_Reply_Area->setEnabled(!enabled);

    // default to not displaying the other buttons
    // ui->cqMacroButton->setVisible(!enabled);
    // ui->replyMacroButton->setVisible(!enabled);
    // ui->snrMacroButton->setVisible(!enabled);
    // ui->infoMacroButton->setVisible(!enabled);
    // ui->macrosMacroButton->setVisible(!enabled);
    // ui->queryButton->setVisible(!enabled);
    // ui->extFreeTextMsgEdit->setVisible(!enabled);
    // if(enabled){
    //     ui->extFreeTextMsgEdit->clear();
    // }

    // show heartbeat and acks in hb mode only
    // ui->actionShow_Band_Heartbeats_and_ACKs->setChecked(enabled);
    // ui->actionShow_Band_Heartbeats_and_ACKs->setVisible(true);
    // ui->actionShow_Band_Heartbeats_and_ACKs->setEnabled(false);
#endif

    updateHBButtonDisplay();
    updateButtonDisplay();
}

void MainWindow::setupJS8() {
    m_nSubMode = Varicode::JS8CallNormal;

    if (ui->actionModeJS8Normal->isChecked())
        m_nSubMode = Varicode::JS8CallNormal;
    else if (ui->actionModeJS8Fast->isChecked())
        m_nSubMode = Varicode::JS8CallFast;
    else if (ui->actionModeJS8Turbo->isChecked())
        m_nSubMode = Varicode::JS8CallTurbo;
    else if (ui->actionModeJS8Slow->isChecked())
        m_nSubMode = Varicode::JS8CallSlow;
    else if (ui->actionModeJS8Ultra->isChecked())
        m_nSubMode = Varicode::JS8CallUltra;

    // Only enable heartbeat for modes that support it
    prepareHeartbeatMode(canCurrentModeSendHeartbeat() &&
                         ui->actionModeJS8HB->isChecked());

    updateModeButtonText();

    m_wideGraph->setSubMode(m_nSubMode);
    m_wideGraph->setFilterMinimumBandwidth(
        JS8::Submode::bandwidth(m_nSubMode) +
        JS8::Submode::rxThreshold(m_nSubMode) * 2);

    enable_DXCC_entity(m_config.DXCC());
    m_config.frequencies()->filter(m_config.region(), Mode::JS8);
    m_FFTSize = JS8_NSPS / 2;
    Q_EMIT FFTSize(m_FFTSize);
    setup_status_bar();
    m_TRperiod = JS8::Submode::period(m_nSubMode);
    m_wideGraph->show();

    Q_ASSERT(JS8_NTMAX == 60);
    m_wideGraph->setPeriod(m_TRperiod);
    m_detector->setTRPeriod(JS8_NTMAX); // TODO - not thread safe

    updateTextDisplay();
    refreshTextDisplay();
    statusChanged();
}

void MainWindow::setFreq(int const n) {
    m_freq = n;
    m_wideGraph->setFreq(n);
    Q_EMIT transmitFrequency(n + m_XIT);
    statusUpdate();
}

void MainWindow::on_actionErase_ALL_TXT_triggered() // Erase ALL.TXT
{
    int ret = JS8MessageBox::query_message(
        this, tr("Confirm Erase"),
        tr("Are you sure you want to erase file ALL.TXT?"));
    if (ret == JS8MessageBox::Yes) {
        QFile f{m_config.writeable_data_dir().absoluteFilePath("ALL.TXT")};
        f.remove();
        m_RxLog = 1;
    }
}

void MainWindow::on_actionErase_js8call_log_adi_triggered() {
    int ret = JS8MessageBox::query_message(
        this, tr("Confirm Erase"),
        tr("Are you sure you want to erase file js8call_log.adi?"));
    if (ret == JS8MessageBox::Yes) {
        QFile f{
            m_config.writeable_data_dir().absoluteFilePath("js8call_log.adi")};
        f.remove();

        m_logBook.init();
    }
}

void MainWindow::on_actionOpen_log_directory_triggered() {
    QDesktopServices::openUrl(
        QUrl::fromLocalFile(m_config.writeable_data_dir().absolutePath()));
}

void MainWindow::band_changed() {
    if (m_config.pwrBandTxMemory() && !m_tune) {
        if (m_pwrBandTxMemory.contains(m_lastBand)) {
            ui->outAttenuation->setValue(m_pwrBandTxMemory[m_lastBand].toInt());
        } else {
            m_pwrBandTxMemory[m_lastBand] = ui->outAttenuation->value();
        }
    }
}

void MainWindow::enable_DXCC_entity(bool /*on*/) {
    m_logBook.init(); // re-read the log and cty.dat files
    updateGeometry();
}

void MainWindow::buildFrequencyMenu(QMenu *menu) {
    auto custom = menu->addAction("Set a Custom Frequency...");

    connect(custom, &QAction::triggered, this, [this]() {
        bool ok = false;
        auto currentFreq = Radio::frequency_MHz_string(dialFrequency());
        QString newFreq =
            QInputDialog::getText(this, tr("Set a Custom Frequency"),
                                  tr("Frequency in MHz:"), QLineEdit::Normal,
                                  currentFreq, &ok)
                .toUpper()
                .trimmed();
        if (!ok) {
            return;
        }

        setRig(Radio::frequency(newFreq, 6));
    });

    menu->addSeparator();

    auto frequencies = m_config.frequencies()->frequency_list();
    std::sort(frequencies.begin(), frequencies.end(),
              [](FrequencyList_v2::Item &a, FrequencyList_v2::Item &b) {
                  return a.frequency_ < b.frequency_;
              });

    foreach (auto f, frequencies) {
        auto freq = Radio::pretty_frequency_MHz_string(f.frequency_);
        auto const &band = m_config.bands()->find(f.frequency_);

        auto a =
            menu->addAction(QString("%1:%2%2%3 MHz")
                                .arg(band)
                                .arg(QString(" ").repeated(5 - band.length()))
                                .arg(freq));
        connect(a, &QAction::triggered, this,
                [this, f]() { setRig(f.frequency_); });
    }
}

void MainWindow::buildHeartbeatMenu(QMenu *menu) {
    if (m_hbInterval > 0) {
        auto startStop = menu->addAction(ui->hbMacroButton->isChecked()
                                             ? "Stop Heartbeat Timer"
                                             : "Start Heartbeat Timer");
        connect(startStop, &QAction::triggered, this,
                [this]() { ui->hbMacroButton->toggle(); });
        menu->addSeparator();
    }

    buildRepeatMenu(menu, ui->hbMacroButton, false, &m_hbInterval);

    menu->addSeparator();
    auto now = menu->addAction("Send Heartbeat Now");
    connect(now, &QAction::triggered, this, &MainWindow::sendHB);
}

void MainWindow::buildCQMenu(QMenu *menu) {
    if (m_cqInterval > 0) {
        auto startStop =
            menu->addAction(ui->cqMacroButton->isChecked() ? "Stop CQ Timer"
                                                           : "Start CQ Timer");
        connect(startStop, &QAction::triggered, this,
                [this]() { ui->cqMacroButton->toggle(); });
        menu->addSeparator();
    }

    buildRepeatMenu(menu, ui->cqMacroButton, true, &m_cqInterval);

    menu->addSeparator();
    auto now = menu->addAction("Send CQ Now");
    connect(now, &QAction::triggered, this, [this]() { sendCQ(false); });
}

void MainWindow::buildRepeatMenu(QMenu *menu, QPushButton *button,
                                 bool isLowInterval, int *interval) {
    QList<QPair<QString, int>> items = {
        {"On demand / do not repeat", 0},
        {"Repeat every 1 minute", 1},
        {"Repeat every 5 minutes", 5},
        {"Repeat every 10 minutes", 10},
        {"Repeat every 15 minutes", 15},
        {"Repeat every 30 minutes", 30},
        {"Repeat every 60 minutes", 60},
        {"Repeat every N minutes (Custom Interval)",
         -1}, // this needs to be last because of isSet bool
    };

    if (isLowInterval) {
        items.removeAt(6); // remove the sixty minute interval
        items.removeAt(5); // remove the thirty minute interval
    } else {
        items.removeAt(2); // remove the five minute interval
        items.removeAt(1); // remove the one minute interval
    }

    auto customFormat = QString("Repeat every %1 minutes (Custom Interval)");

    QActionGroup *group = new QActionGroup(menu);

    bool isSet = false;
    foreach (auto pair, items) {
        int minutes = pair.second;
        bool isMatch = *interval == minutes;
        bool isCustom = (minutes == -1 && isSet == false);
        if (isMatch) {
            isSet = true;
        }

        auto text = pair.first;
        if (isCustom) {
            text = QString(customFormat).arg(*interval);
        }

        QAction *action = menu->addAction(text);
        action->setData(minutes);
        action->setCheckable(true);
        action->setChecked(isMatch || isCustom);
        group->addAction(action);

        connect(
            action, &QAction::toggled, this,
            [this, action, customFormat, minutes, interval,
             button](bool checked) {
                int min = minutes;

                if (checked) {

                    if (minutes == -1) {
                        bool ok = false;
                        min =
                            QInputDialog::getInt(this, "Repeat every N minutes",
                                                 "Minutes", 0, 1, 1440, 1, &ok);
                        if (!ok) {
                            return;
                        }
                        action->setText(QString(customFormat).arg(*interval));
                    }

                    *interval = min;

                    if (min > 0) {
                        // force a re-toggle
                        button->setChecked(false);
                    }
                    button->setChecked(min > 0);
                }
            });
    }
}

void MainWindow::sendHB() {

    QString mycall = m_config.my_callsign();
    QString mygrid = m_config.my_grid().left(4);

    QStringList parts;
    parts.append(QString("%1:").arg(mycall));

#if JS8_CUSTOMIZE_HB
    auto hb = m_config.hb_message();
#else
    auto hb = QString{};
#endif
    if (hb.isEmpty()) {
        parts.append("HEARTBEAT");
        parts.append(mygrid);
    } else {
        parts.append(hb);
    }

    QString message = parts.join(" ").trimmed();

    auto f = findFreeFreqOffset(500, 1000, 50);

    if (freq() <= 1000) {
        f = freq();
    } else if (m_config.heartbeat_anywhere()) {
        f = -1;
    }

    enqueueMessage(PriorityLow + 1, message, f, nullptr);
    processTxQueue();
}

void MainWindow::sendHeartbeatAck(QString to, int snr, QString extra) {
#if JS8_HB_ACK_SNR_CONFIGURABLE
    auto message = m_config.heartbeat_ack_snr()
                       ? QString("%1 SNR %2 %3")
                             .arg(to)
                             .arg(Varicode::formatSNR(snr))
                             .arg(extra)
                             .trimmed()
                       : QString("%1 ACK %2").arg(to).arg(extra).trimmed();
#else
    auto message = QString("%1 HEARTBEAT SNR %2 %3")
                       .arg(to)
                       .arg(Varicode::formatSNR(snr))
                       .arg(extra)
                       .trimmed();
#endif

    auto f =
        m_config.heartbeat_anywhere() ? -1 : findFreeFreqOffset(500, 1000, 50);

    if (m_config.autoreply_confirmation()) {
        confirmThenEnqueueMessage(90, PriorityLow + 1, message, f,
                                  [this]() { processTxQueue(); });
    } else {
        enqueueMessage(PriorityLow + 1, message, f, nullptr);
        processTxQueue();
    }
}

void MainWindow::on_hbMacroButton_toggled(bool checked) {
    qCDebug(mainwindow_js8) << "on_hbMacroButton_toggled(" << checked << ")";
    if (checked) {
        // only clear callsign if we do not allow hbs while in qso
        if (m_config.heartbeat_qso_pause()) {
            clearCallsignSelected();
        }

        if (m_hbInterval) {
            if (!m_hb_loop->isActive()) {
                qCDebug(mainwindow_js8)
                    << "Starting HB loop from on_hbMacroButton_toggled()";
                m_hb_loop->onTxLoopPeriodChangeStart(m_hbInterval *
                                                     (qint64)60000);
            }
        } else {
            qCDebug(mainwindow_js8)
                << "Sending single HB from on_hbMacroButton_toggled()";
            m_hb_loop->onLoopCancel();
            // Heartbeat, but not in a loop.
            sendHB();

            // make this button emulate a single press button
            ui->hbMacroButton->setChecked(false);
        }
    } else {
        if (m_hb_loop->isActive() && m_hbButtonIsLongterm) {
            qCDebug(mainwindow_js8)
                << "Stopping HB loop from on_hbMacroButton_toggled()";
            m_hb_loop->onLoopCancel();
        }
    }
    qCDebug(mainwindow_js8)
        << "updateHBButtonDisplay called via on_hbMacroButton_toggled";
    updateHBButtonDisplay();
}

void MainWindow::on_hbMacroButton_clicked() {}

void MainWindow::sendCQ(bool repeat) {

    if (!repeat && m_cq_loop->isActive()) {
        qCDebug(mainwindow_js8) << "Cancel CQ loop on single-shot CQ";
        m_cq_loop->onLoopCancel();
    }
    if (!repeat && m_hb_loop->isActive()) {
        qCDebug(mainwindow_js8) << "Cancel HB loop on single-shot CQ";
        m_hb_loop->onLoopCancel();
    }

    QString message = m_config.cq_message();
    if (message.isEmpty()) {
        QString mygrid = m_config.my_grid().left(4);
        message = QString("CQ CQ CQ %1").arg(mygrid).trimmed();
    }

    clearCallsignSelected();

    addMessageText(replaceMacros(message, buildMacroValues(), true));

    if (repeat || m_config.transmit_directed())
        toggleTx(true);
}

void MainWindow::on_cqMacroButton_toggled(bool checked) {
    qCDebug(mainwindow_js8) << "on_cqMacroButton_toggled(" << checked << ")";
    if (checked) {
        clearCallsignSelected();

        if (m_cqInterval) {
            qCDebug(mainwindow_js8)
                << "Starting CQ loop from on_cqMacroButton_toggled()";
            m_cq_loop->onTxLoopPeriodChangeStart(m_cqInterval * (qint64)60000);
        } else {
            qCDebug(mainwindow_js8)
                << "Sending single CQ from on_cqMacroButton_toggled()";
            m_cq_loop->onLoopCancel();
            sendCQ(false);

            // make this button emulate a single press button
            ui->cqMacroButton->setChecked(false);
        }
    } else {
        if (m_cq_loop->isActive() && m_cqButtonIsLongterm) {
            qCDebug(mainwindow_js8)
                << "Stopping CQ loop from on_cqMacroButton_toggled()";
            m_cq_loop->onLoopCancel();
        }
    }
    qCDebug(mainwindow_js8)
        << "updateCQButtonDisplay called via on_cqMacroButton_toggled";
    updateCQButtonDisplay();
}

void MainWindow::on_cqMacroButton_clicked() {}

void MainWindow::on_replyMacroButton_clicked() {
    QString call = callsignSelected();
    if (call.isEmpty()) {
        return;
    }

    auto message = m_config.reply_message();
    message = replaceMacros(message, buildMacroValues(), true);
    addMessageText(QString("%1 %2").arg(call).arg(message));

    if (m_config.transmit_directed())
        toggleTx(true);
}

void MainWindow::on_snrMacroButton_clicked() {
    QString call = callsignSelected();
    if (call.isEmpty()) {
        return;
    }

    auto now = DriftingDateTime::currentDateTimeUtc();
    int callsignAging = m_config.callsign_aging();
    if (!m_callActivity.contains(call)) {
        return;
    }

    auto cd = m_callActivity[call];
    if (callsignAging && cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
        return;
    }

    auto snr = Varicode::formatSNR(cd.snr);

    addMessageText(QString("%1 SNR %2").arg(call).arg(snr));

    if (m_config.transmit_directed())
        toggleTx(true);
}

void MainWindow::on_infoMacroButton_clicked() {
    QString info = m_config.my_info();
    if (info.isEmpty()) {
        return;
    }

    addMessageText(
        QString("INFO %1").arg(replaceMacros(info, buildMacroValues(), true)));

    if (m_config.transmit_directed())
        toggleTx(true);
}

void MainWindow::on_statusMacroButton_clicked() {
    QString status = m_config.my_status();
    if (status.isEmpty()) {
        return;
    }

    addMessageText(QString("STATUS %1")
                       .arg(replaceMacros(status, buildMacroValues(), true)));

    if (m_config.transmit_directed())
        toggleTx(true);
}

void MainWindow::setShowColumn(QString tableKey, QString columnKey,
                               bool value) {
    m_showColumnsCache[tableKey + columnKey] = QVariant(value);
    displayBandActivity();
    displayCallActivity();
}

bool MainWindow::showColumn(QString tableKey, QString columnKey,
                            bool default_) {
    return m_showColumnsCache.value(tableKey + columnKey, QVariant(default_))
        .toBool();
}

QString MainWindow::columnLabel(QString defaultLabel) {
    bool minimalLabels = showColumn("all", "minimal_labels", false);

    // If we are not rendering minimal labels, return the default
    if (!minimalLabels) {
        return defaultLabel;
    }

    // If there is an entry, send it, if not, return default
    return m_columnLabelMap.value(defaultLabel, defaultLabel);
}

void MainWindow::buildShowColumnsMenu(QMenu *menu, QString tableKey) {
    QList<QPair<QString, QString>> columnKeys = {
        {"Frequency Offset", "offset"},
        {"Last heard timestamp", "timestamp"},
        {"SNR", "snr"},
        {"Time Delta", "tdrift"},
        {"Mode Speed", "submode"},
    };

    QMap<QString, bool> defaultOverride = {
        {"submode", false},  {"tdrift", false},  {"grid", false},
        {"distance", false}, {"azimuth", false}, {"minimal_labels", false}};

    if (tableKey == "call") {
        columnKeys.prepend({"Callsign", "callsign"});
        columnKeys.append({
            {"Grid Locator", "grid"},
            {"Distance", "distance"},
            {"Azimuth", "azimuth"},
            {"Worked Before", "log"},
            {"Logged Name", "logName"},
            {"Logged Comment", "logComment"},
        });
    }

    columnKeys.prepend({"Minimal Column Labels", "minimal_labels"});
    columnKeys.prepend({"Show Column Labels", "labels"});

    int columnIndex = 0;
    QString origTableKey = tableKey;
    foreach (auto p, columnKeys) {
        auto columnLabel = p.first;
        auto columnKey = p.second;

        auto a = menu->addAction(columnLabel);
        a->setCheckable(true);

        // Add separator after second item
        // If this is the second item, it is the minimal labels item, so set the
        // table key to all
        if (++columnIndex == 2) {
            tableKey = "all";
            menu->addSeparator();
        }

        bool showByDefault = true;
        if (defaultOverride.contains(columnKey)) {
            showByDefault = defaultOverride[columnKey];
        }
        a->setChecked(showColumn(tableKey, columnKey, showByDefault));

        connect(a, &QAction::triggered, this, [this, a, tableKey, columnKey]() {
            setShowColumn(tableKey, columnKey, a->isChecked());
        });

        // If we have switched to a custom table key in this iteration, reset to
        // the original key
        if (tableKey != origTableKey) {
            tableKey = origTableKey;
        }
    }
}

void MainWindow::setSortBy(QString key, QString value) {
    m_sortCache[key] = QVariant(value);
    displayBandActivity();
    displayCallActivity();
}

QString MainWindow::getSortBy(QString const &key,
                              QString const &defaultValue) const {
    return m_sortCache.value(key, QVariant(defaultValue)).toString();
}

MainWindow::SortByReverse
MainWindow::getSortByReverse(QString const &key,
                             QString const &defaultValue) const {
    auto const sortBy = getSortBy(key, defaultValue);
    auto const reverse = sortBy.startsWith("-");

    return {reverse ? sortBy.sliced(1) : sortBy, reverse};
}

void MainWindow::buildSortByMenu(QMenu *menu, QString key, QString defaultValue,
                                 QList<QPair<QString, QString>> values) {
    auto currentSortBy = getSortBy(key, defaultValue);

    QActionGroup *g = new QActionGroup(menu);
    g->setExclusive(true);

    foreach (auto p, values) {
        auto k = p.first;
        auto v = p.second;
        auto a = menu->addAction(k);
        a->setCheckable(true);
        a->setChecked(v == currentSortBy);
        a->setActionGroup(g);

        connect(a, &QAction::triggered, this, [this, a, key, v]() {
            if (a->isChecked()) {
                setSortBy(key, v);
            }
        });
    }
}

void MainWindow::buildBandActivitySortByMenu(QMenu *menu) {
    buildSortByMenu(menu, "bandActivity", "offset",
                    {{"Frequency offset", "offset"},
                     {"Last heard timestamp (oldest first)", "timestamp"},
                     {"Last heard timestamp (recent first)", "-timestamp"},
                     {"SNR (weakest first)", "snr"},
                     {"SNR (strongest first)", "-snr"},
                     {"Mode Speed (slowest first)", "submode"},
                     {"Mode Speed (fastest first)", "-submode"}});
}

void MainWindow::buildCallActivitySortByMenu(QMenu *menu) {
    buildSortByMenu(menu, "callActivity", "callsign",
                    {{"Callsign", "callsign"},
                     {"Callsigns Replied (recent first)", "ackTimestamp"},
                     {"Frequency offset", "offset"},
                     {"Distance (closest first)", "distance"},
                     {"Distance (farthest first)", "-distance"},
                     {"Azimuth", "azimuth"},
                     {"Last heard timestamp (oldest first)", "timestamp"},
                     {"Last heard timestamp (recent first)", "-timestamp"},
                     {"SNR (weakest first)", "snr"},
                     {"SNR (strongest first)", "-snr"},
                     {"Mode Speed (slowest first)", "submode"},
                     {"Mode Speed (fastest first)", "-submode"}});
}

void MainWindow::buildQueryMenu(QMenu *menu, QString call) {
    bool isAllCall = isAllCallIncluded(call);

    // for now, we're going to omit displaying the call...delete this if we want
    // the other functionality
    call = "";

    auto grid = m_config.my_grid();

    bool emptyInfo = m_config.my_info().isEmpty();
    bool emptyGrid = m_config.my_grid().isEmpty();

    auto callAction = menu->addAction(
        QString("Send a directed message to selected callsign"));
    connect(callAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 ").arg(selectedCall), true);
    });

    menu->addSeparator();

    auto sendReplyAction = menu->addAction(
        QString("%1 Reply - Send reply message to selected callsign")
            .arg(call)
            .trimmed());
    connect(sendReplyAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        auto message = m_config.reply_message();
        message = replaceMacros(message, buildMacroValues(), true);
        addMessageText(QString("%1 %2").arg(selectedCall).arg(message), true);
    });

    auto sendSNRAction = menu->addAction(
        QString("%1 SNR - Send a signal report to the selected callsign")
            .arg(call)
            .trimmed());
    sendSNRAction->setEnabled(m_callActivity.contains(callsignSelected()));
    connect(sendSNRAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        if (!m_callActivity.contains(selectedCall)) {
            return;
        }

        auto d = m_callActivity[selectedCall];
        addMessageText(QString("%1 SNR %2")
                           .arg(selectedCall)
                           .arg(Varicode::formatSNR(d.snr)),
                       true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto infoAction = menu->addAction(
        QString("%1 INFO - Send my station information").arg(call).trimmed());
    infoAction->setDisabled(emptyInfo);
    connect(infoAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(
            QString("%1 INFO %2").arg(selectedCall).arg(m_config.my_info()),
            true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto gridAction = menu->addAction(
        QString("%1 GRID %2 - Send my current station Maidenhead grid locator")
            .arg(call)
            .arg(grid)
            .trimmed());
    gridAction->setDisabled(emptyGrid);
    connect(gridAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(
            QString("%1 GRID %2").arg(selectedCall).arg(m_config.my_grid()),
            true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    menu->addSeparator();

    auto snrQueryAction = menu->addAction(
        QString("%1 SNR? - What is my signal report?").arg(call).trimmed());
    snrQueryAction->setDisabled(isAllCall);
    connect(snrQueryAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 SNR?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto infoQueryAction =
        menu->addAction(QString("%1 INFO? - What is your station information?")
                            .arg(call)
                            .trimmed());
    infoQueryAction->setDisabled(isAllCall);
    connect(infoQueryAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 INFO?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto gridQueryAction =
        menu->addAction(QString("%1 GRID? - What is your current grid locator?")
                            .arg(call)
                            .trimmed());
    gridQueryAction->setDisabled(isAllCall);
    connect(gridQueryAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 GRID?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto stationIdleQueryAction = menu->addAction(
        QString("%1 STATUS? - What is your station status message?")
            .arg(call)
            .trimmed());
    stationIdleQueryAction->setDisabled(isAllCall);
    connect(stationIdleQueryAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 STATUS?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto heardQueryAction = menu->addAction(
        QString("%1 HEARING? - What are the stations are you hearing? (Top 4 "
                "ranked by most recently heard)")
            .arg(call)
            .trimmed());
    heardQueryAction->setDisabled(isAllCall);
    connect(heardQueryAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 HEARING?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

#if 0
    auto retransmitAction = menu->addAction(QString("%1|[MESSAGE] - Please ACK and retransmit the following message").arg(call).trimmed());
    retransmitAction->setDisabled(isAllCall);
    connect(retransmitAction, &QAction::triggered, this, [this](){

        QString selectedCall = callsignSelected();
        if(selectedCall.isEmpty()){
            return;
        }

        addMessageText(QString("%1|[MESSAGE]").arg(selectedCall), true, true);
    });
#endif

    auto alertAction = menu->addAction(
        QString("%1>[MESSAGE] - Please relay this message to its destination")
            .arg(call)
            .trimmed());
    alertAction->setDisabled(isAllCall);
    connect(alertAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1>[MESSAGE]").arg(selectedCall), true, true);
    });

    auto msgAction = menu->addAction(
        QString("%1 MSG [MESSAGE] - Please store this message in your inbox")
            .arg(call)
            .trimmed());
    msgAction->setDisabled(isAllCall);
    connect(msgAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 MSG [MESSAGE]").arg(selectedCall), true,
                       true);
    });

    auto msgToAction = menu->addAction(
        QString("%1 MSG TO:[CALLSIGN] [MESSAGE] - Please store this message at "
                "your station for later retreival by [CALLSIGN]")
            .arg(call)
            .trimmed());
    msgToAction->setDisabled(isAllCall);
    connect(msgToAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(
            QString("%1 MSG TO:[CALLSIGN] [MESSAGE]").arg(selectedCall), true,
            true);
    });

    auto qsoQueryAction = menu->addAction(
        QString("%1 QUERY CALL [CALLSIGN]? - Please acknowledge you can "
                "communicate directly with [CALLSIGN]")
            .arg(call)
            .trimmed());
    connect(qsoQueryAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 QUERY CALL [CALLSIGN]?").arg(selectedCall),
                       true, true);
    });

    auto qsoQueryMsgsAction = menu->addAction(
        QString("%1 QUERY MSGS - Do you have any messages for me?")
            .arg(call)
            .trimmed());
    connect(qsoQueryMsgsAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 QUERY MSGS").arg(selectedCall), true, true);
    });

    auto qsoQueryMsgAction =
        menu->addAction(QString("%1 QUERY MSG [ID] - Please deliver the "
                                "complete message identified by ID")
                            .arg(call)
                            .trimmed());
    connect(qsoQueryMsgAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 QUERY MSG [ID]").arg(selectedCall), true,
                       true);
    });

    menu->addSeparator();

    auto agnAction = menu->addAction(
        QString("%1 AGN? - Please repeat your last transmission")
            .arg(call)
            .trimmed());
    connect(agnAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 AGN?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto qslQueryAction = menu->addAction(
        QString("%1 QSL? - Did you receive my last transmission?")
            .arg(call)
            .trimmed());
    connect(qslQueryAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 QSL?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto qslAction = menu->addAction(
        QString("%1 QSL - I confirm I received your last transmission")
            .arg(call)
            .trimmed());
    connect(qslAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 QSL").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto yesAction = menu->addAction(
        QString("%1 YES - I confirm your last inquiry").arg(call).trimmed());
    connect(yesAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 YES").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto noAction =
        menu->addAction(QString("%1 NO - I do not confirm your last inquiry")
                            .arg(call)
                            .trimmed());
    connect(noAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 NO").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto hwAction = menu->addAction(
        QString("%1 HW CPY? - How do you copy?").arg(call).trimmed());
    connect(hwAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 HW CPY?").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto rrAction = menu->addAction(
        QString("%1 RR - Roger. Received. I copy.").arg(call).trimmed());
    connect(rrAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 RR").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto fbAction =
        menu->addAction(QString("%1 FB - Fine Business").arg(call).trimmed());
    connect(fbAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 FB").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto sevenThreeAction = menu->addAction(
        QString("%1 73 - I send my best regards").arg(call).trimmed());
    connect(sevenThreeAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 73").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto skAction =
        menu->addAction(QString("%1 SK - End of contact").arg(call).trimmed());
    connect(skAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 SK").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });

    auto ditDitAction = menu->addAction(
        QString("%1 DIT DIT - End of contact / Two bits").arg(call).trimmed());
    connect(ditDitAction, &QAction::triggered, this, [this]() {
        QString selectedCall = callsignSelected();
        if (selectedCall.isEmpty()) {
            return;
        }

        addMessageText(QString("%1 DIT DIT").arg(selectedCall), true);

        if (m_config.transmit_directed())
            toggleTx(true);
    });
}

void MainWindow::buildRelayMenu(QMenu *menu) {
    auto now = DriftingDateTime::currentDateTimeUtc();
    int callsignAging = m_config.callsign_aging();
    foreach (auto cd, m_callActivity.values()) {
        if (callsignAging &&
            cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
            continue;
        }

        menu->addAction(buildRelayAction(cd.call));
    }
}

QAction *MainWindow::buildRelayAction(QString call) {
    QAction *a = new QAction(call, nullptr);
    connect(a, &QAction::triggered, this,
            [this, call]() { prependMessageText(QString("%1>").arg(call)); });
    return a;
}

void MainWindow::buildEditMenu(QMenu *menu, QTextEdit *edit) {
    bool hasSelection = !edit->textCursor().selectedText().isEmpty();

    auto cut = menu->addAction("Cu&t");
    cut->setEnabled(hasSelection && !edit->isReadOnly());
    connect(edit, &QTextEdit::copyAvailable, this,
            [edit, cut](bool copyAvailable) {
                cut->setEnabled(copyAvailable && !edit->isReadOnly());
            });
    connect(cut, &QAction::triggered, this, [edit]() {
        edit->copy();
        edit->textCursor().removeSelectedText();
    });

    auto copy = menu->addAction("&Copy");
    copy->setEnabled(hasSelection);
    connect(edit, &QTextEdit::copyAvailable, this,
            [copy](bool copyAvailable) { copy->setEnabled(copyAvailable); });
    connect(copy, &QAction::triggered, edit, &QTextEdit::copy);

    auto paste = menu->addAction("&Paste");
    paste->setEnabled(edit->canPaste());
    connect(paste, &QAction::triggered, edit, &QTextEdit::paste);
}

QMap<QString, QString> MainWindow::buildMacroValues() {
    auto lastActive =
        DriftingDateTime::currentDateTimeUtc().addSecs(-m_idleMinutes * 60);
    QString myIdle = since(lastActive).toUpper().replace("NOW", "0M");
    QString myVersion = version().replace("-devel", "").replace("-rc", "");

    QMap<QString, QString> values = {
        {"<MYCALL>", m_config.my_callsign()},
        {"<MYGRID4>", m_config.my_grid().left(4)},
        {"<MYGRID12>", m_config.my_grid().left(12)},
        {"<MYINFO>", m_config.my_info()},
        {"<MYHB>", m_config.hb_message()},
        {"<MYCQ>", m_config.cq_message()},
        {"<MYREPLY>", m_config.reply_message()},
        {"<MYSTATUS>", m_config.my_status()},

        {"<MYVERSION>", myVersion},
        {"<MYIDLE>", myIdle},
    };

    auto selectedCall = callsignSelected();
    if (m_callActivity.contains(selectedCall)) {
        auto cd = m_callActivity[selectedCall];

        values["<CALL>"] = selectedCall;
        values["<TDELTA>"] = QString("%1 ms").arg((int)(1000 * cd.tdrift));

        if (cd.snr > -31) {
            values["<SNR>"] = Varicode::formatSNR(cd.snr);
        }
    }

    // these macros can have recursive macros
    values["<MYINFO>"] = replaceMacros(values["<MYINFO>"], values, false);
    values["<MYSTATUS>"] = replaceMacros(values["<MYSTATUS>"], values, false);
    values["<MYCQ>"] = replaceMacros(values["<MYCQ>"], values, false);
    values["<MYHB>"] = replaceMacros(values["<MYHB>"], values, false);
    values["<MYREPLY>"] = replaceMacros(values["<MYREPLY>"], values, false);

    return values;
}

void MainWindow::buildColumnLabelMap() {
    // This is the map of full-length strings to shortened versions
    // Add new minimal labels here as needed
    m_columnLabelMap = {{"Callsigns", "Call"}, {"Callsigns (%1)", "Call(%1)"},
                        {"Offset", "Off"},     {"SNR", "SN"},
                        {"Time Delta", "TD"},  {"Speed", "Sp"},
                        {"Distance", "Dist"},  {"Azimuth", "Az"},
                        {"%1 ms", "%1"},       {"%1 dB", "%1"},
                        {"%1 Hz", "%1"}};

    // Populate original header maps
    int cols = ui->tableWidgetRXAll->columnCount();
    for (int c = 0; c < cols; ++c) {
        QString label = ui->tableWidgetRXAll->horizontalHeaderItem(c)->text();

        m_origRxHeaderLabelMap[c] = label;
    }

    cols = ui->tableWidgetCalls->columnCount();
    for (int c = 0; c < cols; ++c) {
        QString label = ui->tableWidgetCalls->horizontalHeaderItem(c)->text();

        m_origCallActivityHeaderLabelMap[c] = label;
    }
}

void MainWindow::buildSuggestionsMenu(QMenu *menu, QTextEdit *edit,
                                      const QPoint &point) {
    if (!m_config.spellcheck()) {
        return;
    }

    bool found = false;

    auto c = edit->cursorForPosition(point);
    if (c.charFormat().underlineStyle() != QTextCharFormat::WaveUnderline) {
        return;
    }

    c.movePosition(QTextCursor::StartOfWord);
    c.movePosition(QTextCursor::EndOfWord, QTextCursor::KeepAnchor);

    auto word = c.selectedText().toUpper().trimmed();
    if (word.isEmpty()) {
        return;
    }

    QStringList suggestions = JSCChecker::suggestions(word, 5, &found);
    if (suggestions.isEmpty() && !found) {
        return;
    }

    if (suggestions.isEmpty()) {
        auto a = menu->addAction("No Suggestions");
        a->setDisabled(true);
    } else {
        foreach (auto suggestion, suggestions) {
            auto a = menu->addAction(suggestion);

            connect(a, &QAction::triggered, this, [edit, point, suggestion]() {
                auto c = edit->cursorForPosition(point);
                c.select(QTextCursor::WordUnderCursor);
                c.insertText(suggestion);
            });
        }
    }

    menu->addSeparator();
}

void MainWindow::buildSavedMessagesMenu(QMenu *menu) {
    auto values = buildMacroValues();

    foreach (QString macro, m_config.macros()->stringList()) {
        QAction *action = menu->addAction(replaceMacros(macro, values, false));
        connect(action, &QAction::triggered, this, [this, macro]() {
            auto values = buildMacroValues();
            addMessageText(replaceMacros(macro, values, true));

            if (m_config.transmit_directed())
                toggleTx(true);
        });
    }

    menu->addSeparator();

    auto editAction = new QAction(QString("&Edit Saved Messages"), menu);
    menu->addAction(editAction);
    connect(editAction, &QAction::triggered, this,
            [this]() { openSettings(5); });

    auto saveAction = new QAction(QString("&Save Current Message"), menu);
    saveAction->setDisabled(ui->extFreeTextMsgEdit->toPlainText().isEmpty());
    menu->addAction(saveAction);
    connect(saveAction, &QAction::triggered, this, [this]() {
        auto macros = m_config.macros();
        if (macros->insertRow(macros->rowCount())) {
            auto index = macros->index(macros->rowCount() - 1);
            macros->setData(index, ui->extFreeTextMsgEdit->toPlainText());
            writeSettings();
        }
    });
}

void MainWindow::on_queryButton_pressed() {
    QMenu *menu = ui->queryButton->menu();
    if (!menu) {
        menu = new QMenu(ui->queryButton);
    }
    menu->clear();

    buildQueryMenu(menu, callsignSelected());

    ui->queryButton->setMenu(menu);
    ui->queryButton->showMenu();
}

void MainWindow::on_macrosMacroButton_pressed() {
    QMenu *menu = ui->macrosMacroButton->menu();
    if (!menu) {
        menu = new QMenu(ui->macrosMacroButton);
    }
    menu->clear();

    buildSavedMessagesMenu(menu);

    ui->macrosMacroButton->setMenu(menu);
    ui->macrosMacroButton->showMenu();
}

void MainWindow::on_deselectButton_pressed() { clearCallsignSelected(); }

void MainWindow::on_tableWidgetRXAll_cellClicked(int /*row*/, int /*col*/) {
    ui->tableWidgetCalls->selectionModel()->select(
        ui->tableWidgetCalls->selectionModel()->selection(),
        QItemSelectionModel::Deselect);

    displayCallActivity();
}

void MainWindow::on_tableWidgetRXAll_cellDoubleClicked(int row, int col) {
    on_tableWidgetRXAll_cellClicked(row, col);

    // TODO: jsherer - could also parse the messages for the last callsign?
    auto item = ui->tableWidgetRXAll->item(row, 0);
    int offset = item->text().replace(" Hz", "").toInt();

    // switch to the offset of this row
    setFreqOffsetForRestore(offset, false);

    // TODO: prompt mode switch?

    // print the history in the main window...
    int activityAging = m_config.activity_aging();
    QDateTime now = DriftingDateTime::currentDateTimeUtc();
    QDateTime firstActivity = now;
    QString activityText;
    bool isLast = false;
    foreach (auto d, m_bandActivity[offset]) {
        if (activityAging && d.utcTimestamp.secsTo(now) / 60 >= activityAging) {
            continue;
        }
        if (activityText.isEmpty()) {
            firstActivity = d.utcTimestamp;
        }
        activityText.append(d.text);

        isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast;
        if (isLast) {
            activityText = QString("%1 %2 ")
                               .arg(Varicode::rstrip(activityText))
                               .arg(m_config.eot());
        }
    }
    if (!activityText.isEmpty()) {
        displayTextForFreq(activityText, offset, firstActivity, false, true,
                           isLast);
    }
}

QString MainWindow::generateCallDetail(QString selectedCall) {
    if (selectedCall.isEmpty()) {
        return "";
    }

    // heard detail
    QString hearing =
        m_heardGraphOutgoing.value(selectedCall).values().join(", ");
    QString heardby =
        m_heardGraphIncoming.value(selectedCall).values().join(", ");
    QStringList detail = {
        QString("<h1>%1</h1>").arg(selectedCall.toHtmlEscaped()),
        hearing.isEmpty() ? ""
                          : QString("<p><strong>HEARING</strong>: %1</p>")
                                .arg(hearing.toHtmlEscaped()),
        heardby.isEmpty() ? ""
                          : QString("<p><strong>HEARD BY</strong>: %1</p>")
                                .arg(heardby.toHtmlEscaped()),
    };

    return detail.join("\n");
}

void MainWindow::on_tableWidgetCalls_cellClicked(int /*row*/, int /*col*/) {
    ui->tableWidgetRXAll->selectionModel()->select(
        ui->tableWidgetRXAll->selectionModel()->selection(),
        QItemSelectionModel::Deselect);

    displayBandActivity();
}

void MainWindow::on_tableWidgetCalls_cellDoubleClicked(int row, int col) {
    on_tableWidgetCalls_cellClicked(row, col);

    auto call = callsignSelected();
    addMessageText(call);

#if SHOW_MESSAGE_HISTORY_ON_DOUBLECLICK
    if (m_rxInboxCountCache.value(call, 0) > 0) {

        // TODO:
        // CommandDetail d = m_rxCallsignInboxCountCache[call].first();
        // m_rxCallsignInboxCountCache[call].removeFirst();
        //
        // processAlertReplyForCommand(d, d.relayPath, d.cmd);

        Inbox i(inboxPath());
        if (i.open()) {
            QList<Message> msgs;
            foreach (auto pair,
                     i.values("UNREAD", "$.params.FROM", call, 0, 1000)) {
                msgs.append(pair.second);
            }

            auto mw = new MessageWindow(this);
            mw->populateMessages(msgs);
            mw->show();

            auto pair = i.firstUnreadFrom(call);
            auto id = pair.first;
            auto msg = pair.second;
            auto params = msg.params();

            CommandDetail d;
            d.cmd = params.value("CMD").toString();
            d.extra = params.value("EXTRA").toString();
            d.freq = params.value("OFFSET").toInt();
            d.from = params.value("FROM").toString();
            d.grid = params.value("GRID").toString();
            d.relayPath = params.value("PATH").toString();
            d.snr = params.value("SNR").toInt();
            d.tdrift = params.value("TDRIFT").toFloat();
            d.text = params.value("TEXT").toString();
            d.to = params.value("TO").toString();
            d.utcTimestamp = QDateTime::fromString(
                params.value("UTC").toString(), "yyyy-MM-dd hh:mm:ss");
            d.utcTimestamp.setUtcOffset(0);

            msg.setType("READ");
            i.set(id, msg);

            m_rxInboxCountCache[call] =
                max(0, m_rxInboxCountCache.value(call) - 1);

            processAlertReplyForCommand(d, d.relayPath, d.cmd);
        }

    } else {
        addMessageText(call);
    }
#endif
}

void MainWindow::on_tuneButton_clicked(bool checked) {
    static bool lastChecked = false;
    if (lastChecked == checked)
        return;
    lastChecked = checked;
    if (checked && m_tune == false) { // we're starting tuning so remember Tx
                                      // and change pwr to Tune value
        if (m_config.pwrBandTuneMemory()) {
            m_pwrBandTxMemory[m_lastBand] =
                ui->outAttenuation->value(); // remember our Tx pwr
            m_PwrBandSetOK = false;
            if (m_pwrBandTuneMemory.contains(m_lastBand)) {
                ui->outAttenuation->setValue(
                    m_pwrBandTuneMemory[m_lastBand].toInt()); // set to Tune pwr
            }
            m_PwrBandSetOK = true;
        }
    }
    if (m_tune) {
        tuneButtonTimer.start(250);
    } else {
        itone[0] = 0;
        on_monitorButton_clicked(true);
        m_tune = true;
    }
    Q_EMIT tune(checked);
}

void MainWindow::end_tuning() {
    tuneATU_Timer.stop(); // stop tune watchdog when stopping Tune manually
    on_stopTxButton_clicked();
    // we're turning off so remember our Tune pwr setting and reset to Tx pwr
    if (m_config.pwrBandTuneMemory() || m_config.pwrBandTxMemory()) {
        m_pwrBandTuneMemory[m_lastBand] =
            ui->outAttenuation->value(); // remember our Tune pwr
        m_PwrBandSetOK = false;
        ui->outAttenuation->setValue(
            m_pwrBandTxMemory[m_lastBand].toInt()); // set to Tx pwr
        m_PwrBandSetOK = true;
    }
}

void MainWindow::stop_tuning() {
    tuneATU_Timer.stop(); // stop tune watchdog when stopping Tune manually
    on_tuneButton_clicked(false);
    ui->tuneButton->setChecked(false);
    m_isTimeToSend = false;
    m_tune = false;
}

void MainWindow::stopTuneATU() {
    on_tuneButton_clicked(false);
    m_isTimeToSend = false;
}

void MainWindow::resetPushButtonToggleText(QPushButton *btn) {
    bool checked = btn->isChecked();
    auto style = btn->styleSheet();
    if (checked) {
        style = style.replace("font-weight:normal;", "font-weight:bold;");
    } else {
        style = style.replace("font-weight:bold;", "font-weight:normal;");
    }
    btn->setStyleSheet(style);

#if PUSH_BUTTON_CHECKMARK
    auto on = "✓ ";
    auto text = btn->text();
    if (checked) {
        btn->setText(on + text.replace(on, ""));
    } else {
        btn->setText(text.replace(on, ""));
    }
#endif

#if PUSH_BUTTON_MIN_WIDTH
    int width = 0;
    QList<QPushButton *> btns;
    foreach (auto child, ui->buttonGrid->children()) {
        if (!child->isWidgetType()) {
            continue;
        }

        if (!child->objectName().contains("Button")) {
            continue;
        }

        auto b = qobject_cast<QPushButton *>(child);
        width = qMax(width, b->geometry().width());
        btns.append(b);
    }

    foreach (auto child, btns) {
        child->setMinimumWidth(width);
    }
#endif
}

void MainWindow::on_stopTxButton_clicked() // Stop Tx
{
    if (m_tune)
        stop_tuning();
    if (m_auto and !m_tuneup)
        auto_tx_mode(false);
    m_btxok = false;

    resetMessage();

    if (m_stopTxButtonIsLongterm) {
        m_hb_loop->onLoopCancel();
        m_cq_loop->onLoopCancel();
    }
}

void MainWindow::rigOpen() {
    update_dynamic_property(ui->readFreq, "state", "warning");
    ui->readFreq->setText("CAT");
    ui->readFreq->setEnabled(true);
    m_config.transceiver_online();
    Q_EMIT m_config.sync_transceiver(true, true);
}

void MainWindow::on_readFreq_clicked() {
    if (m_transmitting)
        return;

    if (m_config.transceiver_online()) {
        Q_EMIT m_config.sync_transceiver(true, true);
    }
}

void MainWindow::setXIT(int audio_freq) {
    if (m_transmitting && !m_config.tx_qsy_allowed()) {
        qCWarning(mainwindow_js8) << "Ignoring change of audio freq to"
                                  << audio_freq << "as currently transmitting.";
        return;
    }

    // m_XIT is the frequency diff that will be added to the audio frequency
    // and subtracted from the radio frequency.
    // The new audio frequency is in the 1500 - 2000 Hz range,
    // the audio actually transmitted is 1500 - 2160 Hz (TURBO has 160 Hz
    // bandwith). This way, the unwanted triple audio frequency possibly
    // generated by audio distortions is safely beyond the TX audio bandwidth of
    // 3 kHz and will not result in transmission. Also, the 1500 - 2160 Hz range
    // should not be distorted or dampened by the TX's audio filter.
    if (m_config.split_mode()) {
        const int next_lower_multiple_of_500 = audio_freq - audio_freq % 500;
        m_XIT = 1500 - next_lower_multiple_of_500;
    } else {
        m_XIT = 0;
    }

    const int new_audio_frequency = audio_freq + m_XIT;

    if ((m_monitoring || m_transmitting) && m_config.is_transceiver_online() &&
        m_config.split_mode()) {
        // All conditions are met, reset the transceiver Tx dial frequency
        m_freqTxNominal = m_freqNominal - m_XIT;
        qCDebug(mainwindow_js8)
            << "For incoming AF" << audio_freq << "setting tx HF to"
            << m_freqTxNominal << "and new AF to" << new_audio_frequency;
        Q_EMIT m_config.transceiver_tx_frequency(m_freqTxNominal);
    }

    // Now set the audio Tx freq
    Q_EMIT transmitFrequency(new_audio_frequency);
}

void MainWindow::qsy(int const hzDelta) {
    setRig(m_freqNominal + hzDelta);
    setFreqOffsetForRestore(m_wideGraph->centerFreq(), false);

    // Adjust band activity frequencies.

    BandActivity bandActivity;

    for (auto [key, value] : m_bandActivity.asKeyValueRange()) {
        if (value.isEmpty())
            continue;

        auto const newKey = key - hzDelta;

        bandActivity[newKey] = value;
        bandActivity[newKey].last().offset -= hzDelta;
    }

    m_bandActivity.swap(bandActivity);

    // Adjust call activity frequencies.

    for (auto [key, value] : m_callActivity.asKeyValueRange()) {
        value.offset -= hzDelta;
    }

    displayActivity(true);
}

void MainWindow::onDriftChanged(qint64 /*new_drift_ms*/) {
    // here we reset the buffer position without clearing the buffer
    // this makes the detected emit the correct k when drifting time
    qCDebug(mainwindow_js8) << "Processing drift change.";
    m_detector->resetBufferPosition();
}

void MainWindow::setFreqOffsetForRestore(int freq, bool shouldRestore) {
    changeFreq(freq);
    if (shouldRestore) {
        m_shouldRestoreFreq = true;
    } else {
        m_previousFreq = 0;
        m_shouldRestoreFreq = false;
    }
}

bool MainWindow::tryRestoreFreqOffset() {
    if (!m_shouldRestoreFreq || m_previousFreq == 0) {
        return false;
    }

    setFreqOffsetForRestore(m_previousFreq, false);
    return true;
}

void MainWindow::changeFreq(int const newFreq) {
    // Don't allow QSY if we've already queued a transmission,
    // unless we have that functionality enabled.

    if (isMessageQueuedForTransmit() && !m_config.tx_qsy_allowed())
        return;

    // TODO: jsherer - here's where we'd set minimum frequency again (later?)

    m_previousFreq = freq();
    setFreq(std::max(0, newFreq));

    displayDialFrequency();
}

void MainWindow::handle_transceiver_update(
    Transceiver::TransceiverState const &new_rig_state) {
    qCDebug(mainwindow_js8)
        << "MainWindow::handle_transceiver_update:" << new_rig_state;
    Transceiver::TransceiverState old_state{m_rigState};

    // GM8JCF: in stopTx2 we maintain PTT if there are still untransmitted JS8
    // frames and we are holding the PTT KN4CRD: if we're not holding the PTT we
    // need to check to ensure it's safe to transmit
    if (m_config.hold_ptt() ||
        (new_rig_state.ptt() &&
         !m_rigState
              .ptt())) // safe to start audio (caveat - DX Lab Suite Commander)
    {
        if (m_generateAudioWhenPttConfirmedByTX &&
            m_iptt) // waiting to Tx and still needed
        {
            // The Modulator nicely emits silence during txDelay, so let us just
            // tigger it.
            transmit();
        }
        m_generateAudioWhenPttConfirmedByTX = false;
    }
    m_rigState = new_rig_state;

    auto old_freqNominal = m_freqNominal;
    if (!old_freqNominal) {
        // always take initial rig frequency to avoid start up problems
        // with bogus Tx frequencies
        m_freqNominal = new_rig_state.frequency();
    }

    if (old_state.online() == false && new_rig_state.online() == true) {
        // initializing
        on_monitorButton_clicked(!m_config.monitor_off_at_startup());
        on_monitorTxButton_toggled(!m_config.transmit_off_at_startup());
    }

    if (new_rig_state.frequency() != old_state.frequency() ||
        new_rig_state.split() != m_splitMode) {
        m_splitMode = new_rig_state.split();
        if (!new_rig_state.ptt()) {
            m_freqNominal = new_rig_state.frequency();
            if (old_freqNominal != m_freqNominal) {
                m_freqTxNominal = m_freqNominal;
            }

            if (m_monitoring) {
                m_lastMonitoredFrequency = m_freqNominal;
            }
            if (m_lastDialFreq != m_freqNominal) {

                m_lastDialFreq = m_freqNominal;
                m_secBandChanged =
                    DriftingDateTime::currentMSecsSinceEpoch() / 1000;

                if (m_freqNominal != m_bandHoppedFreq) {
                    m_bandHopped = false;
                }

                if (new_rig_state.frequency() < 30000000u) {
                    write_frequency_entry("ALL.TXT");
                }

                if (m_config.spot_to_reporting_networks()) {
                    spotSetLocal();
                    pskSetLocal();
                    aprsSetLocal();
                }
                statusChanged();
                m_wideGraph->setDialFreq(m_freqNominal / 1.e6f);
            }
        } else {
            m_freqTxNominal = new_rig_state.split()
                                  ? new_rig_state.tx_frequency()
                                  : new_rig_state.frequency();
        }
    }

    // ensure frequency display is correct
    // setRig();
    updateCurrentBand();
    displayDialFrequency();
    update_dynamic_property(ui->readFreq, "state", "ok");
    ui->readFreq->setEnabled(false);
    ui->readFreq->setText(new_rig_state.split() ? "CAT/S" : "CAT");
}

void MainWindow::handle_transceiver_failure(QString const &reason) {
    update_dynamic_property(ui->readFreq, "state", "error");
    ui->readFreq->setEnabled(true);
    on_stopTxButton_clicked();
    rigFailure(reason);
}

void MainWindow::rigFailure(QString const &reason) {
    if (m_first_error) {
        // one automatic retry
        QTimer::singleShot(0, this, &MainWindow::rigOpen);
        m_first_error = false;
    } else {
        m_rigErrorMessageBox.setDetailedText(reason);

        // don't call slot functions directly to avoid recursion
        m_rigErrorMessageBox.exec();
        auto const clicked_button = m_rigErrorMessageBox.clickedButton();
        if (clicked_button == m_configurations_button) {
            ui->menuConfig->exec(QCursor::pos());
        } else {
            switch (m_rigErrorMessageBox.standardButton(clicked_button)) {
            case JS8MessageBox::Ok:
                m_config.select_tab(1);
                QTimer::singleShot(0, this,
                                   &MainWindow::on_actionSettings_triggered);
                break;

            case JS8MessageBox::Retry:
                QTimer::singleShot(0, this, &MainWindow::rigOpen);
                break;

            case JS8MessageBox::Cancel:
                QTimer::singleShot(0, this, &MainWindow::close);
                break;

            default:
                break; // squashing compile warnings
            }
        }
        m_first_error = true; // reset
    }
}

void MainWindow::on_outAttenuation_valueChanged(int const a) {
    if (m_PwrBandSetOK) {
        if (!m_tune && m_config.pwrBandTxMemory())
            m_pwrBandTxMemory[m_lastBand] = a; // remember our Tx pwr
        if (m_tune && m_config.pwrBandTuneMemory())
            m_pwrBandTuneMemory[m_lastBand] = a; // remember our Tune pwr
    }

    // Slider interpreted as dB / 100.

    Q_EMIT outAttenuationChanged(a / 10.0);
}

void MainWindow::spotSetLocal() {
    Q_EMIT spotClientSetLocalStation(
        m_config.my_callsign(), m_config.my_grid(),
        replaceMacros(m_config.my_info(), buildMacroValues(), true));
}

void MainWindow::pskSetLocal() {
    Q_EMIT pskReporterSetLocalStation(
        m_config.my_callsign(), m_config.my_grid(),
        replaceMacros(m_config.my_info(), buildMacroValues(), true));
}

void MainWindow::aprsSetLocal() {
    Q_EMIT aprsClientSetLocalStation(
        "APJ8CL", QString::number(APRSISClient::hashCallsign("APJ8CL")));
}

void MainWindow::transmitDisplay(bool transmitting) {
    if (transmitting == m_transmitting) {
        if (transmitting) {
            ui->signal_meter_widget->setValue(0, 0);
            if (m_monitoring)
                monitor(false);
            m_btxok = true;
        }
    }

    updateTxButtonDisplay();
}

void MainWindow::postDecode(bool is_new, QString const &) {
#if 0
  auto const& decode = message.trimmed ();
  auto const& parts = decode.left (22).split (' ', QString::SkipEmptyParts);
  if (parts.size () >= 5)
  {
      auto has_seconds = parts[0].size () > 4;
      m_messageClient->decode (is_new
                               , QTime::fromString (parts[0], has_seconds ? "hhmmss" : "hhmm")
                               , parts[1].toInt ()
                               , parts[2].toFloat (), parts[3].toUInt (), parts[4]
                               , decode.mid (has_seconds ? 24 : 22, 21)
                               , QChar {'?'} == decode.mid (has_seconds ? 24 + 21 : 22 + 21, 1)
                               , m_diskData);
  }
#endif

    if (is_new) {
        m_rxDirty = true;
    }
}

void MainWindow::tryNotify(QString const &key) {
    if (auto const path = m_config.notification_path(key); !path.isEmpty()) {
        emit playNotification(path);
    }
}

void MainWindow::displayTransmit() {
    // Transmit Activity
    update_dynamic_property(ui->startTxButton, "transmitting", m_transmitting);
    update_dynamic_property(ui->monitorTxButton, "transmitting",
                            m_transmitting);
}

bool MainWindow::presentlyWantHBReplies() {
    return ui->actionModeAutoreply->isChecked() &&
           ui->actionHeartbeatAcknowledgements->isChecked() &&
           m_messageBuffer.isEmpty() &&
           (!m_config.heartbeat_qso_pause() ||
            m_prevSelectedCallsign.isEmpty());
}

void MainWindow::updateModeButtonText() {
    auto multi = ui->actionModeMultiDecoder->isChecked();
    auto autoreply = ui->actionModeAutoreply->isChecked();
    auto heartbeat =
        ui->actionModeJS8HB->isEnabled() && ui->actionModeJS8HB->isChecked();

    auto modeText = JS8::Submode::name(m_nSubMode);
    if (multi) {
        modeText += QString("+MULTI");
    }

    if (autoreply) {
        if (m_config.autoreply_confirmation()) {
            modeText += QString("+AUTO+CONF");
        } else {
            modeText += QString("+AUTO");
        }
    }

    if (heartbeat) {
        if (presentlyWantHBReplies()) {
            modeText += QString("+HB+ACK");
        } else {
            modeText += QString("+HB");
        }
    }

    ui->modeButton->setText(modeText);
}

void MainWindow::updateButtonDisplay() {
    bool isTransmitting = isMessageQueuedForTransmit();

    auto selectedCallsign = callsignSelected(true);
    bool emptyCallsign = selectedCallsign.isEmpty();
    bool emptyInfo = m_config.my_info().isEmpty();
    bool emptyStatus = m_config.my_status().isEmpty();

    bool previous_hbButtonisLongterm = m_hbButtonIsLongterm;
    m_hbButtonIsLongterm = false;
    ui->hbMacroButton->setDisabled(isTransmitting);
    m_hbButtonIsLongterm = previous_hbButtonisLongterm;

    bool previous_cqButtonisLongterm = m_cqButtonIsLongterm;
    m_cqButtonIsLongterm = false;
    ui->cqMacroButton->setDisabled(isTransmitting);
    m_cqButtonIsLongterm = previous_cqButtonisLongterm;

    ui->replyMacroButton->setDisabled(isTransmitting || emptyCallsign);
    ui->snrMacroButton->setDisabled(isTransmitting || emptyCallsign);
    ui->infoMacroButton->setDisabled(isTransmitting || emptyInfo);
    ui->statusMacroButton->setDisabled(isTransmitting || emptyStatus);
    ui->macrosMacroButton->setDisabled(isTransmitting);
    ui->queryButton->setDisabled(isTransmitting || emptyCallsign);
    ui->deselectButton->setDisabled(isTransmitting || emptyCallsign);
    ui->queryButton->setText(
        emptyCallsign ? "Directed"
                      : QString("Directed to %1").arg(selectedCallsign));

    // update mode button text
    updateModeButtonText();
}

void MainWindow::updateHBButtonDisplay() {
    if (m_hb_loop->isActive()) {
        QDateTime now = DriftingDateTime::currentDateTimeUtc();
        QDateTime nextHeartbeat = m_hb_loop->nextActivity();
        long secs = std::lround(now.msecsTo(nextHeartbeat) / 1000.0);

        QString hbBase = presentlyWantHBReplies() ? "HB + ACK" : "HB";

        if (secs > 0) {
            ui->hbMacroButton->setText(
                QString("%1 (%2)").arg(hbBase).arg(secs));
        } else {
            // Dead code?
            ui->hbMacroButton->setText(QString("%1 (now)").arg(hbBase));
        }
    } else {
        if (presentlyWantHBReplies()) {
            ui->hbMacroButton->setText("HB + ACK");
        } else {
            ui->hbMacroButton->setText("HB");
        }
    }
}

void MainWindow::updateCQButtonDisplay() {
    if (m_cq_loop->isActive()) {
        QDateTime now = DriftingDateTime::currentDateTimeUtc();
        QDateTime nextCQ = m_cq_loop->nextActivity();
        long secs = std::lround(now.msecsTo(nextCQ) / 1000.0);
        // qCDebug(mainwindow_js8)
        //         << "updateCQButtonDisplay, signal due at" << nextCQ
        //         << "so" << secs << "s to go";
        if (secs > 0) {
            ui->cqMacroButton->setText(QString("CQ (%1)").arg(secs));
        } else {
            // Dead code?
            ui->cqMacroButton->setText("CQ (now)");
        }
    } else {
        ui->cqMacroButton->setText("CQ");
        // qCDebug(mainwindow_js8) << "updateCQButtonDisplay while m_cq_loop is
        // off";
    }
}

void MainWindow::updateTextDisplay() {
    bool canTransmit = ensureCanTransmit();
    bool isTransmitting = isMessageQueuedForTransmit();
    bool emptyText = ui->extFreeTextMsgEdit->toPlainText().isEmpty();

    ui->startTxButton->setDisabled(!canTransmit || isTransmitting || emptyText);

    if (m_txTextDirty) {
        // debounce frame and word count
        if (m_txTextDirtyDebounce.isActive()) {
            m_txTextDirtyDebounce.stop();
        }
        m_txTextDirtyDebounce.setSingleShot(true);
        m_txTextDirtyDebounce.start(100);
        m_txTextDirty = false;
    }
}

#if __APPLE__
#define USE_SYNC_FRAME_COUNT 0
#else
#define USE_SYNC_FRAME_COUNT 0
#endif

void MainWindow::refreshTextDisplay() {
    qCDebug(mainwindow_js8) << "refreshing text display...";
    auto text = ui->extFreeTextMsgEdit->toPlainText();

#if USE_SYNC_FRAME_COUNT
    auto frames = buildMessageFrames(text);

    QStringList textList;
    qCDebug(mainwindow_js8) << "frames:";
    foreach (auto frame, frames) {
        auto dt = DecodedText(frame.first, frame.second);
        qCDebug(mainwindow_js8) << "->" << frame << dt.message()
                                << Varicode::frameTypeString(dt.frameType());
        textList.append(dt.message());
    }

    auto transmitText = textList.join("");
    auto count = frames.length();

    // ugh...i hate these globals
    m_txTextDirtyLastSelectedCall = callsignSelected(true);
    m_txTextDirtyLastText = text;
    m_txFrameCountEstimate = count;
    m_txTextDirty = false;

    updateTextWordCheckerDisplay();
    updateTextStatsDisplay(transmitText, count);
    updateTxButtonDisplay();

#else
    // prepare selected callsign for directed message
    QString selectedCall = callsignSelected();

    // prepare compound
    QString mycall = m_config.my_callsign();
    QString mygrid = m_config.my_grid().left(4);
    bool forceIdentify = !m_config.avoid_forced_identify();
    bool forceData = false;

    BuildMessageFramesThread *t =
        new BuildMessageFramesThread(mycall, mygrid, selectedCall, text,
                                     forceIdentify, forceData, m_nSubMode);

    connect(t, &BuildMessageFramesThread::finished, t, &QObject::deleteLater);
    connect(t, &BuildMessageFramesThread::resultReady, this,
            [this, text](QString transmitText, int frames) {
                // ugh...i hate these globals
                m_txTextDirtyLastSelectedCall = callsignSelected(true);
                m_txTextDirtyLastText = text;
                m_txFrameCountEstimate = frames;
                m_txTextDirty = false;

                updateTextWordCheckerDisplay();
                updateTextStatsDisplay(transmitText, m_txFrameCountEstimate);
                updateTxButtonDisplay();
            });
    t->start();
#endif
}

void MainWindow::updateTextWordCheckerDisplay() {
    if (!m_config.spellcheck()) {
        return;
    }

    JSCChecker::checkRange(ui->extFreeTextMsgEdit, 0, -1);
}

void MainWindow::updateTextStatsDisplay(QString text, int count) {
    const double fpm = 60.0 / m_TRperiod;
    if (count > 0) {
        auto words = text.split(" ", Qt::SkipEmptyParts).length();
        auto wpm = QString::number(words / (count / fpm), 'f', 1);
        auto cpm = QString::number(text.length() / (count / fpm), 'f', 1);
        wpm_label.setText(QString("%1wpm / %2cpm").arg(wpm).arg(cpm));
        wpm_label.setVisible(true);
    } else {
        wpm_label.setVisible(false);
        wpm_label.clear();
    }
}

void MainWindow::updateTxButtonDisplay() {
    // can we transmit at all?
    bool canTransmit = ensureCanTransmit();

    // if we're tuning or have a message queued
    if (m_tune || isMessageQueuedForTransmit()) {
        int count = m_txFrameCount;
        int left = m_txFrameQueue.count();
        int sent = count - left;
        QString buttonText;
        if (m_tune) {
            buttonText = State::Tuning.toString();
        } else if (m_transmitting) {
            buttonText =
                State::timed(State::Sending, ((left + 1) * m_TRperiod) -
                                                 ((m_sec0 + 1) % m_TRperiod));
        } else {
            buttonText = State::timed(
                State::Ready, sent == 1 ? ((left + 1) * m_TRperiod)
                                        : (((left + 2) * m_TRperiod) -
                                           ((m_sec0 + 1) % m_TRperiod)));
        }
        ui->startTxButton->setText(buttonText);
        ui->startTxButton->setEnabled(false);
        ui->startTxButton->setFlat(true);
    } else {
        QString const buttonText =
            m_txFrameCountEstimate > 0
                ? State::timed(State::Send, m_txFrameCountEstimate * m_TRperiod)
                : State::Send.toString();
        ui->startTxButton->setText(buttonText);
        ui->startTxButton->setEnabled(canTransmit &&
                                      m_txFrameCountEstimate > 0);
        ui->startTxButton->setFlat(false);
    }
}

QString MainWindow::callsignSelected(bool) {
    if (!ui->tableWidgetCalls->selectedItems().isEmpty()) {
        auto selectedCalls = ui->tableWidgetCalls->selectedItems();
        if (!selectedCalls.isEmpty()) {
            auto call = selectedCalls.first()->data(Qt::UserRole).toString();
            if (!call.isEmpty()) {
                return call;
            }
        }
    }

    if (!ui->tableWidgetRXAll->selectedItems().isEmpty()) {
        int selectedOffset = -1;
        auto selectedItems = ui->tableWidgetRXAll->selectedItems();
        selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt();

        int threshold = 0;
        auto activity = m_bandActivity.value(selectedOffset);
        if (!activity.isEmpty()) {
            threshold = JS8::Submode::rxThreshold(activity.last().submode);
        }

        auto keys = m_callActivity.keys();

        std::stable_sort(keys.begin(), keys.end(),
                         [this](QString const &lhs, QString const &rhs) {
                             auto const lhsTS =
                                 m_callActivity[lhs].utcTimestamp;
                             auto const rhsTS =
                                 m_callActivity[rhs].utcTimestamp;

                             return lhsTS == rhsTS ? lhs < rhs : rhsTS < lhsTS;
                         });

        // Return the first callsign at a frequency within the
        // threshold limit of the selected offset, if any.

        auto const offsetLo = selectedOffset - threshold;
        auto const offsetHi = selectedOffset + threshold;

        for (auto const &key : keys) {
            if (auto const &d = m_callActivity[key];
                offsetLo <= d.offset && d.offset <= offsetHi) {
                return d.call;
            }
        }
    }

#if ALLOW_USE_INPUT_TEXT_CALLSIGN
    if (useInputText) {
        auto text = ui->extFreeTextMsgEdit->toPlainText().left(
            11); // Maximum callsign is 6 + / + 4 = 11 characters
        auto calls = Varicode::parseCallsigns(text);
        if (!calls.isEmpty() && text.startsWith(calls.first()) &&
            calls.first() != m_config.my_callsign()) {
            return calls.first();
        }
    }
#endif

    return QString();
}

void MainWindow::callsignSelectedChanged(QString /*old*/,
                                         QString selectedCall) {
    auto placeholderText =
        QString("Type your outgoing messages here.").toUpper();
    if (selectedCall.isEmpty()) {
        // try to restore hb
        if (m_hbPaused) {
            ui->hbMacroButton->setChecked(true);
            m_hbPaused = false;
        }
    } else {
        placeholderText =
            QString("Type your outgoing directed message to %1 here.")
                .arg(selectedCall)
                .toUpper();

        // when we select a callsign, use it as the qso start time
        if (!m_callSelectedTime.contains(selectedCall)) {
            m_callSelectedTime[selectedCall] =
                DriftingDateTime::currentDateTimeUtc();
        }

        if (m_config.heartbeat_qso_pause()) {
            // TODO: jsherer - HB issue
            // don't hb if we select a callsign... (but we should keep track so
            // if we deselect, we restore our hb)
            if (ui->hbMacroButton->isChecked()) {
                qCDebug(mainwindow_js8)
                    << "Unchecking hbMacroButton after selection"
                    << selectedCall << "but planning to resurrect later";
                ui->hbMacroButton->setChecked(false);
                m_hbPaused = true;
            }

            // don't cq if we select a callsign... (and it will not be restored
            // otherwise)
            if (ui->cqMacroButton->isChecked()) {
                qCDebug(mainwindow_js8)
                    << "Unchecking cqMacroButton after selection"
                    << selectedCall;
                ui->cqMacroButton->setChecked(false);
            }
        }
    }
    ui->extFreeTextMsgEdit->setPlaceholderText(placeholderText);

#if SHOW_CALL_DETAIL_BROWSER
    auto html = generateCallDetail(selectedCall);
    ui->callDetailTextBrowser->setHtml(html);
    ui->callDetailTextBrowser->setVisible(
        !selectedCall.isEmpty() && (!hearing.isEmpty() || !heardby.isEmpty()));
#endif

    m_prevSelectedCallsign = selectedCall;

    // immediately update the display
    updateButtonDisplay();
    updateTextDisplay();
    statusChanged();
}

void MainWindow::clearCallsignSelected() {
    // remove the date cache
    m_callSelectedTime.remove(m_prevSelectedCallsign);

    // remove the callsign selection
    ui->tableWidgetCalls->clearSelection();
    ui->tableWidgetRXAll->clearSelection();
}

bool MainWindow::isRecentOffset(int submode, int offset) {
    if (abs(offset - freq()) <= JS8::Submode::rxThreshold(submode)) {
        return true;
    }
    return (m_rxRecentCache.contains(offset / 10 * 10) &&
            m_rxRecentCache[offset / 10 * 10]->secsTo(
                DriftingDateTime::currentDateTimeUtc()) < 120);
}

void MainWindow::markOffsetRecent(int offset) {
    m_rxRecentCache.insert(
        offset / 10 * 10, new QDateTime(DriftingDateTime::currentDateTimeUtc()),
        10);
    m_rxRecentCache.insert(
        offset / 10 * 10 + 10,
        new QDateTime(DriftingDateTime::currentDateTimeUtc()), 10);
}

bool MainWindow::isDirectedOffset(int offset, bool *pIsAllCall) {
    bool isDirected = (m_rxDirectedCache.contains(offset / 10 * 10) &&
                       m_rxDirectedCache[offset / 10 * 10]->date.secsTo(
                           DriftingDateTime::currentDateTimeUtc()) < 120);

    if (isDirected && pIsAllCall) {
        *pIsAllCall = m_rxDirectedCache[offset / 10 * 10]->isAllcall;
    }

    return isDirected;
}

void MainWindow::markOffsetDirected(int offset, bool isAllCall) {
    CachedDirectedType *d1 = new CachedDirectedType{
        isAllCall, DriftingDateTime::currentDateTimeUtc()};
    CachedDirectedType *d2 = new CachedDirectedType{
        isAllCall, DriftingDateTime::currentDateTimeUtc()};
    m_rxDirectedCache.insert(offset / 10 * 10, d1, 10);
    m_rxDirectedCache.insert(offset / 10 * 10 + 10, d2, 10);
}

void MainWindow::clearOffsetDirected(int offset) {
    m_rxDirectedCache.remove(offset / 10 * 10);
    m_rxDirectedCache.remove(offset / 10 * 10 + 10);
}

bool MainWindow::isMyCallIncluded(const QString &text) {
    QString myCall = Radio::base_callsign(m_config.my_callsign());

    if (myCall.isEmpty()) {
        return false;
    }

    if (!text.contains(myCall)) {
        return false;
    }

    auto calls = Varicode::parseCallsigns(text);
    return calls.contains(myCall) || calls.contains(m_config.my_callsign());
}

bool MainWindow::isAllCallIncluded(const QString &text) {
    return text.contains("@ALLCALL") || text.contains("@HB");
}

bool MainWindow::isGroupCallIncluded(const QString &text) {
    return m_config.my_groups().contains(text);
}

void MainWindow::processActivity(bool force) {
    if (!m_rxDirty && !force) {
        return;
    }

    // Recent Rx Activity
    processRxActivity();

    // Process Idle Activity
    processIdleActivity();

    // Grouped Compound Activity
    processCompoundActivity();

    // Buffered Activity
    processBufferedActivity();

    // Command Activity
    processCommandActivity();

    // Process PSKReporter Spots
    processSpots();

    m_rxDirty = false;
}

void MainWindow::resetTimeDeltaAverage() {
    m_driftMsMMA = 0;
    m_driftMsMMA_N = 0;
}

void MainWindow::setDrift(int n) { DriftingDateTime::setDrift(n); }

void MainWindow::processIdleActivity() {
    auto const now = DriftingDateTime::currentDateTimeUtc();

    // if we detect an idle offset, insert an ellipsis into the activity queue
    // and band activity

    for (auto [offset, activity] : m_bandActivity.asKeyValueRange()) {
        if (activity.isEmpty())
            continue;

        auto const last = activity.last();

        if ((last.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast)
            continue;
        if (last.text == m_config.mfi())
            continue;
        if (last.utcTimestamp.secsTo(now) <
            JS8::Submode::period(last.submode) * 1.50)
            continue;

        ActivityDetail d = {};
        d.text = m_config.mfi();
        d.utcTimestamp = last.utcTimestamp;
        d.snr = last.snr;
        d.tdrift = last.tdrift;
        d.dial = last.dial;
        d.offset = last.offset;
        d.submode = last.submode;

        if (hasExistingMessageBuffer(d.submode, offset, false, nullptr)) {
            m_messageBuffer[offset].msgs.append(d);
        }

        m_rxActivityQueue.append(d);
        activity.append(d);
    }
}

void MainWindow::processRxActivity() {
    if (m_rxActivityQueue.isEmpty()) {
        return;
    }

    int freqOffset = freq();

    qCDebug(mainwindow_js8)
        << m_messageBuffer.count() << "message buffers open";

    while (!m_rxActivityQueue.isEmpty()) {
        ActivityDetail d = m_rxActivityQueue.dequeue();

        if (canSendNetworkMessage()) {
            sendNetworkMessage(
                "RX.ACTIVITY", d.text,
                {{"_ID", QVariant(-1)},
                 {"FREQ", QVariant(d.dial + d.offset)},
                 {"DIAL", QVariant(d.dial)},
                 {"OFFSET", QVariant(d.offset)},
                 {"SNR", QVariant(d.snr)},
                 {"SPEED", QVariant(d.submode)},
                 {"TDRIFT", QVariant(d.tdrift)},
                 {"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}});
        }

        // use the actual frequency and check its delta from our current
        // frequency meaning, if our current offset is 1502 and the d.freq is
        // 1492, the delta is <= 10;
        bool shouldDisplay =
            abs(d.offset - freqOffset) <= JS8::Submode::rxThreshold(d.submode);

        int prevOffset = d.offset;
        if (hasExistingMessageBuffer(d.submode, d.offset, false, &prevOffset) &&
            ((m_messageBuffer[prevOffset].cmd.to == m_config.my_callsign()) ||
             // (isAllCallIncluded(m_messageBuffer[prevOffset].cmd.to))     ||
             // // uncomment this if we want to incrementally print allcalls
             (isGroupCallIncluded(m_messageBuffer[prevOffset].cmd.to)))) {
            d.isBuffered = true;
            shouldDisplay = true;

            if (!m_messageBuffer[prevOffset].compound.isEmpty()) {
                // qCDebug(mainwindow_js8) << "should display compound too
                // because at this point it hasn't been displayed" <<
                // m_messageBuffer[prevOffset].compound.last().call;

                auto lastCompound = m_messageBuffer[prevOffset].compound.last();

                // fixup compound call incremental text
                d.text = QString("%1: %2").arg(lastCompound.call).arg(d.text);
                d.utcTimestamp =
                    qMin(d.utcTimestamp, lastCompound.utcTimestamp);
            }

        } else if (hasClosedExistingMessageBuffer(d.offset)) {
            // incremental typeahead should just be displayed...
            // TODO: should the buffer be reopened?
            shouldDisplay = true;

        } else if (d.isDirected && d.text.contains("<....>")) {
            // if this is a _partial_ directed message, skip until the complete
            // call comes through.
            continue;

        } else if (d.isDirected &&
                   (d.text.contains(": HB ") ||
                    d.text.contains(": @ALLCALL HB"))) { // TODO: HEARTBEAT
            // if this is a heartbeat, process elsewhere...
            continue;
        }

        // if this is the first data frame of a standard message, parse the
        // first word callsigns and spot them :)
        if ((d.bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst &&
            !d.isDirected && !d.isCompound) {
            auto calls = Varicode::parseCallsigns(d.text);
            if (!calls.isEmpty()) {
                auto theirCall = calls.first();
                if (d.text.startsWith(theirCall) &&
                    d.text.mid(theirCall.length(), 1) == ":") {
                    CallDetail cd = {};
                    cd.call = theirCall;
                    cd.dial = d.dial;
                    cd.offset = d.offset;
                    cd.snr = d.snr;
                    cd.bits = d.bits;
                    cd.tdrift = d.tdrift;
                    cd.utcTimestamp = d.utcTimestamp;
                    cd.submode = d.submode;
                    logCallActivity(cd, true);
                }
            }
        }

        // TODO: incremental printing of directed messages
        // Display if:
        // 1) this is a directed message header "to" us and should be
        // buffered... 2) or, this is a buffered message frame for a buffer with
        // us as the recipient.

        if (!shouldDisplay) {
            continue;
        }

        bool isFirst =
            (d.bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst;
        bool isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast;

        // if we're the last message, let's display our EOT character
        if (isLast) {
            d.text = QString("%1 %2 ")
                         .arg(Varicode::rstrip(d.text))
                         .arg(m_config.eot());
        }

        // log it to the display!
        displayTextForFreq(d.text, d.offset, d.utcTimestamp, false, isFirst,
                           isLast);

        // If we've received a message to be displayed, we should no longer call
        // CQ.
        if (m_cq_loop->isActive()) {
            qCDebug(mainwindow_js8)
                << "Canceling calling CQ loop to priorize incoming messages.";
            m_cq_loop->onLoopCancel();
        }

        if (isLast) {
            clearOffsetDirected(d.offset);
        }

        if (isLast && !d.isBuffered) {
            // buffered commands need the rxFrameBlockNumbers cache so it can
            // fixup its display all other "last" data frames can clear the
            // rxFrameBlockNumbers cache so the next message will be on a new
            // line.
            m_rxFrameBlockNumbers.remove(d.offset);
        }
    }

#if 0
    // TODO: this works but should also print in the rx window.
    foreach(auto offset, m_bandActivity.keys()){
        if(seen.contains(offset)){
            continue;
        }

        if(m_bandActivity[offset].isEmpty()){
            continue;
        }

        auto last = m_bandActivity[offset].last();
        if((last.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast){
            continue;
        }

        auto now = DriftingDateTime::currentDateTimeUtc();
        if(last.utcTimestamp.secsTo(now) < m_TRperiod){
            continue;
        }

        ActivityDetail d = {};
        d.text = " . . . ";
        d.utcTimestamp = now;
        d.snr = -99;

        m_bandActivity[offset].append(d);
    }
#endif
}

void MainWindow::processCompoundActivity() {
    if (m_messageBuffer.isEmpty()) {
        return;
    }

    // group compound callsign and directed commands together.
    foreach (auto freq, m_messageBuffer.keys()) {

        auto &buffer = m_messageBuffer[freq];

        qCDebug(mainwindow_js8) << "-> grouping buffer for freq" << freq;

        if (buffer.compound.isEmpty()) {
            qCDebug(mainwindow_js8) << "-> buffer.compound is empty...skip";
            continue;
        }

        // if we don't have an initialized command, skip...
        int bits = buffer.cmd.bits;
        bool validBits =
            (bits == Varicode::JS8Call ||
             ((bits & Varicode::JS8CallFirst) == Varicode::JS8CallFirst) ||
             ((bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) ||
             ((bits & Varicode::JS8CallData) == Varicode::JS8CallData));
        if (!validBits) {
            qCDebug(mainwindow_js8) << "-> buffer.cmd bits is invalid...skip";
            continue;
        }

        // if we need two compound calls, but less than two have arrived...skip
        if (buffer.cmd.from == "<....>" && buffer.cmd.to == "<....>" &&
            buffer.compound.length() < 2) {
            qCDebug(mainwindow_js8)
                << "-> buffer needs two compound, but has less...skip";
            continue;
        }

        // if we need one compound call, but non have arrived...skip
        if ((buffer.cmd.from == "<....>" || buffer.cmd.to == "<....>") &&
            buffer.compound.length() < 1) {
            qCDebug(mainwindow_js8)
                << "-> buffer needs one compound, but has less...skip";
            continue;
        }

        if (buffer.cmd.from == "<....>") {
            auto d = buffer.compound.dequeue();
            buffer.cmd.from = d.call;
            buffer.cmd.grid = d.grid;
            buffer.cmd.isCompound = true;
            buffer.cmd.utcTimestamp =
                qMin(buffer.cmd.utcTimestamp, d.utcTimestamp);

            if ((d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) {
                buffer.cmd.bits = d.bits;
            }
        }

        if (buffer.cmd.to == "<....>") {
            auto d = buffer.compound.dequeue();
            buffer.cmd.to = d.call;
            buffer.cmd.isCompound = true;
            buffer.cmd.utcTimestamp =
                qMin(buffer.cmd.utcTimestamp, d.utcTimestamp);

            if ((d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast) {
                buffer.cmd.bits = d.bits;
            }
        }

        if ((buffer.cmd.bits & Varicode::JS8CallLast) !=
            Varicode::JS8CallLast) {
            qCDebug(mainwindow_js8) << "-> still not last message...skip";
            continue;
        }

        // fixup the datetime with the "minimum" dt seen
        // this will allow us to delete the activity lines
        // when the compound buffered command comes in.
        auto dt = buffer.cmd.utcTimestamp;
        foreach (auto c, buffer.compound) {
            dt = qMin(dt, c.utcTimestamp);
        }
        foreach (auto m, buffer.msgs) {
            dt = qMin(dt, m.utcTimestamp);
        }
        buffer.cmd.utcTimestamp = dt;

        qCDebug(mainwindow_js8)
            << "buffered compound command ready" << buffer.cmd.from
            << buffer.cmd.to << buffer.cmd.cmd;

        m_rxCommandQueue.append(buffer.cmd);
        m_messageBuffer.remove(freq);

        // TODO: only if to me?
        m_lastClosedMessageBufferOffset = freq;
    }
}

void MainWindow::processBufferedActivity() {
    if (m_messageBuffer.isEmpty())
        return;

    foreach (auto freq, m_messageBuffer.keys()) {
        auto buffer = m_messageBuffer[freq];

        // check to make sure we empty old buffers by getting the latest
        // timestamp and checking to see if it's older than one minute.
        auto dt = DriftingDateTime::currentDateTimeUtc().addDays(-1);
        if (buffer.cmd.utcTimestamp.isValid()) {
            dt = qMax(dt, buffer.cmd.utcTimestamp);
        }
        if (!buffer.compound.isEmpty()) {
            dt = qMax(dt, buffer.compound.last().utcTimestamp);
        }
        if (!buffer.msgs.isEmpty()) {
            dt = qMax(dt, buffer.msgs.last().utcTimestamp);
        }

        // if the buffer has messages older than 1 minute, and we still haven't
        // closed it, let's mark it as the last frame
        if (dt.secsTo(DriftingDateTime::currentDateTimeUtc()) > 60 &&
            !buffer.msgs.isEmpty()) {
            buffer.msgs.last().bits |= Varicode::JS8CallLast;
        }

        // but, if the buffer is older than 1.5 minutes, and we still haven't
        // closed it, just remove it and skip
        if (dt.secsTo(DriftingDateTime::currentDateTimeUtc()) > 90) {
            m_messageBuffer.remove(freq);
            continue;
        }

        // if the buffer has no messages, skip
        if (buffer.msgs.isEmpty()) {
            continue;
        }

        // if the buffered message hasn't seen the last message, skip
        if ((buffer.msgs.last().bits & Varicode::JS8CallLast) !=
            Varicode::JS8CallLast) {
            continue;
        }

        QString message;
        foreach (auto part, buffer.msgs) {
            message.append(part.text);
        }
        message = Varicode::rstrip(message);

        QString checksum;

        bool valid = false;

        if (Varicode::isCommandBuffered(buffer.cmd.cmd)) {
            int checksumSize = Varicode::isCommandChecksumed(buffer.cmd.cmd);

            if (checksumSize == 32) {
                message = Varicode::lstrip(message);
                checksum = message.right(6);
                message = message.left(message.length() - 7);
                valid = Varicode::checksum32Valid(checksum, message);
            } else if (checksumSize == 16) {
                message = Varicode::lstrip(message);
                checksum = message.right(3);
                message = message.left(message.length() - 4);
                valid = Varicode::checksum16Valid(checksum, message);
            } else if (checksumSize == 0) {
                valid = true;
            }
        } else {
            valid = true;
        }

        if (valid) {
            buffer.cmd.bits |= Varicode::JS8CallLast;
            buffer.cmd.text = message;
            buffer.cmd.isBuffered = true;
            m_rxCommandQueue.append(buffer.cmd);
        } else {
            qCDebug(mainwindow_js8)
                << "Buffered message failed checksum...discarding";
            qCDebug(mainwindow_js8) << "Checksum:" << checksum;
            qCDebug(mainwindow_js8) << "Message:" << message;
        }

        // regardless of valid or not, remove the "complete" buffered message
        // from the buffer cache
        m_messageBuffer.remove(freq);
        m_lastClosedMessageBufferOffset = freq;
    }
}

void MainWindow::processCommandActivity() {
#if 0
    if (!m_txFrameQueue.isEmpty()) {
        return;
    }
#endif

    if (m_rxCommandQueue.isEmpty()) {
        return;
    }

#if 0
    bool processed = false;

    int f = currentFreq();
#endif

    auto now = DriftingDateTime::currentDateTimeUtc();

    while (!m_rxCommandQueue.isEmpty()) {
        auto d = m_rxCommandQueue.dequeue();

        auto selectedCallsign = callsignSelected();
        bool isAllCall = isAllCallIncluded(d.to);
        bool isGroupCall = isGroupCallIncluded(d.to);

        qCDebug(mainwindow_js8)
            << "try processing command" << d.from << d.to << d.cmd << d.dial
            << d.offset << d.grid << d.extra << isAllCall << isGroupCall;

        // if we need a compound callsign but never got one...skip
        if (d.from == "<....>" || d.to == "<....>") {
            continue;
        }

        // we're only processing a subset of queries at this point
        if (!Varicode::isCommandAllowed(d.cmd)) {
            continue;
        }

        // is this to me?
        bool toMe =
            d.to == m_config.my_callsign().trimmed() ||
            d.to == Radio::base_callsign(m_config.my_callsign()).trimmed();

        // log call activity...
        CallDetail cd = {};
        cd.call = d.from;
        cd.grid = d.grid;
        cd.snr = d.snr;
        cd.dial = d.dial;
        cd.offset = d.offset;
        cd.bits = d.bits;
        cd.ackTimestamp =
            d.text.contains(": ACK") || toMe ? d.utcTimestamp : QDateTime{};
        cd.utcTimestamp = d.utcTimestamp;
        cd.tdrift = d.tdrift;
        cd.submode = d.submode;
        logCallActivity(cd, true);
        logHeardGraph(d.from, d.to);

        // Placeholder for callbacks to be passed to confirmThenEnqueueMessage
        // and enqueueMessage
        Callback callback = nullptr;

        // PROCESS BUFFERED HEARING FOR EVERYONE
        if (d.cmd == " HEARING") {
            // 1. parse callsigns
            // 2. log it to the heard graph
            auto calls = Varicode::parseCallsigns(d.text);
            foreach (auto call, calls) {
                logHeardGraph(d.from, call);
            }
        }

        // PROCESS BUFFERED GRID FOR EVERYONE
        if (d.cmd == " GRID") {
            // 1. parse grids
            // 2. log it to our call activity
            auto grids = Varicode::parseGrids(d.text);
            foreach (auto grid, grids) {
                CallDetail cd = {};
                cd.bits = d.bits;
                cd.call = d.from;
                cd.dial = d.dial;
                cd.offset = d.offset;
                cd.grid = grid;
                cd.snr = d.snr;
                cd.utcTimestamp = d.utcTimestamp;
                cd.tdrift = d.tdrift;
                cd.submode = d.submode;

                // PROCESS GRID SPOTS TO APRSIS FOR EVERYONE
                if (d.to == "@APRSIS") {
                    spotAprsGrid(cd.dial, cd.offset, cd.snr, cd.call, cd.grid);
                }

                logCallActivity(cd, true);
            }
        }

        // PROCESS @JS8NET, @APRSIS, AND OTHER GROUP SPOTS FOR EVERYONE
        if (d.to.startsWith("@")) {
            spotCmd(d);
        }

        // PROCESS @APRSIS CMD SPOTS FOR EVERYONE
        if (d.to == "@APRSIS") {
            spotAprsCmd(d);
        }

        // PREPARE CMD TEXT STRING
        QStringList textList = {
            QString("%1: %2%3").arg(d.from).arg(d.to).arg(d.cmd)};

        if (!d.extra.isEmpty()) {
            textList.append(d.extra);
        }

        if (!d.text.isEmpty()) {
            textList.append(d.text);
        }

        QString text = textList.join(" ");
        bool isLast = (d.bits & Varicode::JS8CallLast) == Varicode::JS8CallLast;
        if (isLast) {
            // append the eot character to the text
            text = QString("%1 %2 ")
                       .arg(Varicode::rstrip(text))
                       .arg(m_config.eot());
        }

        // log the text to directed txt log
        writeMsgTxt(text, d.snr, d.offset);

        // write all directed messages to api
        if (canSendNetworkMessage()) {
            sendNetworkMessage(
                "RX.DIRECTED", text,
                {{"_ID", QVariant(-1)},
                 {"FROM", QVariant(d.from)},
                 {"TO", QVariant(d.to)},
                 {"CMD", QVariant(d.cmd)},
                 {"GRID", QVariant(d.grid)},
                 {"EXTRA", QVariant(d.extra)},
                 {"TEXT", QVariant(text)},
                 {"FREQ", QVariant(d.dial + d.offset)},
                 {"DIAL", QVariant(d.dial)},
                 {"OFFSET", QVariant(d.offset)},
                 {"SNR", QVariant(d.snr)},
                 {"SPEED", QVariant(d.submode)},
                 {"TDRIFT", QVariant(d.tdrift)},
                 {"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}});
        }

        // we're only responding to allcalls if we are participating in the
        // allcall group but, don't avoid for heartbeats...those are technically
        // allcalls but are processed differently
        if (isAllCall && m_config.avoid_allcall() && d.cmd != " CQ" &&
            d.cmd != " HB" && d.cmd != " HEARTBEAT") {
            continue;
        }

        // we're only responding to allcall, groupcalls, and our callsign at
        // this point, so we'll end after logging the callsigns we've heard
        if (!isAllCall && !toMe && !isGroupCall) {
            continue;
        }

        ActivityDetail ad = {};
        ad.isLowConfidence = false;
        ad.isDirected = true;
        ad.bits = d.bits;
        ad.dial = d.dial;
        ad.offset = d.offset;
        ad.snr = d.snr;
        ad.text = text;
        ad.utcTimestamp = d.utcTimestamp;

        // we'd be double printing here if were on frequency, so let's be
        // "smart" about this...
        bool shouldDisplay = true;

        // don't display ping allcalls
        if (isAllCall && (d.cmd != " " || ad.text.contains("@HB HEARTBEAT"))) {
            shouldDisplay = false;
        }

        if (shouldDisplay) {
            auto c = ui->textEditRX->textCursor();
            c.movePosition(QTextCursor::End);
            ui->textEditRX->setTextCursor(c);

            // ACKs and SNRs are the most likely source of items to be
            // overwritten (multiple responses at once)... so don't overwrite
            // those (i.e., print each on a new line)
            bool shouldOverwrite =
                (!d.cmd.contains(" ACK") &&
                 !d.cmd.contains(" SNR")); /* && isRecentOffset(d.freq);*/

            if (shouldOverwrite &&
                ui->textEditRX->find(d.utcTimestamp.time().toString(),
                                     QTextDocument::FindBackward)) {
                // ... maybe we could delete the last line that had this message
                // on this frequency...
                c = ui->textEditRX->textCursor();
                c.movePosition(QTextCursor::StartOfBlock);
                c.movePosition(QTextCursor::EndOfBlock,
                               QTextCursor::KeepAnchor);
                qCDebug(mainwindow_js8) << "should display directed message, "
                                           "erasing last rx activity line..."
                                        << c.selectedText().toUpper();
                c.removeSelectedText();
                c.deletePreviousChar();
                c.deletePreviousChar();
                /*
                c.deleteChar();
                c.deleteChar();
                */
            }

            // log it to the display!
            displayTextForFreq(ad.text, ad.offset, ad.utcTimestamp, false, true,
                               false);

            /*
            // and send it to the network in case we want to interact with it
            from an external app... if(canSendNetworkMessage()){
                sendNetworkMessage("RX.DIRECTED.ME", ad.text, {
                    {"_ID", QVariant(-1)},
                    {"FROM", QVariant(d.from)},
                    {"TO", QVariant(d.to)},
                    {"CMD", QVariant(d.cmd)},
                    {"GRID", QVariant(d.grid)},
                    {"EXTRA", QVariant(d.extra)},
                    {"TEXT", QVariant(d.text)},
                    {"FREQ", QVariant(ad.dial+ad.offset)},
                    {"DIAL", QVariant(ad.dial)},
                    {"OFFSET", QVariant(ad.offset)},
                    {"SNR", QVariant(ad.snr)},
                    {"SPEED", QVariant(ad.submode)},
                    {"TDRIFT", QVariant(ad.tdrift)},
                    {"UTC", QVariant(ad.utcTimestamp.toMSecsSinceEpoch())}
                });
            }
            */

            if (!isAllCall) {
                // If we've received a message to be displayed,
                // we should no longer call CQ
                if (m_cq_loop->isActive()) {
                    qCDebug(mainwindow_js8)
                        << "Canceling CQ loop to prioritize incoming messages, "
                           "case II";
                    m_cq_loop->onLoopCancel();
                }

                // notification for directed message
                tryNotify("directed");
            }
        }

        // we're only responding to callsigns in our whitelist if we have one
        // defined... make sure the whitelist is empty (no restrictions) or the
        // from callsign or its base callsign is on it
        auto whitelist = m_config.auto_whitelist();
        if (!whitelist.isEmpty() &&
            !(whitelist.contains(d.from) ||
              whitelist.contains(Radio::base_callsign(d.from)))) {
            qCDebug(mainwindow_js8)
                << "skipping command for whitelist" << d.from;
            continue;
        }

        // we'll never reply to a blacklisted callsign or base callsign
        auto blacklist = m_config.auto_blacklist();
        if (!blacklist.isEmpty() &&
            (blacklist.contains(d.from) ||
             blacklist.contains(Radio::base_callsign(d.from)))) {
            qCDebug(mainwindow_js8)
                << "skipping command for blacklist" << d.from;
            continue;
        }

        // if this is an allcall, check to make sure we haven't replied to their
        // allcall recently (in the past ten minutes) that way we never get
        // spammed by allcalls at too high of a frequency
        if (isAllCall && m_txAllcallCommandCache.contains(d.from) &&
            m_txAllcallCommandCache[d.from]->secsTo(now) / 60 < 15) {
            qCDebug(mainwindow_js8)
                << "skipping command for allcall timeout" << d.from;
            continue;
        }

        // don't actually process any automatic message replies while in idle
        if (m_tx_watchdog) {
            qCDebug(mainwindow_js8)
                << "skipping command for idle timeout" << d.from;
            continue;
        }

        // HACK: if this is an autoreply cmd and relay path is populated and cmd
        // is not MSG or MSG TO:, then swap out the relay path
        if (Varicode::isCommandAutoreply(d.cmd) && !d.relayPath.isEmpty() &&
            !d.cmd.startsWith(" MSG") && !d.cmd.startsWith(" QUERY")) {
            d.from = d.relayPath;
        }

        // construct a reply, if needed
        QString reply;
        int priority = PriorityNormal;
        int freq = -1;

        // QUERIED SNR
        if (d.cmd == " SNR?" && !isAllCall) {
            reply = QString("%1 SNR %2")
                        .arg(d.from)
                        .arg(Varicode::formatSNR(d.snr));
        }

        // QUERIED INFO
        else if (d.cmd == " INFO?" && !isAllCall) {
            QString info = m_config.my_info();
            if (info.isEmpty()) {
                continue;
            }

            reply = QString("%1 INFO %2")
                        .arg(d.from)
                        .arg(replaceMacros(info, buildMacroValues(), true));
        }

        // QUERIED ACTIVE
        else if (d.cmd == " STATUS?" && !isAllCall) {
            QString status = m_config.my_status();
            if (status.isEmpty()) {
                continue;
            }

            reply = QString("%1 STATUS %2")
                        .arg(d.from)
                        .arg(replaceMacros(status, buildMacroValues(), true));
        }

        // QUERIED GRID
        else if (d.cmd == " GRID?" && !isAllCall) {
            QString grid = m_config.my_grid();
            if (grid.isEmpty()) {
                continue;
            }

            reply = QString("%1 GRID %2").arg(d.from).arg(grid);
        }

        // QUERIED STATIONS HEARD
        else if (d.cmd == " HEARING?" && !isAllCall) {
            auto calls = m_callActivity.keys();

            std::stable_sort(calls.begin(), calls.end(),
                             [this](QString const &lhs, QString const &rhs) {
                                 return m_callActivity[rhs].utcTimestamp <
                                        m_callActivity[lhs].utcTimestamp;
                             });

            auto const callsignAging = m_config.callsign_aging();
            auto const maxStations = 4;
            auto i = 0;
            QStringList lines;

            for (auto const &call : calls) {
                if (i >= maxStations)
                    break;
                if (call == d.from)
                    continue;

                auto const &cd = m_callActivity[call];

                if (callsignAging &&
                    cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
                    continue;
                }

                lines.append(cd.call);
                i++;
            }

            lines.prepend(QString("%1 HEARING").arg(d.from));
            reply = lines.join(' ');
        }

        // PROCESS RELAY
        else if (d.cmd == ">" && !isAllCall && !m_config.relay_off()) {

            // 1. see if there are any more hops to process
            // 2. if so, forward
            // 3. otherwise, display alert & reply dialog

            QString callToPattern = {
                R"(^(?<callsign>\b(?<prefix>[A-Z0-9]{1,4}\/)?(?<base>([0-9A-Z])?([0-9A-Z])([0-9])([A-Z])?([A-Z])?([A-Z])?)(?<suffix>\/[A-Z0-9]{1,4})?(?<type>[> ]))\b)"};
            QRegularExpression re(callToPattern);
            auto text = d.text;
            auto match = re.match(text);

            // if the text starts with a callsign, and relay is not disabled,
            // and this is not a group callsign, then relay.
            if (match.hasMatch() && !isGroupCall) {
                // replace freetext with relayed free text
                if (match.captured("type") != ">") {
                    text = text.replace(match.capturedStart("type"),
                                        match.capturedLength("type"), ">");
                }
                reply = QString("%1 *DE* %2").arg(text).arg(d.from);

                // otherwise, as long as we're not an ACK...alert the user and
                // either send an ACK or Message
            } else if (!d.text.startsWith("ACK")) {

                // parse out the callsign path
                auto calls = parseRelayPathCallsigns(d.from, d.text);

                // put these third party calls in the heard list
                foreach (auto call, calls) {
                    CallDetail cd = {};
                    cd.call = call;
                    cd.snr = -64;
                    cd.dial = d.dial;
                    cd.offset = d.offset;
                    cd.through = d.from;
                    cd.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
                    cd.tdrift = d.tdrift;
                    cd.submode = d.submode;
                    logCallActivity(cd, false);
                }

                d.relayPath = calls.join('>');

                reply = QString("%1 ACK").arg(d.relayPath);

                // check to see if the relay text contains a command that should
                // be replied to instead of an ack.
                QStringList relayedCmds = d.text.split(" ");
                if (!relayedCmds.isEmpty()) {
                    auto first = relayedCmds.first();

                    auto valid = Varicode::isCommandAllowed(first);
                    if (!valid) {
                        first = " " + first;
                        valid = Varicode::isCommandAllowed(first);
                        if (valid) {
                            relayedCmds.removeFirst();
                        }
                    }

                    // HACK: "MSG TO:" should be supported but contains a space
                    // :(
                    if (!relayedCmds.isEmpty()) {
                        if (first == " MSG") {
                            auto second = relayedCmds.first();
                            if (second == "TO:") {
                                first = " MSG TO:";
                                relayedCmds.removeFirst();
                            } else if (second.startsWith("TO:")) {
                                first = " MSG TO:";
                                relayedCmds.replace(0, second.mid(3));
                            }
                        } else if (first == " QUERY") {
                            auto second = relayedCmds.first();
                            if (second == "MSGS" || second == "MSGS?") {
                                first = " QUERY MSGS";
                                relayedCmds.removeFirst();
                            } else if (second == "CALL") {
                                first = " QUERY CALL";
                                relayedCmds.removeFirst();
                            }
                        }
                    }

                    if (Varicode::isCommandAllowed(first) &&
                        Varicode::isCommandAutoreply(first)) {
                        CommandDetail rd = {};
                        rd.bits = d.bits;
                        rd.cmd = first;
                        rd.dial = d.dial;
                        rd.offset = d.offset;
                        rd.from =
                            d.from; // note, MSG and QUERY commands are not set
                                    // with from as the relay path.
                        rd.relayPath = d.relayPath;
                        rd.text = relayedCmds.join(" "); // d.text;
                        rd.to = d.to;
                        rd.utcTimestamp = d.utcTimestamp;

                        m_rxCommandQueue.insert(0, rd);
                        continue;
                    }
                }

#if STORE_RELAY_MSGS_TO_INBOX
                // if we make it here, this is a message
                addCommandToMyInbox(d);
#endif
            }
        }

        // PROCESS MESSAGE STORAGE
        else if (d.cmd == " MSG TO:" && !isAllCall && !m_config.relay_off()) {

            // store message
            QStringList segs = d.text.split(" ");
            if (segs.isEmpty()) {
                continue;
            }

            auto to = segs.first();
            segs.removeFirst();

            auto text = segs.join(" ").trimmed();

            auto calls = parseRelayPathCallsigns(d.from, text);
            d.relayPath = calls.join(">");

            CommandDetail cd = {};
            cd.bits = d.bits;
            cd.cmd = d.cmd;
            cd.extra = d.extra;
            cd.dial = d.dial;
            cd.offset = d.offset;
            cd.from = d.from;
            cd.grid = d.grid;
            cd.relayPath = d.relayPath;
            cd.snr = d.snr;
            cd.tdrift = d.tdrift;
            cd.text = text;
            cd.to = Radio::base_callsign(to);
            cd.utcTimestamp = d.utcTimestamp;
            cd.submode = d.submode;

            qCDebug(mainwindow_js8)
                << "storing message to" << to << ":" << text;

            addCommandToStorage("STORE", cd);

            // we haven't replaced the from with the relay path, so we have to
            // use it for the ack if there is one
            reply = QString("%1 ACK").arg(calls.length() > 1 ? d.relayPath
                                                             : d.from);
        }

        // PROCESS AGN
        else if (d.cmd == " AGN?" && !isAllCall && !isGroupCall &&
                 !m_lastTxMessage.isEmpty()) {
            reply = Varicode::rstrip(m_lastTxMessage);
        }

        // PROCESS ACTIVE HEARTBEAT
        // if we have hb mode enabled and auto reply enabled <del>and auto ack
        // enabled and no callsign is selected</del> update: if we're in HB
        // mode, doesn't matter if a callsign is selected.
        else if ((d.cmd == " HB" || d.cmd == " HEARTBEAT") &&
                 canCurrentModeSendHeartbeat() &&
                 ui->actionModeJS8HB->isChecked() &&
                 ui->actionModeAutoreply->isChecked() &&
                 ui->actionHeartbeatAcknowledgements->isChecked()) {
            // do not process HB activity if buffer is not empty, this prevents
            // broken incoming MSG's
            if (!m_messageBuffer.isEmpty()) {
                qCDebug(mainwindow_js8) << "hb paused for messageBuffer";
                continue;
            }

            // check to make sure we aren't pausing HB transmissions (ACKs)
            // while a callsign is selected
            if (m_config.heartbeat_qso_pause() && !selectedCallsign.isEmpty()) {
                qCDebug(mainwindow_js8) << "hb paused during qso";
                continue;
            }

            // check to make sure this callsign isn't blacklisted
            if (m_config.hb_blacklist().contains(d.from) ||
                m_config.hb_blacklist().contains(
                    Radio::base_callsign(d.from))) {
                qCDebug(mainwindow_js8) << "hb blacklist blocking" << d.from;
                continue;
            }

            // check to see if we have a message for a station who is
            // heartbeating
            QString extra;
            auto mid = getNextMessageIdForCallsign(d.from);
            if (mid != -1) {
                extra = QString("MSG ID %1").arg(mid);
            }

            // group messaging - if isGroupCall, check to see if there's a
            // message id for the group and return it if there's not an
            // individual message
            // TODO: include group name in response to differentiate from direct
            // messages?
            else if (isGroupCall) {
                mid = getNextGroupMessageIdForCallsign(d.to, d.from);
                if (mid != -1) {
                    extra = QString("MSG ID %1").arg(mid);
                }
            }

            // TODO: require confirmation?
            sendHeartbeatAck(d.from, d.snr, extra);

            if (isAllCall) {
                // since all pings are technically @ALLCALL, let's bump the
                // allcall cache here...
                m_txAllcallCommandCache.insert(d.from, new QDateTime(now), 5);
            }

            continue;
        }

        // PROCESS HEARTBEAT SNR
        else if (d.cmd == " HEARTBEAT SNR") {
            qCDebug(mainwindow_js8) << "skipping incoming hb snr" << d.text;
            continue;
        }

        // PROCESS CQ
        else if (d.cmd == " CQ") {
            qCDebug(mainwindow_js8) << "skipping incoming cq" << d.text;
            continue;
        }

        // PROCESS MSG
        else if (d.cmd == " MSG" && !isAllCall) {

            auto text = d.text;

            qCDebug(mainwindow_js8) << "adding message to inbox" << text;

            auto calls = parseRelayPathCallsigns(d.from, text);

            d.cmd = " MSG ";
            d.relayPath = calls.join(">");
            d.text = text;

            addCommandToMyInbox(d);

            // notification
            tryNotify("inbox");

            // we haven't replaced the from with the relay path, so we have to
            // use it for the ack if there is one
            reply = QString("%1 ACK").arg(calls.length() > 1 ? d.relayPath
                                                             : d.from);

#define SHOW_ALERT_FOR_MSG 1
#if SHOW_ALERT_FOR_MSG
            SelfDestructMessageBox *m = new SelfDestructMessageBox(
                300, "New Message Received",
                QString("A new message was received at %1 UTC from %2")
                    .arg(d.utcTimestamp.time().toString())
                    .arg(d.from),
                QMessageBox::Information, QMessageBox::Ok, QMessageBox::Ok,
                false, this);

            m->show();
#endif
        }

        // PROCESS ACKS
        else if (d.cmd == " ACK" && !isAllCall) {
            qCDebug(mainwindow_js8) << "skipping incoming ack" << d.text;

            // notification for ack
            tryNotify("ack");

            // make sure this is explicit
            continue;
        }

        // PROCESS BUFFERED CMD
        else if (d.cmd == " CMD" && !isAllCall) {
            qCDebug(mainwindow_js8) << "skipping incoming command" << d.text;

            // make sure this is explicit
            continue;
        }

        // PROCESS BUFFERED QUERY
        else if (d.cmd == " QUERY" && !isAllCall) {
            auto who = d.from; // keep in mind, this is the sender, not the
                               // original requestor if relayed
            auto replyPath = d.from;

            if (d.relayPath.contains(">")) {
                auto path = d.relayPath.split(">");
                who = path.last();
                replyPath = d.relayPath;
            }

            QStringList segs = d.text.split(" ");
            if (segs.isEmpty()) {
                continue;
            }

            auto cmd = segs.first();
            segs.removeFirst();

            if (cmd == "MSG" && !segs.isEmpty()) {
                auto inbox = Inbox(inboxPath());
                if (!inbox.open()) {
                    continue;
                }

                bool ok = false;
                int mid = QString(segs.first()).toInt(&ok);
                if (!ok) {
                    continue;
                }

                auto msg = inbox.value(mid);
                auto params = msg.params();
                if (params.isEmpty()) {
                    continue;
                }

                auto from = params.value("FROM").toString().trimmed();

                auto to = params.value("TO").toString().trimmed();

                // group messaging - allow any message to a @GROUP to be
                // retrieved by anybody
                bool isGroupMsg = to.startsWith("@");

                if (!isGroupMsg && to != who &&
                    to != Radio::base_callsign(who)) {
                    continue;
                }

                auto text = params.value("TEXT").toString().trimmed();
                if (text.isEmpty()) {
                    continue;
                }

                /*
                 * mark as delivered (so subsequent HBs and QUERY MSGS don't
                 * receive this message)
                 *
                 * Do this in callbacks so that messages are only marked read
                 * after any potential confirmations have been made by the user,
                 * and the message has been processed in the transaction queue.
                 */
                if (!isGroupMsg) {
                    callback = [this, mid, msg]() {
                        this->markMsgDelivered(mid, msg);
                    };
                } else {
                    callback = [this, mid, who]() {
                        this->markGroupMsgDeliveredForCallsign(mid, who);
                    };
                }

                auto lookaheadMid = getLookaheadMessageIdForCallsign(who, mid);
                if (lookaheadMid == -1 && isGroupMsg) {
                    lookaheadMid =
                        getLookaheadGroupMessageIdForCallsign(d.to, who, mid);
                }

                // and reply
                if (lookaheadMid != -1) {
                    reply = QString("%1 MSG %2 FROM %3 NEXT MSG ID %4");
                    reply = reply.arg(replyPath);
                    reply = reply.arg(text);
                    reply = reply.arg(from);
                    reply = reply.arg(lookaheadMid);
                } else {
                    reply = QString("%1 MSG %2 FROM %3");
                    reply = reply.arg(replyPath);
                    reply = reply.arg(text);
                    reply = reply.arg(from);
                }
            }
        }

        // PROCESS BUFFERED QUERY MSGS
        else if (d.cmd == " QUERY MSGS" &&
                 ui->actionModeAutoreply->isChecked()) {
            auto who = d.from; // keep in mind, this is the sender, not the
                               // original requestor if relayed
            auto replyPath = d.from;

            if (d.relayPath.contains(">")) {
                auto path = d.relayPath.split(">");
                who = path.last();
                replyPath = d.relayPath;
            }

            // if this is an allcall or a directed call, check to see if we have
            // a stored message for user. we reply yes if the user would be able
            // to retreive a stored message
            auto mid = getNextMessageIdForCallsign(who);
            if (mid != -1) {
                reply = QString("%1 YES MSG ID %2").arg(replyPath).arg(mid);
            }

            // Group messaging - if isGroupCall, check to see if there's a
            // message id for the group and return it if there's not an
            // individual message
            // TODO: include group name in response to differentiate from direct
            // messages?
            else if (isGroupCall) {
                mid = getNextGroupMessageIdForCallsign(d.to, d.from);
                if (mid != -1) {
                    reply = QString("%1 YES MSG ID %2").arg(replyPath).arg(mid);
                }
            }

            // if this is not an allcall and we have no messages, reply no.
            if (!isAllCall && reply.isEmpty()) {
                reply = QString("%1 NO").arg(replyPath);
            }
        }

        // PROCESS BUFFERED QUERY CALL
        else if (d.cmd == " QUERY CALL" &&
                 ui->actionModeAutoreply->isChecked()) {
            auto replyPath = d.from;
            if (d.relayPath.contains(">")) {
                replyPath = d.relayPath;
            }

            auto who = d.text;
            if (who.isEmpty()) {
                continue;
            }

            auto callsigns = Varicode::parseCallsigns(who);
            if (callsigns.isEmpty()) {
                continue;
            }

            QStringList replies;
            int callsignAging = m_config.callsign_aging();
            auto baseCall = callsigns.first();
            foreach (auto cd, m_callActivity.values()) {
                if (callsignAging &&
                    cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
                    continue;
                }

                if (baseCall == cd.call ||
                    baseCall == Radio::base_callsign(cd.call)) {
                    auto r = QString("%1 (%2)")
                                 .arg(Varicode::formatSNR(cd.snr))
                                 .arg(since(cd.utcTimestamp))
                                 .trimmed();
                    replies.append(r);
                    break;
                }
            }

            if (!replies.isEmpty()) {
                replies.prepend(QString("%1 YES").arg(replyPath));
            }

            reply = replies.join(" ");

            if (!reply.isEmpty()) {
                if (isAllCall) {
                    m_txAllcallCommandCache.insert(d.from, new QDateTime(now),
                                                   25);
                }
            }
        }

#if 0
        // PROCESS ALERT
        else if (d.cmd == "!" && !isAllCall) {

            // create alert dialog
            processAlertReplyForCommand(d, d.from, " ");

            // make sure this is explicit
            continue;
        }
#endif

        // well, if there's no reply, don't do anything...
        if (reply.isEmpty()) {
            continue;
        }

        // do not queue @ALLCALL replies if auto-reply is not checked
        if (!ui->actionModeAutoreply->isChecked() && isAllCall) {
            continue;
        }

#if 0
        // TODO: jsherer - HB issue here
        // do not queue a reply if it's a HB and HB is not active
        // if((!ui->hbMacroButton->isChecked() || m_hbInterval <= 0) && d.cmd.contains("HB")){
        //     continue;
        // }
#endif

        // do not queue for reply if there's text in the window
        if (!ui->extFreeTextMsgEdit->toPlainText().isEmpty()) {
            continue;
        }

        // do not queue for reply if there's a buffer open to us
        int bufferOffset = 0;
        if (hasExistingMessageBufferToMe(&bufferOffset)) {
            qCDebug(mainwindow_js8) << "skipping reply due to open buffer"
                                    << bufferOffset << m_messageBuffer.count();
            continue;
        }

        // add @ALLCALLs to the @ALLCALL cache
        if (isAllCall) {
            m_txAllcallCommandCache.insert(d.from, new QDateTime(now), 25);
        }

        // queue the reply here to be sent when a free interval is available on
        // the frequency that was sent unless, this is an allcall, to which we
        // should be responding on a clear frequency offset we always want to
        // make sure that the directed cache has been updated at this point so
        // we have the most information available to make a frequency selection.
        if (m_config.autoreply_confirmation()) {
            confirmThenEnqueueMessage(90, priority, reply, freq, callback);
        } else {
            enqueueMessage(priority, reply, freq, callback);
        }
    }
}

QString MainWindow::inboxPath() {
    return QDir::toNativeSeparators(
        m_config.writeable_data_dir().absoluteFilePath("inbox.db3"));
}

void MainWindow::refreshInboxCounts() {
    auto inbox = Inbox(inboxPath());
    if (inbox.open()) {
        // reset inbox counts
        m_rxInboxCountCache.clear();

        // compute new counts from db
        auto v = inbox.values("UNREAD", "$", "%", 0, 10000);
        foreach (auto pair, v) {
            auto params = pair.second.params();
            auto to = params.value("TO").toString();
            if (to.isEmpty() ||
                (to != m_config.my_callsign() &&
                 to != Radio::base_callsign(m_config.my_callsign()))) {
                continue;
            }
            auto from = params.value("FROM").toString();
            if (from.isEmpty()) {
                continue;
            }

            m_rxInboxCountCache[from] = m_rxInboxCountCache.value(from, 0) + 1;

            if (!m_callActivity.contains(from)) {
                auto const utc = params.value("UTC").toString();
                auto const snr = params.value("SNR").toInt();
                auto const dial = params.value("DIAL").toInt();
                auto const offset = params.value("OFFSET").toInt();
                auto const tdrift = params.value("TDRIFT").toInt();
                auto const submode = params.value("SUBMODE").toInt();

                CallDetail cd;
                cd.call = from;
                cd.snr = snr;
                cd.dial = dial;
                cd.offset = offset;
                cd.tdrift = tdrift;
                cd.utcTimestamp =
                    QDateTime::fromString(utc, "yyyy-MM-dd hh:mm:ss");
                cd.utcTimestamp.setTimeZone(QTimeZone::utc());
                cd.ackTimestamp = cd.utcTimestamp;
                cd.submode = submode;
                logCallActivity(cd, false);
            }
        }

        // Now handle group message counts
        QMap<QString, int> groupMessageCounts = inbox.getGroupMessageCounts();
        foreach (auto key, groupMessageCounts.keys()) {
            m_rxInboxCountCache[key] = groupMessageCounts[key];
        }
    }
}

bool MainWindow::hasMessageHistory(QString call) {
    auto inbox = Inbox(inboxPath());
    if (!inbox.open()) {
        return false;
    }

    int store = inbox.count("STORE", "$.params.TO", call);
    int unread = inbox.count("UNREAD", "$.params.FROM", call);
    int read = inbox.count("READ", "$.params.FROM", call);
    return (store + unread + read) > 0;
}

int MainWindow::addCommandToMyInbox(CommandDetail d) {
    // local cache for inbox count
    m_rxInboxCountCache[d.from] = m_rxInboxCountCache.value(d.from, 0) + 1;

    // add it to my unread inbox
    return addCommandToStorage("UNREAD", d);
}

int MainWindow::addCommandToStorage(QString type, CommandDetail d) {
    // inbox:
    auto inbox = Inbox(inboxPath());
    if (!inbox.open()) {
        return -1;
    }

    QVariantMap v = {
        {"UTC", QVariant(d.utcTimestamp.toString("yyyy-MM-dd hh:mm:ss"))},
        {"TO", QVariant(d.to)},
        {"FROM", QVariant(d.from)},
        {"PATH", QVariant(d.relayPath)},
        {"TDRIFT", QVariant(d.tdrift)},
        {"FREQ", QVariant(d.dial + d.offset)},
        {"DIAL", QVariant(d.dial)},
        {"OFFSET", QVariant(d.offset)},
        {"CMD", QVariant(d.cmd)},
        {"SNR", QVariant(d.snr)},
        {"SUBMODE", QVariant(d.submode)},
    };

    if (!d.grid.isEmpty()) {
        v["GRID"] = QVariant(d.grid);
    }

    if (!d.extra.isEmpty()) {
        v["EXTRA"] = QVariant(d.extra);
    }

    if (!d.text.isEmpty()) {
        v["TEXT"] = QVariant(d.text);
    }

    auto m = Message(type, "", v);

    return inbox.append(m);
}

int MainWindow::getNextMessageIdForCallsign(QString callsign) {
    auto inbox = Inbox(inboxPath());
    if (!inbox.open()) {
        return -1;
    }

    auto v1 = inbox.values("STORE", "$.params.TO", callsign, 0, 10);
    foreach (auto pair, v1) {
        auto params = pair.second.params();
        auto text = params.value("TEXT").toString().trimmed();
        if (!text.isEmpty()) {
            return pair.first;
        }
    }

    auto v2 = inbox.values("STORE", "$.params.TO",
                           Radio::base_callsign(callsign), 0, 10);
    foreach (auto pair, v2) {
        auto params = pair.second.params();
        auto text = params.value("TEXT").toString().trimmed();
        if (!text.isEmpty()) {
            return pair.first;
        }
    }

    return -1;
}

int MainWindow::getLookaheadMessageIdForCallsign(QString callsign, int msgId) {
    auto inbox = Inbox(inboxPath());
    if (!inbox.open()) {
        return -1;
    }

    int mid = inbox.getLookaheadMessageIdForCallsign(callsign, msgId);

    if (mid == -1) {
        mid = inbox.getLookaheadMessageIdForCallsign(
            Radio::base_callsign(callsign), msgId);
    }

    if (mid != -1) {
        return mid;
    }

    return -1;
}

// Facade for Inbox::getNextGroupMessageIdForCallsign
int MainWindow::getNextGroupMessageIdForCallsign(QString group_name,
                                                 QString callsign) {
    Inbox inbox(inboxPath());
    if (!inbox.open()) {
        return -1;
    }

    return inbox.getNextGroupMessageIdForCallsign(group_name, callsign);
}

// Facade for Inbox::getLookaheadGroupMessageIdForCallsign
int MainWindow::getLookaheadGroupMessageIdForCallsign(QString group_name,
                                                      QString callsign,
                                                      int afterMsgId) {
    Inbox inbox(inboxPath());
    if (!inbox.open()) {
        return -1;
    }

    int mid = inbox.getLookaheadGroupMessageIdForCallsign(group_name, callsign,
                                                          afterMsgId);

    if (mid == -1) {
        mid = inbox.getLookaheadGroupMessageIdForCallsign(
            group_name, Radio::base_callsign(callsign), afterMsgId);
    }

    if (mid != -1) {
        return mid;
    }

    return -1;
}

// Facade for Inbox::markGroupMsgDeliveredForCallsign
bool MainWindow::markGroupMsgDeliveredForCallsign(int msgId, QString callsign) {
    Inbox inbox(inboxPath());
    if (!inbox.open()) {
        return false;
    }

    return inbox.markGroupMsgDeliveredForCallsign(msgId, callsign);
}

bool MainWindow::markMsgDelivered(int mid, Message msg) {
    Inbox inbox(inboxPath());
    if (!inbox.open()) {
        return false;
    }

    msg.setType("DELIVERED");
    return inbox.set(mid, msg);
}

QStringList MainWindow::parseRelayPathCallsigns(QString from, QString text) {
    QStringList calls;
    QString callDePattern = {
        R"(\s([*]DE[*]|VIA)\s(?<callsign>\b(?<prefix>[A-Z0-9]{1,4}\/)?(?<base>([0-9A-Z])?([0-9A-Z])([0-9])([A-Z])?([A-Z])?([A-Z])?)(?<suffix>\/[A-Z0-9]{1,4})?)\b)"};
    QRegularExpression re(callDePattern);
    auto iter = re.globalMatch(text);
    while (iter.hasNext()) {
        auto match = iter.next();
        calls.prepend(match.captured("callsign"));
    }
    calls.prepend(from);
    return calls;
}

void MainWindow::processSpots() {
    if (!m_config.spot_to_reporting_networks()) {
        m_rxCallQueue.clear();
        return;
    }

    if (m_rxCallQueue.isEmpty()) {
        return;
    }

    // Is it ok to post spots to PSKReporter?
    int nsec = DriftingDateTime::currentSecsSinceEpoch() - m_secBandChanged;
    bool okToPost = (nsec > (4 * m_TRperiod) / 5);
    if (!okToPost) {
        return;
    }

    while (!m_rxCallQueue.isEmpty()) {
        CallDetail d = m_rxCallQueue.dequeue();
        if (d.call.isEmpty()) {
            continue;
        }

        if (m_config.spot_blacklist().contains(d.call) ||
            m_config.spot_blacklist().contains(Radio::base_callsign(d.call))) {
            continue;
        }

        qCDebug(mainwindow_js8) << "spotting call to reporting networks"
                                << d.call << d.snr << d.dial << d.offset;

        spotReport(d.submode, d.dial, d.offset, d.snr, d.call, d.grid);
        pskLogReport("JS8", d.dial, d.offset, d.snr, d.call, d.grid,
                     d.utcTimestamp);

        if (canSendNetworkMessage()) {
            sendNetworkMessage("RX.SPOT", "",
                               {
                                   {"_ID", QVariant(-1)},
                                   {"FREQ", QVariant(d.dial + d.offset)},
                                   {"DIAL", QVariant(d.dial)},
                                   {"OFFSET", QVariant(d.offset)},
                                   {"CALL", QVariant(d.call)},
                                   {"SNR", QVariant(d.snr)},
                                   {"GRID", QVariant(d.grid)},
                               });
        }
    }
}

void MainWindow::processTxQueue() {
#if IDLE_BLOCKS_TX
    if (m_tx_watchdog) {
        return;
    }
#endif

    if (m_txMessageQueue.isEmpty()) {
        return;
    }

    // grab the next message...
    auto head = m_txMessageQueue.head();

    // decide if it's ok to transmit...
    int f = head.offset;
    if (f == -1) {
        f = freq();
    }

    // we need a valid frequency...
    if (f <= 0) {
        return;
    }

    // tx frame queue needs to be empty...
    if (!m_txFrameQueue.isEmpty()) {
        return;
    }

    // our message box needs to be empty...
    if (!ui->extFreeTextMsgEdit->toPlainText().isEmpty()) {
        return;
    }

    // and if we are a low priority message, we need to have not transmitted in
    // the past 30 seconds...
    if (head.priority <= PriorityLow &&
        m_lastTxStartTime.secsTo(DriftingDateTime::currentDateTimeUtc()) <=
            30) {
        return;
    }

    // if so... dequeue the next message from the queue...
    auto message = m_txMessageQueue.dequeue();

    // add the message to the outgoing message text box
    addMessageText(message.message, true);

    // check to see if this is a high priority message, or if we have autoreply
    // enabled, or if this is a ping and the ping button is enabled
    if (message.priority >= PriorityHigh ||
        message.message.contains(" HEARTBEAT ") ||
        message.message.contains(" HB ") || message.message.contains(" ACK ") ||
        ui->actionModeAutoreply->isChecked()) {
        // then try to set the frequency...
        setFreqOffsetForRestore(f, true);

        // then prepare to transmit...
        toggleTx(true);
    }

    if (message.callback) {
        message.callback();
    }
}

void MainWindow::displayActivity(bool force) {
    if (!m_rxDisplayDirty && !force) {
        return;
    }

    // Band Activity
    displayBandActivity();

    // Call Activity
    displayCallActivity();

    m_rxDisplayDirty = false;
}

// updateBandActivity
void MainWindow::displayBandActivity() {
    auto now = DriftingDateTime::currentDateTimeUtc();

    // Reset the header label text to accommodate minimal label setting
    int cols = ui->tableWidgetRXAll->columnCount();
    for (int c = 0; c < cols; ++c) {
        ui->tableWidgetRXAll->horizontalHeaderItem(c)->setText(
            columnLabel(m_origRxHeaderLabelMap[c]));
    }

    ui->tableWidgetRXAll->setFont(m_config.table_font());

    // Selected Offset
    int selectedOffset = -1;
    auto selectedItems = ui->tableWidgetRXAll->selectedItems();
    if (!selectedItems.isEmpty()) {
        selectedOffset = selectedItems.first()->data(Qt::UserRole).toInt();
    }

    ui->tableWidgetRXAll->setUpdatesEnabled(false);
    {
        // Scroll Position
        auto const currentScrollPos =
            ui->tableWidgetRXAll->verticalScrollBar()->value();

        // Clear the table
        ui->tableWidgetRXAll->setRowCount(0);

        // Sort!
        auto const sort = getSortByReverse("bandActivity", "offset");
        auto keys = m_bandActivity.keys();

        // Base comparison, called by the detail comparisons. We may not need
        // to proceed to the detail comparison at all here, if at least one of
        // the lists is empty. If both are non-empty, delegate to the detail
        // comparison, providing it with the last list elements.

        auto const compare = [this](int const lhsKey, int const rhsKey,
                                    auto &&detail) {
            auto const &lhs = m_bandActivity[lhsKey];
            auto const &rhs = m_bandActivity[rhsKey];

            if (lhs.isEmpty())
                return false;
            if (rhs.isEmpty())
                return true;

            return detail(lhs.last(), rhs.last());
        };

        // Time stamp comparison, easy stuff, just a total ordering on the
        // UTC time stamp field.

        auto const compareTimestamp = [compare](int const lhsKey,
                                                int const rhsKey) {
            return compare(lhsKey, rhsKey, [](auto &&lhs, auto &&rhs) {
                return lhs.utcTimestamp < rhs.utcTimestamp;
            });
        };

        // SNR comparison;  we always want insane SNR values to be at the end
        // of the list and the list is going to be reversed if reverse is set,
        // so we want to set things up so that insane elements are either all
        // at the beginning in the case of a reverse, or all at the end in the
        // standard case. Reverse takes care of itself; we just need to sort
        // out standard.

        auto const compareSNR = [compare, reverse = sort.reverse](
                                    int const lhsKey, int const rhsKey) {
            return compare(lhsKey, rhsKey, [reverse](auto &&lhs, auto &&rhs) {
                auto lhsSNR = lhs.snr;
                auto rhsSNR = rhs.snr;

                if (!reverse) {
                    if (lhsSNR < -60 || lhsSNR > 60)
                        lhsSNR = -lhsSNR;
                    if (rhsSNR < -60 || rhsSNR > 60)
                        rhsSNR = -rhsSNR;
                }

                return lhsSNR < rhsSNR;
            });
        };

        // Submode comparison; slow mode isn't at the start of the enumeration;
        // it's in the middle of it. All the other modes are in the expected
        // order.

        auto const compareSubmode = [compare](int const lhsKey,
                                              int const rhsKey) {
            return compare(lhsKey, rhsKey, [](auto &&lhs, auto &&rhs) {
                auto lhsSubmode = lhs.submode;
                auto rhsSubmode = rhs.submode;

                if (lhsSubmode == Varicode::JS8CallSlow)
                    lhsSubmode = -lhsSubmode;
                if (rhsSubmode == Varicode::JS8CallSlow)
                    rhsSubmode = -rhsSubmode;

                return lhsSubmode < rhsSubmode;
            });
        };

        // Always perform an initial sort by offset.

        std::stable_sort(keys.begin(), keys.end());

        // If something other than offset was requested as the sort by, perform
        // an additional stable sort by the field requested.

        if (sort.by == "timestamp")
            std::stable_sort(keys.begin(), keys.end(), compareTimestamp);
        else if (sort.by == "snr")
            std::stable_sort(keys.begin(), keys.end(), compareSNR);
        else if (sort.by == "submode")
            std::stable_sort(keys.begin(), keys.end(), compareSubmode);

        // The sort comparators leave things in forward order. If a reverse sort
        // was requested, reverse the keys.

        if (sort.reverse)
            std::reverse(keys.begin(), keys.end());

        // Build the table
        foreach (int offset, keys) {
            bool isOffsetSelected = (offset == selectedOffset);

            QList<ActivityDetail> items = m_bandActivity[offset];
            if (items.length() > 0) {
                QDateTime timestamp;
                QStringList text;
                QString age;
                int snr = 0;
                float tdrift = 0;
                int submode = -1;

                int activityAging = m_config.activity_aging();

                // hide items that shouldn't appear

                for (int i = 0; i < items.length(); i++) {
                    auto item = items[i];

                    bool shouldDisplay = true;

                    // hide aged items
                    if (!isOffsetSelected && activityAging &&
                        item.utcTimestamp.secsTo(now) / 60 >= activityAging) {
                        shouldDisplay = false;
                    }

                    // hide heartbeat items
                    if (!ui->actionShow_Band_Heartbeats_and_ACKs->isChecked()) {
                        // hide heartbeats and acks if we have heartbeating
                        // hidden
                        if (item.text.contains(" @HB ") ||
                            item.text.contains(" HEARTBEAT ")) {
                            shouldDisplay = false;

                            // hide the previous item if this it shouldn't be
                            // displayed either...
                            if (i > 0 && items[i - 1].shouldDisplay &&
                                items[i - 1].text.endsWith(": ")) {
                                items[i - 1].shouldDisplay = false;
                            }
                        }

                        // if our previous item should not be displayed (or this
                        // is the first frame) and we have a MSG ID, then don't
                        // display it either.
                        if ((i == 0 ||
                             (i > 0 && !items[i - 1].shouldDisplay)) &&
                            (item.text.contains(" MSG ID "))) {
                            shouldDisplay = false;
                        }
                    }

                    // hide empty items
                    if (item.text.isEmpty()) {
                        shouldDisplay = false;
                    }

                    // set the visibility of the item
                    items[i].shouldDisplay = shouldDisplay;
                }

                // show the items that should appear
                foreach (ActivityDetail item, items) {
                    if (!item.shouldDisplay) {
                        continue;
                    }

                    if (item.isLowConfidence) {
                        item.text = QString("[%1]").arg(item.text);
                    }

                    if ((item.bits & Varicode::JS8CallLast) ==
                        Varicode::JS8CallLast) {
                        // append the eot character to the text
                        item.text = QString("%1 %2 ")
                                        .arg(Varicode::rstrip(item.text))
                                        .arg(m_config.eot());
                    }
                    text.append(item.text);
                    snr = item.snr;
                    age = since(item.utcTimestamp);
                    timestamp = item.utcTimestamp;
                    tdrift = item.tdrift;
                    submode = item.submode;
                }

                auto joined = Varicode::rstrip(text.join(""));
                if (joined.isEmpty()) {
                    continue;
                }

                ui->tableWidgetRXAll->insertRow(
                    ui->tableWidgetRXAll->rowCount());
                int row = ui->tableWidgetRXAll->rowCount() - 1;
                int col = 0;

                auto offsetItem = new QTableWidgetItem(
                    QString(columnLabel("%1 Hz")).arg(offset));
                offsetItem->setData(Qt::UserRole, QVariant(offset));
                offsetItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
                ui->tableWidgetRXAll->setItem(row, col++, offsetItem);

                auto ageItem = new QTableWidgetItem(age);
                ageItem->setTextAlignment(Qt::AlignCenter);
                ageItem->setToolTip(timestamp.toString());
                ui->tableWidgetRXAll->setItem(row, col++, ageItem);

                auto snrText = Varicode::formatSNR(snr);
                auto snrItem = new QTableWidgetItem(
                    snrText.isEmpty()
                        ? ""
                        : QString(columnLabel("%1 dB")).arg(snrText));
                snrItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
                ui->tableWidgetRXAll->setItem(row, col++, snrItem);

                auto tdriftItem = new QTableWidgetItem(
                    QString(columnLabel("%1 ms")).arg((int)(1000 * tdrift)));
                tdriftItem->setData(Qt::UserRole, QVariant(tdrift));
                tdriftItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
                ui->tableWidgetRXAll->setItem(row, col++, tdriftItem);

                auto name = JS8::Submode::name(submode);
                auto submodeItem =
                    new QTableWidgetItem(name.left(1).replace("H", "N"));
                submodeItem->setToolTip(name);
                submodeItem->setData(Qt::UserRole, QVariant(name));
                submodeItem->setTextAlignment(Qt::AlignCenter);
                ui->tableWidgetRXAll->setItem(row, col++, submodeItem);

                // align right if eliding...
                int colWidth = ui->tableWidgetRXAll->columnWidth(3);
                auto textItem = new QTableWidgetItem(joined);
                auto html = QString("<qt/>%1").arg(joined.toHtmlEscaped());
                html =
                    html.replace(m_config.eot(), m_config.eot() + "<br/><br/>");
                html = html.replace(QRegularExpression("([<]br[/][>])+$"), "");
                textItem->setToolTip(html);

                QFontMetrics fm(textItem->font());
                auto elidedText =
                    fm.elidedText(joined, Qt::ElideLeft, colWidth);
                auto flag = Qt::AlignLeft | Qt::AlignVCenter;
                if (elidedText != joined) {
                    flag = Qt::AlignRight | Qt::AlignVCenter;
                    textItem->setText(joined);
                }
                textItem->setTextAlignment(flag);

                ui->tableWidgetRXAll->setItem(row, col++, textItem);

                if (isOffsetSelected) {
                    for (int i = 0; i < ui->tableWidgetRXAll->columnCount();
                         i++) {
                        ui->tableWidgetRXAll->item(row, i)->setSelected(true);
                    }
                }

                bool isDirectedAllCall = false;
                if ((isDirectedOffset(offset, &isDirectedAllCall) &&
                     !isDirectedAllCall) ||
                    isMyCallIncluded(text.last())) {
                    for (int i = 0; i < ui->tableWidgetRXAll->columnCount();
                         i++) {
                        ui->tableWidgetRXAll->item(row, i)->setBackground(
                            QBrush(m_config.color_MyCall()));
                    }
                }

                if (!text.isEmpty()) {
                    auto const list = joined.split(QRegularExpression("[:> ]"),
                                                   Qt::SkipEmptyParts);
                    QSet<QString> words(list.begin(), list.end());

                    if (words.contains("CQ")) {
                        for (int i = 0; i < ui->tableWidgetRXAll->columnCount();
                             i++) {
                            ui->tableWidgetRXAll->item(row, i)->setBackground(
                                QBrush(m_config.color_CQ()));
                        }
                    }

                    auto matchingSecondaryWords =
                        m_config.secondary_highlight_words() & words;
                    if (!matchingSecondaryWords.isEmpty()) {
                        for (int i = 0; i < ui->tableWidgetRXAll->columnCount();
                             i++) {
                            ui->tableWidgetRXAll->item(row, i)->setBackground(
                                QBrush(m_config.color_secondary_highlight()));
                        }
                    }

                    auto matchingPrimaryWords =
                        m_config.primary_highlight_words() & words;
                    if (!matchingPrimaryWords.isEmpty()) {
                        for (int i = 0; i < ui->tableWidgetRXAll->columnCount();
                             i++) {
                            ui->tableWidgetRXAll->item(row, i)->setBackground(
                                QBrush(m_config.color_primary_highlight()));
                        }
                    }
                }
            }
        }

        // Set table color
        auto style = QString(
            "QTableWidget { background:%1; selection-background-color:%2; "
            "alternate-background-color:%1; color:%3; } "
            "QTableWidget::item:selected { background-color: %2; color: %3; }");
        style = style.arg(m_config.color_table_background().name());
        style = style.arg(m_config.color_table_highlight().name());
        style = style.arg(m_config.color_table_foreground().name());
        ui->tableWidgetRXAll->setStyleSheet(style);

        // Set the table palette for inactive selected row
        auto p = ui->tableWidgetRXAll->palette();

        p.setColor(QPalette::Highlight, m_config.color_table_highlight());
        p.setColor(QPalette::HighlightedText,
                   m_config.color_table_foreground());
        p.setColor(QPalette::Inactive, QPalette::Highlight,
                   p.color(QPalette::Active, QPalette::Highlight));
        ui->tableWidgetRXAll->setPalette(p);

        // Set item fonts
        for (int row = 0; row < ui->tableWidgetRXAll->rowCount(); row++) {
            for (int col = 0; col < ui->tableWidgetRXAll->columnCount();
                 col++) {
                auto item = ui->tableWidgetRXAll->item(row, col);
                if (item) {
                    item->setFont(m_config.table_font());
                }
            }
        }

        // Column labels
        ui->tableWidgetRXAll->horizontalHeader()->setVisible(
            showColumn("band", "labels"));

        // Hide columns
        ui->tableWidgetRXAll->setColumnHidden(0, !showColumn("band", "offset"));
        ui->tableWidgetRXAll->setColumnHidden(1,
                                              !showColumn("band", "timestamp"));
        ui->tableWidgetRXAll->setColumnHidden(2, !showColumn("band", "snr"));
        ui->tableWidgetRXAll->setColumnHidden(
            3, !showColumn("band", "tdrift", false));
        ui->tableWidgetRXAll->setColumnHidden(
            4, !showColumn("band", "submode", false));

        // Resize the table columns
        ui->tableWidgetRXAll->resizeColumnToContents(0);
        ui->tableWidgetRXAll->resizeColumnToContents(1);
        ui->tableWidgetRXAll->resizeColumnToContents(2);
        ui->tableWidgetRXAll->resizeColumnToContents(3);
        ui->tableWidgetRXAll->resizeColumnToContents(4);

        // Reset the scroll position
        ui->tableWidgetRXAll->verticalScrollBar()->setValue(currentScrollPos);
    }
    ui->tableWidgetRXAll->setUpdatesEnabled(true);
}

// updateCallActivity
void MainWindow::displayCallActivity() {
    auto now = DriftingDateTime::currentDateTimeUtc();

    // Reset the header label text to accommodate minimal label setting
    int cols = ui->tableWidgetCalls->columnCount();
    for (int c = 0; c < cols; ++c) {
        ui->tableWidgetCalls->horizontalHeaderItem(c)->setText(
            columnLabel(m_origCallActivityHeaderLabelMap[c]));
    }

    ui->tableWidgetCalls->setFont(m_config.table_font());

    // Selected callsign
    QString selectedCall = callsignSelected();

    auto currentScrollPos = ui->tableWidgetCalls->verticalScrollBar()->value();

    ui->tableWidgetCalls->setUpdatesEnabled(false);
    {
        // Clear the table
        ui->tableWidgetCalls->setRowCount(0);
        ui->tableWidgetCalls->horizontalHeaderItem(8)->setText(
            m_config.miles() ? "mi" : "km");

        bool showIconColumn = false;
        createGroupCallsignTableRows(
            ui->tableWidgetCalls, selectedCall,
            showIconColumn); // isAllCallIncluded(selectedCall)); // ||
                             // isGroupCallIncluded(selectedCall));

        // Build the table

        auto const sort = getSortByReverse("callActivity", "callsign");
        auto keys = m_callActivity.keys();

        auto const compareOffset = [this](QString const &lhsKey,
                                          QString const &rhsKey) {
            return m_callActivity[lhsKey].offset <
                   m_callActivity[rhsKey].offset;
        };

        auto const compareAzimuth =
            [this, reverse = sort.reverse, my_grid = m_config.my_grid()](
                QString const &lhsKey, QString const &rhsKey) {
                auto const lhs =
                    Geodesic::vector(my_grid, m_callActivity[lhsKey].grid)
                        .azimuth();
                auto const rhs =
                    Geodesic::vector(my_grid, m_callActivity[rhsKey].grid)
                        .azimuth();

                // We always want invalid azimuths to be at the end of the list,
                // and the list is going to be reversed if reverse is set, so we
                // want to set things up so that invalid elements are either all
                // at the beginning in the case of a reverse, or all at the end
                // in the standard case.

                if (!lhs)
                    return reverse && rhs;
                else if (!rhs)
                    return !reverse;
                else
                    return lhs < rhs;
            };

        auto const compareDistance =
            [this, reverse = sort.reverse, my_grid = m_config.my_grid()](
                QString const &lhsKey, QString const &rhsKey) {
                auto const lhs =
                    Geodesic::vector(my_grid, m_callActivity[lhsKey].grid)
                        .distance();
                auto const rhs =
                    Geodesic::vector(my_grid, m_callActivity[rhsKey].grid)
                        .distance();

                // We always want invalid distances to be at the end of the
                // list, and the list is going to be reversed if reverse is set,
                // so we want to set things up so that invalid elements are
                // either all at the beginning in the case of a reverse, or all
                // at the end in the standard case.

                if (!lhs)
                    return reverse && rhs;
                else if (!rhs)
                    return !reverse;
                else
                    return lhs < rhs;
            };

        auto const compareTimestamp = [this](QString const &lhsKey,
                                             QString const &rhsKey) {
            return m_callActivity[lhsKey].utcTimestamp <
                   m_callActivity[rhsKey].utcTimestamp;
        };

        auto const compareAckTimestamp = [this](QString const &lhsKey,
                                                QString const &rhsKey) {
            return m_callActivity[rhsKey].ackTimestamp <
                   m_callActivity[lhsKey].ackTimestamp;
        };

        auto const compareSNR =
            [this, reverse = sort.reverse](QString const &lhsKey,
                                           QString const &rhsKey) {
                auto lhs = m_callActivity[lhsKey].snr;
                auto rhs = m_callActivity[rhsKey].snr;

                // We always want insane SNR values to be at the end of the
                // list, and the list is going to be reversed if reverse is set,
                // so we want to set things up so that insane elements are
                // either all at the beginning in the case of a reverse, or all
                // at the end in the standard case. Reverse takes care of
                // itself; we just need to sort out standard.

                if (!reverse) {
                    if (lhs < -60 || lhs > 60)
                        lhs = -lhs;
                    if (rhs < -60 || rhs > 60)
                        rhs = -rhs;
                }

                return lhs < rhs;
            };

        auto const compareSubmode = [this](QString const &lhsKey,
                                           QString const &rhsKey) {
            auto lhs = m_callActivity[lhsKey].submode;
            auto rhs = m_callActivity[rhsKey].submode;

            // Slow mode isn't at the start of the enumeration; it's in the
            // middle of it. All the other modes are in the expected order.

            if (lhs == Varicode::JS8CallSlow)
                lhs = -lhs;
            if (rhs == Varicode::JS8CallSlow)
                rhs = -rhs;

            return lhs < rhs;
        };

        // Always perform an initial sort by callsign.

        std::stable_sort(keys.begin(), keys.end());

        // If something other than callsign was requested as the sort by,
        // perform an additional stable sort by the field requested.

        if (sort.by == "offset")
            std::stable_sort(keys.begin(), keys.end(), compareOffset);
        else if (sort.by == "distance")
            std::stable_sort(keys.begin(), keys.end(), compareDistance);
        else if (sort.by == "azimuth")
            std::stable_sort(keys.begin(), keys.end(), compareAzimuth);
        else if (sort.by == "timestamp")
            std::stable_sort(keys.begin(), keys.end(), compareTimestamp);
        else if (sort.by == "ackTimestamp")
            std::stable_sort(keys.begin(), keys.end(), compareAckTimestamp);
        else if (sort.by == "snr")
            std::stable_sort(keys.begin(), keys.end(), compareSNR);
        else if (sort.by == "submode")
            std::stable_sort(keys.begin(), keys.end(), compareSubmode);

        // The sort comparators leave things in forward order. If a reverse sort
        // was requested, reverse the keys.

        if (sort.reverse)
            std::reverse(keys.begin(), keys.end());

        // pin messages to the top
        std::stable_sort(keys.begin(), keys.end(),
                         [this](QString const &lhsKey, QString const &rhsKey) {
                             auto const lhs = (int)!(
                                 m_rxInboxCountCache.value(lhsKey, 0) > 0);
                             auto const rhs = (int)!(
                                 m_rxInboxCountCache.value(rhsKey, 0) > 0);

                             return lhs < rhs;
                         });

        int callsignAging = m_config.callsign_aging();
        foreach (QString call, keys) {
            if (call.trimmed().isEmpty()) {
                continue;
            }

            CallDetail d = m_callActivity[call];
            if (d.call.trimmed().isEmpty()) {
                continue;
            }

            bool isCallSelected = (call == selectedCall);

            // icon flags (flag -> star -> empty)
            bool hasMessage = m_rxInboxCountCache.value(d.call, 0) > 0;

            // display telephone icon if called cq in the past 5 minutes
            bool hasCQ =
                d.cqTimestamp.isValid() && d.cqTimestamp.secsTo(now) / 60 < 5;

            // display star if they've acked a message from us
            bool hasACK = d.ackTimestamp.isValid();

            if (!isCallSelected && !hasMessage && callsignAging &&
                d.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
                continue;
            }

            ui->tableWidgetCalls->insertRow(ui->tableWidgetCalls->rowCount());
            int row = ui->tableWidgetCalls->rowCount() - 1;
            int col = 0;

#if SHOW_THROUGH_CALLS
            QString displayCall =
                d.through.isEmpty()
                    ? d.call
                    : QString("%1>%2").arg(d.through).arg(d.call);
#else
            QString displayCall = d.call;
#endif
            bool hasThrough = !d.through.isEmpty();

            auto iconItem = new QTableWidgetItem(hasMessage   ? "\u2691"
                                                 : hasACK     ? "\u2605"
                                                 : hasCQ      ? "\u260E"
                                                 : hasThrough ? "\u269F"
                                                              : "");
            iconItem->setData(Qt::UserRole, QVariant(d.call));
            iconItem->setToolTip(
                hasMessage ? "Message Available"
                : hasACK   ? QString("Hearing Your Station (%1)")
                               .arg(since(d.ackTimestamp))
                : hasCQ ? QString("Calling CQ (%1)").arg(since(d.cqTimestamp))
                : hasThrough
                    ? QString("Heard Through Relay (%1)").arg(d.through)
                    : "");
            iconItem->setTextAlignment(Qt::AlignCenter);
            ui->tableWidgetCalls->setItem(row, col++, iconItem);
            if (hasMessage || hasACK || hasCQ || hasThrough) {
                showIconColumn = true;
            }

            auto displayItem = new QTableWidgetItem(displayCall);
            displayItem->setData(Qt::UserRole, QVariant(d.call));
            displayItem->setToolTip(generateCallDetail(displayCall));
            ui->tableWidgetCalls->setItem(row, col++, displayItem);

#if ONLY_SHOW_HEARD_CALLSIGNS
            if (d.utcTimestamp.isValid()) {
#else
            if (true) {
#endif
                auto ageItem = new QTableWidgetItem(since(d.utcTimestamp));
                ageItem->setTextAlignment(Qt::AlignCenter);
                ageItem->setToolTip(d.utcTimestamp.toString());
                ui->tableWidgetCalls->setItem(row, col++, ageItem);

                auto snrText = Varicode::formatSNR(d.snr);
                auto snrItem = new QTableWidgetItem(
                    snrText.isEmpty()
                        ? ""
                        : QString(columnLabel("%1 dB")).arg(snrText));
                snrItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
                ui->tableWidgetCalls->setItem(row, col++, snrItem);

                auto offsetItem = new QTableWidgetItem(
                    QString(columnLabel("%1 Hz")).arg(d.offset));
                offsetItem->setData(Qt::UserRole, QVariant(d.offset));
                offsetItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
                ui->tableWidgetCalls->setItem(row, col++, offsetItem);

                auto tdriftItem = new QTableWidgetItem(
                    QString(columnLabel("%1 ms")).arg((int)(1000 * d.tdrift)));
                tdriftItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter);
                ui->tableWidgetCalls->setItem(row, col++, tdriftItem);

                auto name = JS8::Submode::name(d.submode);
                auto modeItem =
                    new QTableWidgetItem(name.left(1).replace("H", "N"));
                modeItem->setToolTip(name);
                modeItem->setData(Qt::UserRole, QVariant(name));
                modeItem->setTextAlignment(Qt::AlignCenter);
                ui->tableWidgetCalls->setItem(row, col++, modeItem);

                auto gridItem = new QTableWidgetItem(
                    QString("%1").arg(d.grid.trimmed().left(4)));
                gridItem->setToolTip(d.grid.trimmed());
                ui->tableWidgetCalls->setItem(row, col++, gridItem);

                auto const vector =
                    Geodesic::vector(m_config.my_grid(), d.grid);
                auto const units = !showColumn("call", "labels");

                auto distanceItem = new QTableWidgetItem(
                    vector.distance().toString(m_config.miles(), units));
                distanceItem->setTextAlignment(Qt::AlignRight |
                                               Qt::AlignVCenter);
                ui->tableWidgetCalls->setItem(row, col++, distanceItem);

                auto azimuthItem =
                    new QTableWidgetItem(vector.azimuth().toString(units));
                if (auto const azimuth = vector.azimuth())
                    azimuthItem->setToolTip(azimuth.compass().toString());
                azimuthItem->setTextAlignment(Qt::AlignRight |
                                              Qt::AlignVCenter);
                ui->tableWidgetCalls->setItem(row, col++, azimuthItem);

                QString flag;
                if (m_logBook.hasWorkedBefore(d.call, "")) {
                    // unicode checkmark
                    flag = "\u2713";
                }
                auto workedBeforeItem = new QTableWidgetItem(flag);
                workedBeforeItem->setTextAlignment(Qt::AlignCenter);
                ui->tableWidgetCalls->setItem(row, col++, workedBeforeItem);

                QString logDetailGrid;
                QString logDetailDate;
                QString logDetailName;
                QString logDetailComment;
                bool gridItemEmpty = gridItem->text().isEmpty();

                if ((gridItemEmpty && showColumn("call", "grid")) ||
                    showColumn("call", "log") ||
                    showColumn("call", "logName") ||
                    showColumn("call", "logComment")) {
                    m_logBook.findCallDetails(d.call, logDetailGrid,
                                              logDetailDate, logDetailName,
                                              logDetailComment);
                }

                if (gridItemEmpty && !logDetailGrid.isEmpty()) {
                    gridItem->setText(logDetailGrid.trimmed().left(4));
                    gridItem->setToolTip(logDetailGrid.trimmed());

                    auto const vector =
                        Geodesic::vector(m_config.my_grid(), d.grid);
                    auto const units = !showColumn("call", "labels");

                    distanceItem->setText(
                        vector.distance().toString(m_config.miles(), units));
                    azimuthItem->setText(vector.azimuth().toString(units));
                    if (auto const azimuth = vector.azimuth())
                        azimuthItem->setToolTip(azimuth.compass().toString());

                    // update the call activity cache with the loaded grid
                    if (m_callActivity.contains(d.call)) {
                        m_callActivity[call].grid = logDetailGrid.trimmed();
                    }
                }

                if (!logDetailDate.isEmpty()) {
                    auto lastLogged =
                        QDate::fromString(logDetailDate, "yyyyMMdd");

                    workedBeforeItem->setToolTip(
                        QString("Last Logged: %1").arg(lastLogged.toString()));
                }

                auto logNameItem = new QTableWidgetItem(logDetailName);
                logNameItem->setTextAlignment(Qt::AlignCenter);
                logNameItem->setToolTip(logDetailName);
                ui->tableWidgetCalls->setItem(row, col++, logNameItem);

                auto logCommentItem = new QTableWidgetItem(logDetailComment);
                logCommentItem->setTextAlignment(Qt::AlignCenter);
                logCommentItem->setToolTip(logDetailComment);
                ui->tableWidgetCalls->setItem(row, col++, logCommentItem);

            } else {
                ui->tableWidgetCalls->setItem(row, col++,
                                              new QTableWidgetItem("")); // age
                ui->tableWidgetCalls->setItem(row, col++,
                                              new QTableWidgetItem("")); // snr
                ui->tableWidgetCalls->setItem(row, col++,
                                              new QTableWidgetItem("")); // freq
                ui->tableWidgetCalls->setItem(
                    row, col++, new QTableWidgetItem("")); // tdrift
                ui->tableWidgetCalls->setItem(row, col++,
                                              new QTableWidgetItem("")); // mode
                ui->tableWidgetCalls->setItem(row, col++,
                                              new QTableWidgetItem("")); // grid
                ui->tableWidgetCalls->setItem(
                    row, col++, new QTableWidgetItem("")); // distance
                ui->tableWidgetCalls->setItem(
                    row, col++, new QTableWidgetItem("")); // azimuth
                ui->tableWidgetCalls->setItem(
                    row, col++, new QTableWidgetItem("")); // worked before
                ui->tableWidgetCalls->setItem(
                    row, col++, new QTableWidgetItem("")); // log name
                ui->tableWidgetCalls->setItem(
                    row, col++, new QTableWidgetItem("")); // log comment
            }

            if (isCallSelected) {
                for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) {
                    ui->tableWidgetCalls->item(row, i)->setSelected(true);
                }
            }

            if (hasCQ) {
                for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) {
                    ui->tableWidgetCalls->item(row, i)->setBackground(
                        QBrush(m_config.color_CQ()));
                }
            }

            if (m_config.secondary_highlight_words().contains(call)) {
                for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) {
                    ui->tableWidgetCalls->item(row, i)->setBackground(
                        QBrush(m_config.color_secondary_highlight()));
                }
            }

            if (m_config.primary_highlight_words().contains(call)) {
                for (int i = 0; i < ui->tableWidgetCalls->columnCount(); i++) {
                    ui->tableWidgetCalls->item(row, i)->setBackground(
                        QBrush(m_config.color_primary_highlight()));
                }
            }
        }

        // Set table color
        auto style = QString(
            "QTableWidget { background:%1; selection-background-color:%2; "
            "alternate-background-color:%1; color:%3; } "
            "QTableWidget::item:selected { background-color: %2; color: %3; }");
        style = style.arg(m_config.color_table_background().name());
        style = style.arg(m_config.color_table_highlight().name());
        style = style.arg(m_config.color_table_foreground().name());
        ui->tableWidgetCalls->setStyleSheet(style);

        // Set the table palette for inactive selected row
        auto p = ui->tableWidgetCalls->palette();
        p.setColor(QPalette::Highlight, m_config.color_table_highlight());
        p.setColor(QPalette::HighlightedText,
                   m_config.color_table_foreground());
        p.setColor(QPalette::Inactive, QPalette::Highlight,
                   p.color(QPalette::Active, QPalette::Highlight));
        ui->tableWidgetCalls->setPalette(p);

        // Set item fonts
        for (int row = 0; row < ui->tableWidgetCalls->rowCount(); row++) {
            auto bold = ui->tableWidgetCalls->item(row, 0)->text() == "\u2691";
            for (int col = 0; col < ui->tableWidgetCalls->columnCount();
                 col++) {
                auto item = ui->tableWidgetCalls->item(row, col);
                if (item) {
                    auto f = m_config.table_font();
                    if (bold) {
                        f.setBold(true);
                    }
                    item->setFont(f);
                }
            }
        }

        // Column labels
        ui->tableWidgetCalls->horizontalHeader()->setVisible(
            showColumn("call", "labels"));

        // Hide columns
        ui->tableWidgetCalls->setColumnHidden(0, !showIconColumn);
        ui->tableWidgetCalls->setColumnHidden(1,
                                              !showColumn("call", "callsign"));
        ui->tableWidgetCalls->setColumnHidden(2,
                                              !showColumn("call", "timestamp"));
        ui->tableWidgetCalls->setColumnHidden(3, !showColumn("call", "snr"));
        ui->tableWidgetCalls->setColumnHidden(4, !showColumn("call", "offset"));
        ui->tableWidgetCalls->setColumnHidden(
            5, !showColumn("call", "tdrift", false));
        ui->tableWidgetCalls->setColumnHidden(
            6, !showColumn("call", "submode", false));
        ui->tableWidgetCalls->setColumnHidden(
            7, !showColumn("call", "grid", false));
        ui->tableWidgetCalls->setColumnHidden(
            8, !showColumn("call", "distance", false));
        ui->tableWidgetCalls->setColumnHidden(
            9, !showColumn("call", "azimuth", false));
        ui->tableWidgetCalls->setColumnHidden(10, !showColumn("call", "log"));
        ui->tableWidgetCalls->setColumnHidden(11,
                                              !showColumn("call", "logName"));
        ui->tableWidgetCalls->setColumnHidden(
            12, !showColumn("call", "logComment"));

        // Resize the table columns
        ui->tableWidgetCalls->resizeColumnToContents(0);
        ui->tableWidgetCalls->resizeColumnToContents(1);
        ui->tableWidgetCalls->resizeColumnToContents(2);
        ui->tableWidgetCalls->resizeColumnToContents(3);
        ui->tableWidgetCalls->resizeColumnToContents(4);
        ui->tableWidgetCalls->resizeColumnToContents(5);
        ui->tableWidgetCalls->resizeColumnToContents(6);
        ui->tableWidgetCalls->resizeColumnToContents(7);
        ui->tableWidgetCalls->resizeColumnToContents(8);
        ui->tableWidgetCalls->resizeColumnToContents(9);
        ui->tableWidgetCalls->resizeColumnToContents(10);
        ui->tableWidgetCalls->resizeColumnToContents(11);

        // Reset the scroll position
        ui->tableWidgetCalls->verticalScrollBar()->setValue(currentScrollPos);
    }
    ui->tableWidgetCalls->setUpdatesEnabled(true);
}

void MainWindow::emitPTT(bool on) {
    qCDebug(mainwindow_js8) << "Setting PTT to" << (on ? "on" : "off");

    Q_EMIT m_config.transceiver_ptt(on);

    // emit to network
    sendNetworkMessage(
        "RIG.PTT", on ? "on" : "off",
        {
            {"_ID", QVariant(-1)},
            {"PTT", QVariant(on)},
            {"UTC",
             QVariant(
                 DriftingDateTime::currentDateTimeUtc().toMSecsSinceEpoch())},
        });
}

void MainWindow::emitTones() {
    if (!canSendNetworkMessage()) {
        return;
    }

    // emit tone numbers to network
    QVariantList t;
    for (int i = 0; i < JS8_NUM_SYMBOLS; i++) {
        // qCDebug(mainwindow_js8) << "tone" << i << "=" << itone[i];
        t.append(QVariant((int)itone[i]));
    }

    sendNetworkMessage("TX.FRAME", "", {{"_ID", QVariant(-1)}, {"TONES", t}});
}

void MainWindow::udpNetworkMessage(Message const &message) {
    if (!m_config.udpEnabled()) {
        return;
    }

    if (!m_config.accept_udp_requests()) {
        return;
    }

    networkMessage(message);
}

void MainWindow::tcpNetworkMessage(Message const &message) {
    if (!m_config.tcpEnabled()) {
        return;
    }

    if (!m_config.accept_tcp_requests()) {
        return;
    }

    networkMessage(message);
}

void MainWindow::networkMessage(Message const &message) {
    auto type = message.type();

    if (type == "PING") {
        return;
    }

    auto id = message.id();

    qCDebug(mainwindow_js8) << "try processing network message" << type << id;

    // Inspired by FLDigi
    // TODO: MAIN.RX - Turn on RX
    // TODO: MAIN.TX - Transmit
    // TODO: MAIN.PTT - PTT
    // TODO: MAIN.TUNE - Tune
    // TODO: MAIN.HALT - Halt
    // TODO: MAIN.AUTO - Auto
    // TODO: MAIN.SPOT - Spot
    // TODO: MAIN.HB - HB

    // RIG.GET_FREQ - Get the current Frequency
    // RIG.SET_FREQ - Set the current Frequency
    if (type == "RIG.GET_FREQ") {
        // Send WSJT-X Status message if protocol is enabled
        if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) {
            QString dx_call = callsignSelected();
            QString dx_grid = "";
            if (!dx_call.isEmpty() && m_callActivity.contains(dx_call)) {
                dx_grid = m_callActivity[dx_call].grid;
            }
            QString tx_message = m_transmitting ? m_currentMessage : "";

            m_wsjtxMessageMapper->sendStatusUpdate(
                dialFrequency(), freq(),
                "JS8", // mode
                dx_call, m_config.my_callsign(), m_config.my_grid(), dx_grid,
                true, // tx_enabled
                m_transmitting,
                m_decoderBusy || m_monitoring, // decoding
                tx_message);
        }

        // Send native JSON message only if not conflicting with WSJT-X
        bool skip_json = false;
        if (m_config.wsjtx_protocol_enabled() &&
            m_config.wsjtx_server_port() == m_config.udp_server_port() &&
            m_config.wsjtx_server_name() == m_config.udp_server_name()) {
            skip_json = true;
        }

        if (!skip_json) {
            sendNetworkMessage(
                "RIG.FREQ", "",
                {{"_ID", id},
                 {"FREQ", QVariant((quint64)dialFrequency() + freq())},
                 {"DIAL", QVariant((quint64)dialFrequency())},
                 {"OFFSET", QVariant((quint64)freq())}});
        }
        return;
    }

    if (type == "RIG.SET_FREQ") {
        auto params = message.params();
        if (params.contains("DIAL")) {
            bool ok = false;
            auto f = params["DIAL"].toInt(&ok);
            if (ok) {
                setRig(f);
                displayDialFrequency();
            }
        }
        if (params.contains("OFFSET")) {
            bool ok = false;
            auto f = params["OFFSET"].toInt(&ok);
            if (ok) {
                setFreqOffsetForRestore(f, false);
            }
        }
    }

    // STATION.GET_CALLSIGN - Get the current callsign
    // STATION.GET_GRID - Get the current grid locator
    // STATION.SET_GRID - Set the current grid locator
    // STATION.GET_INFO - Get the current station qth
    // STATION.SET_INFO - Set the current station qth
    if (type == "STATION.GET_CALLSIGN") {
        sendNetworkMessage("STATION.CALLSIGN", m_config.my_callsign(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "STATION.GET_GRID") {
        sendNetworkMessage("STATION.GRID", m_config.my_grid(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "STATION.SET_GRID") {
        m_config.set_dynamic_location(message.value());
        sendNetworkMessage("STATION.GRID", m_config.my_grid(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "STATION.GET_INFO") {
        sendNetworkMessage("STATION.INFO", m_config.my_info(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "STATION.SET_INFO") {
        m_config.set_dynamic_station_info(message.value());
        sendNetworkMessage("STATION.INFO", m_config.my_info(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "STATION.GET_STATUS") {
        sendNetworkMessage("STATION.STATUS", m_config.my_status(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "STATION.SET_STATUS") {
        m_config.set_dynamic_station_status(message.value());
        sendNetworkMessage("STATION.STATUS", m_config.my_status(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    // RX.GET_CALL_ACTIVITY
    // RX.GET_CALL_SELECTED
    // RX.GET_BAND_ACTIVITY
    // RX.GET_TEXT

    if (type == "RX.GET_CALL_ACTIVITY") {
        auto now = DriftingDateTime::currentDateTimeUtc();
        int callsignAging = m_config.callsign_aging();
        QVariantMap calls = {
            {"_ID", id},
        };

        foreach (auto cd, m_callActivity.values()) {
            if (callsignAging &&
                cd.utcTimestamp.secsTo(now) / 60 >= callsignAging) {
                continue;
            }
            QVariantMap detail;
            detail["SNR"] = QVariant(cd.snr);
            detail["GRID"] = QVariant(cd.grid);
            detail["UTC"] = QVariant(cd.utcTimestamp.toMSecsSinceEpoch());
            calls[cd.call] = QVariant(detail);
        }

        sendNetworkMessage("RX.CALL_ACTIVITY", "", calls);
        return;
    }

    if (type == "RX.GET_CALL_SELECTED") {
        sendNetworkMessage("RX.CALL_SELECTED", callsignSelected(),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "RX.GET_BAND_ACTIVITY") {
        QVariantMap offsets = {
            {"_ID", id},
        };
        for (auto const [offset, activity] : m_bandActivity.asKeyValueRange()) {
            if (activity.isEmpty())
                continue;

            auto const d = activity.last();

            offsets[QString("%1").arg(offset)] = QVariant(QVariantMap{
                {"FREQ", QVariant(d.dial + d.offset)},
                {"DIAL", QVariant(d.dial)},
                {"OFFSET", QVariant(d.offset)},
                {"TEXT", QVariant(d.text)},
                {"SNR", QVariant(d.snr)},
                {"UTC", QVariant(d.utcTimestamp.toMSecsSinceEpoch())}});
        }

        sendNetworkMessage("RX.BAND_ACTIVITY", "", offsets);
        return;
    }

    if (type == "RX.GET_TEXT") {
        sendNetworkMessage("RX.TEXT", ui->textEditRX->toPlainText().right(1024),
                           {
                               {"_ID", id},
                           });
        return;
    }

    // TX.GET_TEXT
    // TX.SET_TEXT
    // TX.SEND_MESSAGE

    if (type == "TX.GET_TEXT") {
        sendNetworkMessage("TX.TEXT",
                           ui->extFreeTextMsgEdit->toPlainText().right(1024),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "TX.SET_TEXT") {
        addMessageText(message.value(), true);
        sendNetworkMessage("TX.TEXT",
                           ui->extFreeTextMsgEdit->toPlainText().right(1024),
                           {
                               {"_ID", id},
                           });
        return;
    }

    if (type == "TX.SEND_MESSAGE") {
        auto text = message.value();
        if (!text.isEmpty()) {
            enqueueMessage(PriorityNormal, text, -1, nullptr);
            processTxQueue();
            return;
        }
    }

    // MODE.GET_SPEED
    // MODE.SET_SPEED
    if (type == "MODE.GET_SPEED") {
        sendNetworkMessage("MODE.SPEED", "",
                           {
                               {"_ID", id},
                               {"SPEED", m_nSubMode},
                           });
        return;
    }

    if (type == "MODE.SET_SPEED") {
        auto ok = false;
        auto const speed =
            message.params().value("SPEED", QVariant(m_nSubMode)).toInt(&ok);
        if (ok) {
            if (speed == Varicode::JS8CallNormal)
                ui->actionModeJS8Normal->setChecked(true);
            else if (speed == Varicode::JS8CallFast)
                ui->actionModeJS8Fast->setChecked(true);
            else if (speed == Varicode::JS8CallTurbo)
                ui->actionModeJS8Turbo->setChecked(true);
            else if (speed == Varicode::JS8CallSlow)
                ui->actionModeJS8Slow->setChecked(true);
            else if (speed == Varicode::JS8CallUltra)
                ui->actionModeJS8Ultra->setChecked(true);
            setupJS8();
        }
        sendNetworkMessage("MODE.SPEED", "",
                           {
                               {"_ID", id},
                               {"SPEED", m_nSubMode},
                           });
        return;
    }

    // INBOX.GET_MESSAGES
    // INBOX.STORE_MESSAGE
    if (type == "INBOX.GET_MESSAGES") {
        QString selectedCall =
            message.params().value("CALLSIGN", "").toString();
        if (selectedCall.isEmpty()) {
            selectedCall = "%";
        }

        Inbox inbox(inboxPath());
        if (!inbox.open()) {
            return;
        }

        QList<QPair<int, Message>> msgs;
        msgs.append(
            inbox.values("STORE", "$.params.TO", selectedCall, 0, 1000));
        msgs.append(
            inbox.values("READ", "$.params.FROM", selectedCall, 0, 1000));
        foreach (auto pair, inbox.values("UNREAD", "$.params.FROM",
                                         selectedCall, 0, 1000)) {
            msgs.append(pair);
        }
        std::stable_sort(
            msgs.begin(), msgs.end(),
            [](QPair<int, Message> const &a, QPair<int, Message> const &b) {
                return QVariant::compare(a.second.params().value("UTC"),
                                         b.second.params().value("UTC")) ==
                       QPartialOrdering::Greater;
            });

        QVariantList l;
        foreach (auto pair, msgs) {
            l << pair.second.toVariantMap();
        }

        sendNetworkMessage("INBOX.MESSAGES", "",
                           {
                               {"_ID", id},
                               {"MESSAGES", l},
                           });
        return;
    }

    if (type == "INBOX.STORE_MESSAGE") {
        QString selectedCall =
            message.params().value("CALLSIGN", "").toString();
        if (selectedCall.isEmpty()) {
            return;
        }

        QString text = message.params().value("TEXT", "").toString();
        if (text.isEmpty()) {
            return;
        }

        CommandDetail d = {};
        d.cmd = " MSG ";
        d.to = selectedCall;
        d.from = m_config.my_callsign();
        d.relayPath = d.from;
        d.text = text;
        d.utcTimestamp = DriftingDateTime::currentDateTimeUtc();
        d.submode = m_nSubMode;

        auto mid = addCommandToStorage("STORE", d);

        sendNetworkMessage("INBOX.MESSAGE", "",
                           {
                               {"_ID", id},
                               {"ID", mid},
                           });
        return;
    }

    // WINDOW.RAISE

    if (type == "WINDOW.RAISE") {
        setWindowState(Qt::WindowActive);
        activateWindow();
        raise();
        return;
    }

    qCDebug(mainwindow_js8) << "Unable to process networkMessage:" << type;
}

bool MainWindow::canSendNetworkMessage() {
    return m_config.udpEnabled() || m_config.tcpEnabled();
}

void MainWindow::sendNetworkMessage(QString const &type,
                                    QString const &message) {
    if (!canSendNetworkMessage()) {
        return;
    }

    auto m = Message(type, message);

    if (m_config.udpEnabled()) {
        m_messageClient->send(m);
    }

    if (m_config.tcpEnabled()) {
        m_messageServer->send(m);
    }
}

void MainWindow::sendNetworkMessage(QString const &type, QString const &message,
                                    QVariantMap const &params) {
    if (!canSendNetworkMessage()) {
        return;
    }

    auto m = Message(type, message, params);

    if (m_config.udpEnabled()) {
        m_messageClient->send(m);
    }

    if (m_config.tcpEnabled()) {
        m_messageServer->send(m);
    }
}

void MainWindow::pskReporterError(QString const &message) {
    qCDebug(mainwindow_js8) << "PSK Reporter Error:" << message;

    showStatusMessage(tr("Spotting to PSK Reporter unavailable"));
}

void MainWindow::setRig(Frequency f) {
    if (f) {
        m_freqNominal = f;
        m_freqTxNominal = m_freqNominal - m_XIT;
    }

    if (m_transmitting && !m_config.tx_qsy_allowed())
        return;

    if ((m_monitoring || m_transmitting) && m_config.transceiver_online()) {
        if (m_config.split_mode()) {
            Q_EMIT m_config.transceiver_tx_frequency(m_freqTxNominal);
        }

        Q_EMIT m_config.transceiver_frequency(m_freqNominal);
    }
}

/**
 * @brief Update and send station status
 *
 * Sends station status updates to both WSJT-X protocol clients (if enabled)
 * and native JSON API clients (if not conflicting). When WSJT-X protocol
 * is enabled on the same port/address as the native JSON API, the native
 * JSON messages are skipped to avoid conflicts.
 */
void MainWindow::statusUpdate() {
    // Send WSJT-X Status message if protocol is enabled
    if (m_wsjtxMessageMapper && m_config.wsjtx_protocol_enabled()) {
        QString dx_call = callsignSelected();
        QString dx_grid = "";
        if (!dx_call.isEmpty() && m_callActivity.contains(dx_call)) {
            dx_grid = m_callActivity[dx_call].grid;
        }
        QString mode = JS8::Submode::name(m_nSubMode);
        QString tx_message = m_transmitting ? m_currentMessage : "";

        m_wsjtxMessageMapper->sendStatusUpdate(
            dialFrequency(), freq(),
            "JS8", // mode
            dx_call, m_config.my_callsign(), m_config.my_grid(), dx_grid,
            true, // tx_enabled - JS8Call always allows TX when not in special
                  // modes
            m_transmitting,
            m_decoderBusy || m_monitoring, // decoding
            tx_message);
    }

    // Send native JSON message only if not conflicting with WSJT-X
    if (canSendNetworkMessage()) {
        // Don't send JSON if WSJT-X is enabled on the same port/address
        bool skip_json = false;
        if (m_config.wsjtx_protocol_enabled() &&
            m_config.wsjtx_server_port() == m_config.udp_server_port() &&
            m_config.wsjtx_server_name() == m_config.udp_server_name()) {
            skip_json = true;
        }

        if (!skip_json) {
            sendNetworkMessage("STATION.STATUS", "",
                               {
                                   {"FREQ", QVariant(dialFrequency() + freq())},
                                   {"DIAL", QVariant(dialFrequency())},
                                   {"OFFSET", QVariant(freq())},
                                   {"SPEED", QVariant(m_nSubMode)},
                                   {"SELECTED", QVariant(callsignSelected())},
                               });
        }
    }
}

void MainWindow::childEvent(QChildEvent *e) {
    if (e->child()->isWidgetType()) {
        switch (e->type()) {
        case QEvent::ChildAdded:
            add_child_to_event_filter(e->child());
            break;
        case QEvent::ChildRemoved:
            remove_child_from_event_filter(e->child());
            break;
        default:
            break;
        }
    }
    QMainWindow::childEvent(e);
}

// add widget and any child widgets to our event filter so that we can
// take action on key press ad mouse press events anywhere in the main window
void MainWindow::add_child_to_event_filter(QObject *target) {
    if (target && target->isWidgetType()) {
        target->installEventFilter(this);
    }
    auto const &children = target->children();
    for (auto iter = children.begin(); iter != children.end(); ++iter) {
        add_child_to_event_filter(*iter);
    }
}

// recursively remove widget and any child widgets from our event filter
void MainWindow::remove_child_from_event_filter(QObject *target) {
    auto const &children = target->children();
    for (auto iter = children.begin(); iter != children.end(); ++iter) {
        remove_child_from_event_filter(*iter);
    }
    if (target && target->isWidgetType()) {
        target->removeEventFilter(this);
    }
}

void MainWindow::resetIdleTimer() {
    if (m_idleMinutes) {
        m_idleMinutes = 0;
        qCDebug(mainwindow_js8) << "idle" << m_idleMinutes << "minutes";
    }
}

void MainWindow::incrementIdleTimer() {
    m_idleMinutes++;
    qCDebug(mainwindow_js8)
        << "increment idle to" << m_idleMinutes << "minutes";
}

void MainWindow::tx_watchdog(bool triggered) {
    auto prior = m_tx_watchdog;
    m_tx_watchdog = triggered;
    if (triggered) {
        m_isTimeToSend = false;
        if (m_tune)
            stop_tuning();
        if (m_auto)
            auto_tx_mode(false);
        stopTx();
        tx_status_label.setStyleSheet(
            "QLabel{background-color: #000000; color:#ffffff; }");
        tx_status_label.setText("Idle timeout");

        // if the watchdog is triggered...we're no longer active
        bool wasAuto = ui->actionModeAutoreply->isChecked();
        bool wasHB = ui->hbMacroButton->isChecked();
        bool wasCQ = ui->cqMacroButton->isChecked();

        // save the button states
        ui->actionModeAutoreply->setChecked(false);
        qCDebug(mainwindow_js8) << "Unchecking the hbMacroButton and "
                                   "cqMacroButton from TX watchdog.";
        ui->hbMacroButton->setChecked(false);
        ui->cqMacroButton->setChecked(false);

        // clear the tx queues
        resetMessageTransmitQueue();

        QMessageBox *msgBox = new QMessageBox(this);
        msgBox->setIcon(QMessageBox::Information);
        msgBox->setWindowTitle("Idle Timeout");
        msgBox->setInformativeText(
            QString("You have been idle for more than %1 minutes.")
                .arg(m_config.watchdog()));
        msgBox->addButton(QMessageBox::Ok);

        connect(msgBox, &QMessageBox::finished, this,
                [this, wasAuto, wasHB, wasCQ](int /*result*/) {
                    // restore the button states
                    ui->actionModeAutoreply->setChecked(wasAuto);
                    ui->hbMacroButton->setChecked(wasHB);
                    ui->cqMacroButton->setChecked(wasCQ);

                    this->tx_watchdog(false);
                });
        msgBox->setModal(true);
        msgBox->show();
    }
    if (prior != triggered)
        statusUpdate();
}

void MainWindow::write_frequency_entry(QString const &file_name) {
    if (!m_config.write_logs()) {
        return;
    }

    // Write freq changes to ALL.TXT only below 30 MHz.
    QFile f2{m_config.writeable_data_dir().absoluteFilePath(file_name)};
    if (f2.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
        QTextStream out(&f2);
        out << DriftingDateTime::currentDateTimeUtc().toString(
                   "yyyy-MM-dd hh:mm:ss")
            << "  " << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6)
            << " MHz  "
            << "JS8" << Qt::endl;
        f2.close();
    } else {
        QTimer::singleShot(0, [this,
                               message = tr("Cannot open \"%1\" for append: %2")
                                             .arg(f2.fileName())
                                             .arg(f2.errorString())] {
            JS8MessageBox::warning_message(this, tr("Log File Error"), message);
        });
    }
}

void MainWindow::write_transmit_entry(QString const &file_name) {
    if (!m_config.write_logs()) {
        return;
    }

    QFile f{m_config.writeable_data_dir().absoluteFilePath(file_name)};
    if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
        QTextStream out(&f);
        auto time = DriftingDateTime::currentDateTimeUtc();
        time = time.addSecs(-(time.time().second() % m_TRperiod));
        auto dt =
            DecodedText(m_currentMessage, m_currentMessageBits, m_nSubMode);
        out << time.toString("yyyy-MM-dd hh:mm:ss") << "  Transmitting "
            << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6) << " MHz  "
            << "JS8"
            << ":  " << dt.message() << Qt::endl;
        f.close();
    } else {
        QTimer::singleShot(0, [this,
                               message = tr("Cannot open \"%1\" for append: %2")
                                             .arg(f.fileName())
                                             .arg(f.errorString())] {
            JS8MessageBox::warning_message(this, tr("Log File Error"), message);
        });
    }
}

void MainWindow::writeAllTxt(QStringView message) {
    if (!m_config.write_logs())
        return;

    // Write decoded text to file "ALL.TXT".

    QFile f{m_config.writeable_data_dir().absoluteFilePath("ALL.TXT")};

    if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
        QTextStream out(&f);

        if (m_RxLog == 1) {
            out << DriftingDateTime::currentDateTimeUtc().toString(
                       "yyyy-MM-dd hh:mm:ss")
                << "  " << qSetRealNumberPrecision(12) << (m_freqNominal / 1.e6)
                << " MHz  JS8" << Qt::endl;

            m_RxLog = 0;
        }

        out << message << Qt::endl;

        f.close();
    } else {
        JS8MessageBox::warning_message(this, tr("File Open Error"),
                                       tr("Cannot open \"%1\" for append: %2")
                                           .arg(f.fileName())
                                           .arg(f.errorString()));
    }
}

void MainWindow::writeMsgTxt(QStringView message, int snr, int offset) {
    if (!m_config.write_logs())
        return;

    // Write decoded text to file "DIRECTED.TXT".

    QFile f{m_config.writeable_data_dir().absoluteFilePath("DIRECTED.TXT")};

    if (f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) {
        QTextStream out(&f);
        QString output = DriftingDateTime::currentDateTimeUtc().toString(
                             "yyyy-MM-dd hh:mm:ss") %
                         "\t" % Radio::frequency_MHz_string(m_freqNominal) %
                         "\t" % QString::number(offset) % "\t" %
                         Varicode::formatSNR(snr) % "\t" % message;

        out << output << Qt::endl;

        f.close();
    } else {
        JS8MessageBox::warning_message(this, tr("File Open Error"),
                                       tr("Cannot open \"%1\" for append: %2")
                                           .arg(f.fileName())
                                           .arg(f.errorString()));
    }
}

QByteArray MainWindow::wisdomFileName() const {
    return QDir::toNativeSeparators(
               m_config.writeable_data_dir().absoluteFilePath(
                   "js8call_wisdom.dat"))
        .toLocal8Bit();
}

Q_LOGGING_CATEGORY(decoder_js8, "decoder.js8", QtWarningMsg)
Q_LOGGING_CATEGORY(mainwindow_js8, "mainwindow.js8", QtWarningMsg)
