/**
 * @file APRSISClient.cpp
 * @brief Implementation of APRS-IS client for JS8Call
 */
#include "APRSISClient.h"
#include "DriftingDateTime.h"
#include "Varicode.h"

#include <QLoggingCategory>
#include <QRandomGenerator>

#include <cmath>

Q_DECLARE_LOGGING_CATEGORY(aprsisclient_js8)

const int PACKET_TIMEOUT_SECONDS = 300;

static QString extractMessageId(QString &message) {
    auto trimmed = message.trimmed();
    auto braceIndex = trimmed.lastIndexOf('{');
    if (braceIndex < 0) {
        return {};
    }

    auto suffix = trimmed.mid(braceIndex + 1);
    if (suffix.endsWith('}')) {
        suffix.chop(1);
    }

    if (suffix.isEmpty()) {
        return {};
    }

    for (auto const &ch : suffix) {
        if (!ch.isLetterOrNumber()) {
            return {};
        }
    }

    message = trimmed.left(braceIndex).trimmed();
    return suffix;
}

/**
 * @brief Construct a new APRSISClient::APRSISClient object
 *
 * @param host
 * @param port
 * @param parent
 */
APRSISClient::APRSISClient(QString const host, quint16 const port,
                           QObject *parent)
    : QTcpSocket{parent}, m_timer{this}, m_paused{false},
      m_incomingRelayEnabled{false}, m_isLoggedIn{false}, m_skipPercent{0.0F} {
    setServer(host, port);

    connect(&m_timer, &QTimer::timeout, this, &APRSISClient::sendReports);
    m_timer.start(std::chrono::minutes(1));

    connect(this, &QTcpSocket::connected, this,
            &APRSISClient::onSocketConnected);
    connect(this, &QTcpSocket::readyRead, this,
            &APRSISClient::onSocketReadyRead);
    connect(this, &QTcpSocket::disconnected, this,
            &APRSISClient::onSocketDisconnected);
    connect(this, &QTcpSocket::errorOccurred, this,
            &APRSISClient::onSocketError);
}

/**
 * @brief Compute APRS-IS passcode for a callsign.
 *
 * @param callsign
 * @return quint32
 */
quint32 APRSISClient::hashCallsign(QString callsign) {
    // based on: https://github.com/hessu/aprsc/blob/master/src/passcode.c
    QByteArray rootCall =
        QString(callsign.split("-").first().toUpper()).toLocal8Bit() + '\0';
    quint32 hash = 0x73E2;

    qsizetype i = 0;
    qsizetype const len = rootCall.length();

    while (i + 1 < len) {
        hash ^= rootCall.at(i) << 8;
        hash ^= rootCall.at(i + 1);
        i += 2;
    }

    return hash & 0x7FFF;
}

/**
 * @brief Create a login frame for APRS-IS.
 *
 * @param callsign
 * @param filter
 * @return QString
 */
QString APRSISClient::loginFrame(QString callsign, QString filter) {
    auto loginFrame = QString("user %1 pass %2 ver %3 %4\n");
    loginFrame = loginFrame.arg(callsign);
    loginFrame = loginFrame.arg(hashCallsign(callsign));
    loginFrame = loginFrame.arg("JS8Call");
    if (!filter.isEmpty()) {
        loginFrame = loginFrame.arg(filter);
    } else {
        loginFrame = loginFrame.arg("");
    }
    return loginFrame;
}

/**
 * @brief Find all matches of a regular expression in a string
 *
 * @param re
 * @param content
 * @return QList<QStringList>
 */
QList<QStringList> findall(QRegularExpression re, QString content) {
    qsizetype pos = 0;
    QList<QStringList> all;

    while (pos < content.length()) {
        auto match = re.match(content, pos);
        if (!match.hasMatch()) {
            break;
        }

        all.append(match.capturedTexts());
        pos = match.capturedEnd();
    }

    return all;
}

/**
 * @brief Floor division for long integers
 *
 * @param num
 * @param den
 * @return long
 */
inline long floordiv(long num, long den) {
    if (0 < (num ^ den))
        return num / den;
    else {
        ldiv_t res = ldiv(num, den);
        return (res.rem) ? res.quot - 1 : res.quot;
    }
}

// convert an arbitrary length grid locator to a high precision lat/lon
/**
 * @brief Convert grid locator to degrees
 *
 * @param locator
 * @return QPair<float, float>
 */
QPair<float, float> APRSISClient::grid2deg(QString locator) {
    QString grid = locator.toUpper();

    float lat = -90;
    float lon = -90;

    auto lats = findall(QRegularExpression("([A-X])([A-X])"), grid);
    auto lons = findall(QRegularExpression("(\\d)(\\d)"), grid);

    int valx[22];
    int valy[22];

    int i = 0;
    int tot = 0;
    char A = 'A';
    foreach (QStringList matched, lats) {
        char x = matched.at(1).at(0).toLatin1();
        char y = matched.at(2).at(0).toLatin1();
        if (i * 2 + 1 >= 22) break;
        valx[i * 2] = x - A;
        valy[i * 2] = y - A;
        i++;
        tot++;
    }

    i = 0;
    foreach (QStringList matched, lons) {
        int x = matched.at(1).toInt();
        int y = matched.at(2).toInt();
        if (i * 2 + 1 >= 22) break;
        valx[i * 2 + 1] = x;
        valy[i * 2 + 1] = y;
        i++;
        tot++;
    }

    for (int i = 0; i < tot; i++) {
        int x = valx[i];
        int y = valy[i];

        int z = i - 1;
        float scale = pow(10, floordiv(-(z - 1), 2)) * pow(24, floordiv(-z, 2));

        lon += scale * x;
        lat += scale * y;
    }

    lon *= 2;

    return {lat, lon};
}

// convert an arbitrary length grid locator to a high precision lat/lon in aprs
// format
/**
 * @brief Convert grid locator to APRS format
 *
 * @param grid
 * @return QPair<QString, QString>
 */
QPair<QString, QString> APRSISClient::grid2aprs(QString grid) {
    auto geo = APRSISClient::grid2deg(grid);
    auto lat = geo.first;
    auto lon = geo.second;

    QString latDir = "N";
    if (lat < 0) {
        lat *= -1;
        latDir = "S";
    }

    QString lonDir = "E";
    if (lon < 0) {
        lon *= -1;
        lonDir = "W";
    }

    double iLat, fLat, iLon, fLon, iLatMin, fLatMin, iLonMin, fLonMin, iLatSec,
        iLonSec;
    fLat = modf(lat, &iLat);
    fLon = modf(lon, &iLon);

    fLatMin = modf(fLat * 60, &iLatMin);
    fLonMin = modf(fLon * 60, &iLonMin);

    iLatSec = round(fLatMin * 60);
    iLonSec = round(fLonMin * 60);

    if (iLatSec == 60) {
        iLatMin += 1;
        iLatSec = 0;
    }

    if (iLonSec == 60) {
        iLonMin += 1;
        iLonSec = 0;
    }

    if (iLatMin == 60) {
        iLat += 1;
        iLatMin = 0;
    }

    if (iLonMin == 60) {
        iLon += 1;
        iLonMin = 0;
    }

    double aprsLat = iLat * 100 + iLatMin + (iLatSec / 60.0);
    double aprsLon = iLon * 100 + iLonMin + (iLonSec / 60.0);

    return {QString("%1%2").arg(aprsLat, 7, 'f', 2, QChar('0')).arg(latDir),
            QString("%1%2").arg(aprsLon, 8, 'f', 2, QChar('0')).arg(lonDir)};
}

/**
 * @brief Strip SSID from callsign
 *
 * @param call
 * @return QString
 */
QString APRSISClient::stripSSID(QString call) {
    return QString(call.split("-").first().toUpper());
}

/**
 * @brief Replace callsign suffix with SSID
 *
 * @param call
 * @param base
 * @return QString
 */
QString APRSISClient::replaceCallsignSuffixWithSSID(QString call,
                                                    QString base) {
    if (call != base) {
        QRegularExpression re("[/](?<ssid>(\\d+))");
        auto matcher = re.globalMatch(call);
        if (matcher.hasNext()) {
            auto match = matcher.next();
            auto ssid = match.captured("ssid");
            call = base + "-" + ssid;
        } else {
            call = base;
        }
    }
    return call;
}

/**
 * @brief Enable or disable persistent inbound relay connections.
 *
 * When enabled, the client keeps an active connection so inbound APRS
 * messages can be received and relayed. When disabled, connections are
 * closed once the outbound queue drains.
 *
 * @param enabled
 */
void APRSISClient::setIncomingRelayEnabled(bool enabled) {
    qCDebug(aprsisclient_js8)
        << "APRSISClient::setIncomingRelayEnabled(" << enabled << ")";
    if (m_incomingRelayEnabled == enabled)
        return;

    m_incomingRelayEnabled = enabled;

    if (m_incomingRelayEnabled) {
        // if enabled, trigger a connection immediately
        if (!m_paused) {
            processQueue(false);
        }
    } else {
        // if disabled, and queue is empty, disconnect
        if (state() == QTcpSocket::ConnectedState && m_frameQueue.isEmpty()) {
            disconnectFromHost();
        }
    }
}

/**
 * @brief Enqueue a spot frame for APRS-IS
 *
 * @param by_call
 * @param from_call
 * @param grid
 * @param comment
 */
void APRSISClient::enqueueSpot(QString by_call, QString from_call, QString grid,
                               QString comment) {
    if (!isPasscodeValid()) {
        return;
    }

    auto geo = APRSISClient::grid2aprs(grid);
    auto spotFrame = QString("%1>APJ8CL,qAS,%2:=%3/%4G#JS8 %5\n");
    spotFrame = spotFrame.arg(from_call);
    spotFrame = spotFrame.arg(by_call);
    spotFrame = spotFrame.arg(geo.first);
    spotFrame = spotFrame.arg(geo.second);
    spotFrame = spotFrame.arg(comment.left(42));
    enqueueRaw(spotFrame);
}

/**
 * @brief Enqueue a third-party message frame for APRS-IS
 *
 * @param by_call
 * @param from_call
 * @param text
 */
void APRSISClient::enqueueThirdParty(QString by_call, QString from_call,
                                     QString text) {
    if (!isPasscodeValid()) {
        return;
    }

    auto frame = QString("%1>APJ8CL,qAS,%2:%3\n");
    frame = frame.arg(from_call);
    frame = frame.arg(by_call);
    frame = frame.arg(text);

    enqueueRaw(frame);
}

/**
 * @brief Enqueue a standard APRS message ACK frame for APRS-IS.
 *
 * @param from_call Source callsign (appears as the message sender).
 * @param to_call Destination callsign to acknowledge.
 * @param messageId APRS message identifier (preserved exactly).
 */
void APRSISClient::enqueueMessageAck(QString from_call, QString to_call,
                                     QString messageId) {
    if (!isPasscodeValid()) {
        return;
    }

    if (from_call.isEmpty() || to_call.isEmpty() || messageId.isEmpty()) {
        return;
    }

    auto dest = to_call.left(9).leftJustified(9, ' ', true);
    auto frame = QString("%1>APRS,TCPIP*::%2:ack%3\n");
    frame = frame.arg(from_call, dest, messageId);

    qDebug() << "DEBUG: APRS ACK Frame:" << frame.trimmed();

    enqueueRaw(frame);
}

/**
 * @brief Enqueue a raw APRS frame for APRS-IS
 *
 * @param aprsFrame
 */
void APRSISClient::enqueueRaw(QString aprsFrame) {
    m_frameQueue.enqueue({aprsFrame, DriftingDateTime::currentDateTimeUtc()});
}

/**
 * @brief Process the APRS-IS frame queue
 *
 * @param disconnect
 */
void APRSISClient::processQueue(bool disconnect) {
    // don't process queue if we haven't set our local callsign
    if (m_localCall.isEmpty()) {
        qCDebug(aprsisclient_js8)
            << "APRSISClient Abort ProcessQueue: No Local Call";
        return;
    }

    if (!m_incomingRelayEnabled && m_frameQueue.isEmpty()) {
        return;
    }

    // don't process queue if there's no host
    if (m_host.isEmpty() || m_port == 0) {
        // no host, so let's clear the queue and exit
        m_frameQueue.clear();
        return;
    }

    // if we're not connected, connect
    // the queue will be processed in onSocketConnected
    if (state() != QTcpSocket::ConnectedState) {
        qCDebug(aprsisclient_js8)
            << "APRSISClient Connecting:" << m_host << m_port;
        connectToHost(m_host, m_port);
        return;
    }

    // if we're connected but not logged in, wait
    if (!m_isLoggedIn) {
        return;
    }

    // process queue
    QQueue<QPair<QString, QDateTime>> delayed;

    while (!m_frameQueue.isEmpty()) {
        auto pair = m_frameQueue.head();
        auto frame = pair.first;
        auto timestamp = pair.second;

        // if the packet is older than the timeout, drop it.
        if (timestamp.secsTo(DriftingDateTime::currentDateTimeUtc()) >
            PACKET_TIMEOUT_SECONDS) {
            qCDebug(aprsisclient_js8)
                << "APRSISClient Packet Timeout:" << frame;
            m_frameQueue.dequeue();
            continue;
        }

        // random delay 25% of the time for throttling (a skip will add 60
        // seconds to the processing time)
        if (m_skipPercent > 0 && QRandomGenerator::global()->generate() % 100 <=
                                     (m_skipPercent * 100)) {
            qCDebug(aprsisclient_js8)
                << "APRSISClient Throttle: Skipping Frame";
            delayed.enqueue(m_frameQueue.dequeue());
            continue;
        }

        QByteArray data = frame.toLocal8Bit();
        if (write(data) == -1) {
            qCDebug(aprsisclient_js8)
                << "APRSISClient Write Error:" << errorString();
            return;
        }

        qCDebug(aprsisclient_js8) << "APRSISClient Write:" << data;
        m_frameQueue.dequeue();
    }

    // enqueue the delayed frames for later processing
    while (!delayed.isEmpty()) {
        m_frameQueue.enqueue(delayed.dequeue());
    }

    if (disconnect && !m_incomingRelayEnabled) {
        disconnectFromHost();
    }
}

/**
 * @brief Handle APRS-IS socket connection and login handshake.
 */
void APRSISClient::onSocketConnected() {
    qCDebug(aprsisclient_js8) << "APRSISClient Connected";

    QString loginFrameStr;
    if (m_incomingRelayEnabled) {
        // Include filter in login frame
        loginFrameStr = loginFrame(m_localCall, "filter t/m");
        qCDebug(aprsisclient_js8) << "APRSISClient Login with filter t/m";
    } else {
        loginFrameStr = loginFrame(m_localCall);
    }

    if (write(loginFrameStr.toLocal8Bit()) == -1) {
        qCDebug(aprsisclient_js8)
            << "APRSISClient Write Login Error:" << errorString();
        return;
    }

    // assume logged in for now, real verification is harder with async
    // but typically APRS-IS sends a line starting with #
    // we'll handle that in readyRead if needed, but for now allow writing
    m_isLoggedIn = true;

    // process any pending queue items
    processQueue(!m_incomingRelayEnabled);
}

/**
 * @brief Process incoming APRS-IS data and emit parsed messages.
 */
void APRSISClient::onSocketReadyRead() {
    while (canReadLine()) {
        auto line = QString::fromUtf8(readLine()).trimmed();
        if (line.isEmpty()) {
            continue;
        }

        qCDebug(aprsisclient_js8) << "APRSISClient Read:" << line;

        if (line.startsWith("#")) {
            // server comment / login response
            continue;
        }

        // Parse message for relay
        // Format: SOURCE>PATH::DEST     :MESSAGE
        // Regex: ^([^>]+)>[^:]+::([A-Z0-9 ]{9}):(.*)$
        static QRegularExpression msgRe("^([^>]+)>[^:]+::([A-Z0-9 ]{9}):(.*)$");
        auto match = msgRe.match(line);
        if (match.hasMatch()) {
            auto from = match.captured(1);
            auto to = match.captured(2).trimmed();
            auto msg = match.captured(3);
            auto messageId = extractMessageId(msg);

            qCDebug(aprsisclient_js8)
                << "APRSISClient Parsed Message:"
                << "From:" << from << "To:" << to << "Msg:" << msg
                << "Id:" << messageId;
            qDebug() << "DEBUG: APRS Parsed Message" << from << to << msg
                     << "Id:" << messageId;

            emit messageReceived(from, to, msg, messageId);
        } else {
            qCDebug(aprsisclient_js8)
                << "APRSISClient: No Regex Match for:" << line;
        }
    }
}

/**
 * @brief Handle APRS-IS socket disconnects and reconnect as needed.
 */
void APRSISClient::onSocketDisconnected() {
    qCDebug(aprsisclient_js8) << "APRSISClient Disconnected";
    m_isLoggedIn = false;

    if (m_incomingRelayEnabled) {
        // retry connection if we're supposed to be persistent
        QTimer::singleShot(5000, this, [this]() {
            if (m_incomingRelayEnabled &&
                state() != QTcpSocket::ConnectedState) {
                processQueue(false);
            }
        });
    }
}

/**
 * @brief Log APRS-IS socket errors.
 * @param socketError Underlying socket error.
 */
void APRSISClient::onSocketError(QAbstractSocket::SocketError) {
    qCDebug(aprsisclient_js8) << "APRSISClient Error:" << errorString();
}

Q_LOGGING_CATEGORY(aprsisclient_js8, "aprsisclient.js8", QtWarningMsg)
