/*
 * SPDX-License-Identifier: GPL-3.0-or-later
 * SPDX-FileCopyrightText: 2020-2021 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
 */
#include "actions_p.h"
#include "validation.h"

#include "../base32/base32.h"
#include "../logging_p.h"
#include "../oath/oath.h"

#include <QMetaEnum>
#include <QScopedPointer>
#include <QTimer>

#include <limits>

KEYSMITH_LOGGER(logger, ".accounts.actions")
KEYSMITH_LOGGER(dispatcherLogger, ".accounts.dispatcher")

static const quint64 maxCounter = std::numeric_limits<quint64>::max();
static const int hashTypeId = qRegisterMetaType<accounts::Account::Hash>();

namespace accounts
{
    AccountJob::AccountJob() :
        QObject()
    {
    }

    AccountJob::~AccountJob()
    {
    }

    Null::Null() :
        AccountJob()
    {
    }

    void Null::run(void)
    {
        Q_EMIT finished();
    }

    void AccountJob::run(void)
    {
        Q_ASSERT_X(false, Q_FUNC_INFO, "should be overridden in derived classes!");
    }

    RequestAccountPassword::RequestAccountPassword(const SettingsProvider &settings, AccountSecret *secret) :
        AccountJob(), m_settings(settings), m_secret(secret), m_failed(false), m_succeeded(false)
    {
    }

    LoadAccounts::LoadAccounts(const SettingsProvider &settings, const AccountSecret *secret,
                               const std::function<qint64(void)> &clock) :
        AccountJob(), m_settings(settings), m_secret(secret), m_clock(clock)
    {
    }

    DeleteAccounts::DeleteAccounts(const SettingsProvider &settings, const QSet<QUuid> &ids) :
        AccountJob(), m_settings(settings), m_ids(ids)
    {
    }

    SaveHotp::SaveHotp(const SettingsProvider &settings,
                       const QUuid id, const QString &accountName, const QString &issuer,
                       const secrets::EncryptedSecret &secret, uint tokenLength,
                       quint64 counter, const std::optional<uint> offset, bool checksum) :
        AccountJob(), m_settings(settings), m_id(id), m_accountName(accountName), m_issuer(issuer),
        m_secret(secret), m_tokenLength(tokenLength), m_counter(counter), m_offset(offset), m_checksum(checksum)
    {
    }

    SaveTotp::SaveTotp(const SettingsProvider &settings,
                       const QUuid id, const QString &accountName, const QString &issuer,
                       const secrets::EncryptedSecret &secret, uint tokenLength,
                       uint timeStep, const QDateTime &epoch, Account::Hash hash,
                       const std::function<qint64(void)> &clock) :
        AccountJob(), m_settings(settings), m_id(id), m_accountName(accountName), m_issuer(issuer),
        m_secret(secret), m_tokenLength(tokenLength), m_timeStep(timeStep), m_epoch(epoch), m_hash(hash), m_clock(clock)
    {
    }

    void SaveHotp::run(void)
    {
        if (!checkId(m_id) || !checkName(m_accountName) || !checkIssuer(m_issuer) ||
            !checkTokenLength(m_tokenLength) || !checkOffset(m_offset, QCryptographicHash::Sha1)) {
            qCDebug(logger)
                << "Unable to save HOTP account:" << m_id
                << "Invalid account details";
            Q_EMIT invalid();
            Q_EMIT finished();
            return;
        }

        const PersistenceAction act([this](QSettings &settings) -> void
        {
            if (!settings.isWritable()) {
                qCWarning(logger)
                    << "Unable to save HOTP account:" << m_id
                    << "Storage not writable";
                Q_EMIT invalid();
                return;
            }

            qCInfo(logger) << "Saving HOTP account:" << m_id;

            const QString group = m_id.toString();
            settings.remove(group);
            settings.beginGroup(group);
            settings.setValue(QStringLiteral("account"), m_accountName);
            if (!m_issuer.isNull()) {
                settings.setValue(QStringLiteral("issuer"), m_issuer);
            }
            settings.setValue(QStringLiteral("type"), QStringLiteral("hotp"));
            QString encodedNonce = QString::fromUtf8(m_secret.nonce().toBase64(QByteArray::Base64Encoding));
            QString encodedSecret = QString::fromUtf8(m_secret.cryptText().toBase64(QByteArray::Base64Encoding));
            settings.setValue(QStringLiteral("secret"), encodedSecret);
            settings.setValue(QStringLiteral("nonce"), encodedNonce);
            settings.setValue(QStringLiteral("counter"), m_counter);
            settings.setValue(QStringLiteral("pinLength"), m_tokenLength);
            if (m_offset) {
                settings.setValue(QStringLiteral("offset"), *m_offset);
            }
            settings.setValue(QStringLiteral("checksum"), m_checksum);
            settings.endGroup();

            // Try to guarantee that data will have been written before claiming the account was actually saved
            settings.sync();

            Q_EMIT saved(m_id, m_accountName, m_issuer, m_secret.cryptText(), m_secret.nonce(), m_tokenLength,
                         m_counter, m_offset.has_value(), m_offset ? *m_offset : 0U, m_checksum);
        });
        m_settings(act);

        Q_EMIT finished();
    }

    void SaveTotp::run(void)
    {
        if (!checkId(m_id) || !checkName(m_accountName) || !checkIssuer(m_issuer) ||
            !checkTokenLength(m_tokenLength) || !checkTimeStep(m_timeStep) || !checkEpoch(m_epoch, m_clock)) {
            qCDebug(logger)
                << "Unable to save TOTP account:" << m_id
                << "Invalid account details";
            Q_EMIT invalid();
            Q_EMIT finished();
            return;
        }

        const PersistenceAction act([this](QSettings &settings) -> void
        {
            if (!settings.isWritable()) {
                qCWarning(logger)
                    << "Unable to save TOTP account:" << m_id
                    << "Storage not writable";
                Q_EMIT invalid();
                return;
            }

            qCInfo(logger) << "Saving TOTP account:" << m_id;

            const QString group = m_id.toString();
            settings.remove(group);
            settings.beginGroup(group);
            settings.setValue(QStringLiteral("account"), m_accountName);
            if (!m_issuer.isNull()) {
                settings.setValue(QStringLiteral("issuer"), m_issuer);
            }
            settings.setValue(QStringLiteral("type"), QStringLiteral("totp"));
            QString encodedNonce = QString::fromUtf8(m_secret.nonce().toBase64(QByteArray::Base64Encoding));
            QString encodedSecret = QString::fromUtf8(m_secret.cryptText().toBase64(QByteArray::Base64Encoding));
            settings.setValue(QStringLiteral("secret"), encodedSecret);
            settings.setValue(QStringLiteral("nonce"), encodedNonce);
            settings.setValue(QStringLiteral("timeStep"), m_timeStep);
            settings.setValue(QStringLiteral("pinLength"), m_tokenLength);
            settings.setValue(QStringLiteral("epoch"), m_epoch.toUTC().toString(Qt::ISODateWithMs));
            settings.setValue(QStringLiteral("hash"), QVariant::fromValue<Account::Hash>(m_hash).toString());
            settings.endGroup();

            // Try to guarantee that data will have been written before claiming the account was actually saved
            settings.sync();

            Q_EMIT saved(m_id, m_accountName, m_issuer, m_secret.cryptText(), m_secret.nonce(), m_tokenLength,
                         m_timeStep, m_epoch, m_hash);
        });
        m_settings(act);

        Q_EMIT finished();
    }

    void DeleteAccounts::run(void)
    {
        const PersistenceAction act([this](QSettings &settings) -> void
        {
            if (!settings.isWritable()) {
                qCWarning(logger) << "Unable to delete accounts: storage not writable";
                Q_EMIT invalid();
                return;
            }

            qCInfo(logger) << "Deleting accounts";

            for (const QUuid &id : m_ids) {
                settings.remove(id.toString());
            }
        });
        m_settings(act);

        Q_EMIT finished();
    }

    void RequestAccountPassword::fail(void)
    {
        if (m_failed || m_succeeded) {
            qCDebug(logger) << "Suppressing 'failure' in unlocking accounts: already handled";
            return;
        }

        m_failed = true;
        QObject::disconnect(m_secret, &AccountSecret::requestsCancelled, this, &RequestAccountPassword::fail);
        QObject::disconnect(m_secret, &AccountSecret::passwordAvailable, this, &RequestAccountPassword::unlock);
        QObject::disconnect(m_secret, &AccountSecret::keyAvailable, this, &RequestAccountPassword::finish);
        Q_EMIT failed();
        Q_EMIT finished();
    }

    void RequestAccountPassword::unlock(void)
    {
        secrets::SecureMasterKey * derived = m_secret->deriveKey();
        std::optional<secrets::EncryptedSecret> challenge = m_secret->challenge();
        if (derived && challenge) {
            qCInfo(logger) << "Successfully derived key for storage";
            return;
        } else {
            qCInfo(logger) << "Failed to unlock storage:"
                << "Unable to derive secret encryption/decryption key or generate its matching challenge";
        }
    }

    void RequestAccountPassword::finish(void)
    {
        if (m_succeeded || m_failed) {
            qCDebug(logger) << "Suppressing 'success' in unlocking accounts: already handled";
            return;
        }

        QObject::disconnect(m_secret, &AccountSecret::requestsCancelled, this, &RequestAccountPassword::fail);
        QObject::disconnect(m_secret, &AccountSecret::passwordAvailable, this, &RequestAccountPassword::unlock);
        QObject::disconnect(m_secret, &AccountSecret::keyAvailable, this, &RequestAccountPassword::finish);
        std::optional<secrets::EncryptedSecret> challenge = m_secret->challenge();
        secrets::SecureMasterKey * derived = m_secret->key();
        if (!derived) {
            qCInfo(logger) << "Failed to finish unlocking storage: no secret encryption/decryption key";
            m_failed = true;
            Q_EMIT failed();
            Q_EMIT finished();
            return;
        }

        // sanity check: challenge should be available once key derivation has completed successfully
        if (!challenge) {
            qCInfo(logger) << "Failed to finish unlocking storage: no challenge for encryption/decryption key";
            m_failed = true;
            Q_EMIT failed();
            Q_EMIT finished();
            return;
        }

        bool ok = false;
        m_settings([derived, &challenge, &ok](QSettings &settings) -> void
        {
            if (!settings.isWritable()) {
                qCWarning(logger) << "Unable to save account secret key parameters: storage not writable";
                return;
            }

            const secrets::KeyDerivationParameters params = derived->params();

            QString encodedSalt = QString::fromUtf8(derived->salt().toBase64(QByteArray::Base64Encoding));
            QString encodedChallenge = QString::fromUtf8(challenge->cryptText().toBase64(QByteArray::Base64Encoding));
            QString encodedNonce = QString::fromUtf8(challenge->nonce().toBase64(QByteArray::Base64Encoding));
            settings.beginGroup(QStringLiteral("master-key"));
            settings.setValue(QStringLiteral("salt"), encodedSalt);
            settings.setValue(QStringLiteral("cpu"), params.cpuCost());
            settings.setValue(QStringLiteral("memory"), (quint64) params.memoryCost());
            settings.setValue(QStringLiteral("algorithm"), params.algorithm());
            settings.setValue(QStringLiteral("length"), params.keyLength());
            settings.setValue(QStringLiteral("nonce"), encodedNonce);
            settings.setValue(QStringLiteral("challenge"), encodedChallenge);
            settings.endGroup();
            ok = true;
        });

        if (ok) {
            qCInfo(logger) << "Successfully unlocked storage";
            m_succeeded = true;
            Q_EMIT unlocked();
        } else {
            qCInfo(logger) << "Failed to finish unlocking storage: unable to store parameters";
            m_failed = true;
            Q_EMIT failed();
        }
        Q_EMIT finished();
    }

    void RequestAccountPassword::run(void)
    {
        if (!m_secret) {
            qCDebug(logger) << "Unable to request accounts password: no account secret object";
            m_failed = true;
            Q_EMIT failed();
            Q_EMIT finished();
            return;
        }

        QObject::connect(m_secret, &AccountSecret::passwordAvailable, this, &RequestAccountPassword::unlock);
        QObject::connect(m_secret, &AccountSecret::requestsCancelled, this, &RequestAccountPassword::fail);
        QObject::connect(m_secret, &AccountSecret::keyAvailable, this, &RequestAccountPassword::finish);

        if (!m_secret->isStillAlive()) {
            qCDebug(logger) << "Unable to request accounts password: account secret marked for death";
            fail();
            return;
        }

        bool ok = false;
        m_settings([this, &ok](QSettings &settings) -> void
        {
            if (!settings.isWritable()) {
                qCWarning(logger) << "Unable to request password for accounts: storage not writable";
                return;
            }

            QStringList groups = settings.childGroups();
            if (!groups.contains(QStringLiteral("master-key"))) {
                qCInfo(logger) << "No key derivation parameters found: requesting 'new' password for accounts";
                ok = m_secret->requestNewPassword();
                return;
            }

            settings.beginGroup(QStringLiteral("master-key"));
            QByteArray salt;
            QByteArray nonce;
            QByteArray challenge;
            quint64 cpuCost = 0ULL;
            quint64 keyLength = 0ULL;
            size_t memoryCost = 0ULL;
            // HACK: disables challenge verification, remove at some point!
            bool challengeAvailable = settings.contains(QStringLiteral("challenge"));
            int algorithm = settings.value(QStringLiteral("algorithm")).toInt(&ok);
            if (ok) {
                ok = false;
                keyLength = settings.value(QStringLiteral("length")).toULongLong(&ok);
            }
            if (ok) {
                ok = false;
                cpuCost = settings.value(QStringLiteral("cpu")).toULongLong(&ok);
            }
            if (ok) {
                ok = false;
                memoryCost = settings.value(QStringLiteral("memory")).toULongLong(&ok);
            }
            if (ok) {
                QByteArray encodedSalt = settings.value(QStringLiteral("salt")).toString().toUtf8();
                salt = QByteArray::fromBase64(encodedSalt, QByteArray::Base64Encoding);
                ok = !salt.isEmpty() && secrets::SecureMasterKey::validate(salt);
            }

            // HACK: disables challenge verification, remove at some point!
            if (challengeAvailable && ok) {
                QByteArray encodedChallenge = settings.value(QStringLiteral("challenge")).toString().toUtf8();
                challenge = QByteArray::fromBase64(encodedChallenge, QByteArray::Base64Encoding);
                ok = !challenge.isEmpty();
            }
            // HACK: disables challenge verification, remove at some point!
            if (challengeAvailable && ok) {
                QByteArray encodedNonce = settings.value(QStringLiteral("nonce")).toString().toUtf8();
                nonce = QByteArray::fromBase64(encodedNonce, QByteArray::Base64Encoding);
                ok = !nonce.isEmpty();
            }
            settings.endGroup();

            const auto params = secrets::KeyDerivationParameters::create(keyLength, algorithm, memoryCost, cpuCost);
            const auto encryptedChallenge = secrets::EncryptedSecret::from(challenge, nonce);

            // HACK: disables challenge verification, remove at some point!
            if (!ok || !params || !secrets::SecureMasterKey::validate(*params) || (challengeAvailable && !encryptedChallenge)) {
                qCDebug(logger) << "Unable to request 'existing' password: invalid challenge, nonce, salt or key derivation parameters";
                return;
            }

            qCInfo(logger) << "Requesting 'existing' password for accounts";
            ok = challengeAvailable
                ? m_secret->requestExistingPassword(*encryptedChallenge, salt, *params)
                : m_secret->requestExistingPassword(salt, *params); // HACK: disables challenge verification, remove at some point!
        });

        if (!ok) {
            qCInfo(logger) << "Unable to unlock storage: failed to request password for accounts";
            fail();
        }
    }

    void LoadAccounts::run(void)
    {
        if (!m_secret || !m_secret->key()) {
            qCDebug(logger) << "Unable to load accounts: secret decryption key not available";
            Q_EMIT finished();
            return;
        }

        bool failed = false;
        const PersistenceAction act([this, &failed](QSettings &settings) -> void
        {
            qCInfo(logger, "Loading accounts from storage");
            const QStringList entries = settings.childGroups();
            for (const QString &group : entries) {
                if (group == QLatin1String("master-key")) {
                    continue;
                }

                const QUuid id(group);
                if (id.isNull()) {
                    qCDebug(logger)
                        << "Ignoring:" << group
                        << "Not an account section";
                    failed = true;
                    continue;
                }

                settings.beginGroup(group);

                const QString accountName = settings.value(QStringLiteral("account")).toString();
                if (!checkName(accountName)) {
                    qCWarning(logger)
                        << "Skipping invalid account:" << id
                        << "Invalid account name";
                    settings.endGroup();
                    continue;
                }

                const QString issuer = settings.value(QStringLiteral("issuer"), QString()).toString();
                if (!checkIssuer(issuer)) {
                    qCWarning(logger)
                        << "Skipping invalid account:" << id
                        << "Invalid account issuer";
                    settings.endGroup();
                    continue;
                }

                const QString type = settings.value(QStringLiteral("type")).toString();
                if (type != QStringLiteral("hotp") && type != QStringLiteral("totp")) {
                    qCWarning(logger)
                        << "Skipping invalid account:" << id
                        << "Invalid account type";
                    settings.endGroup();
                    failed = true;
                    continue;
                }

                bool ok = false;
                const int tokenLength = settings.value(QStringLiteral("pinLength")).toInt(&ok);
                if (!ok || !checkTokenLength(tokenLength)) {
                    qCWarning(logger)
                        << "Skipping invalid account:" << id
                        << "Invalid token length";
                    settings.endGroup();
                    failed = true;
                    continue;
                }

                const QByteArray encodedNonce = settings.value(QStringLiteral("nonce")).toString().toUtf8();
                const QByteArray encodedSecret = settings.value(QStringLiteral("secret")).toString().toUtf8();
                const QByteArray nonce = QByteArray::fromBase64(encodedNonce, QByteArray::Base64Encoding);
                const QByteArray secret = QByteArray::fromBase64(encodedSecret, QByteArray::Base64Encoding);

                const auto encryptedSecret = secrets::EncryptedSecret::from(secret, nonce);
                if (!encryptedSecret) {
                    qCWarning(logger)
                        << "Skipping invalid account:" << id
                        << "Invalid token secret";
                    settings.endGroup();
                    failed = true;
                    continue;
                }

                QScopedPointer<secrets::SecureMemory> decrypted(m_secret->decrypt(*encryptedSecret));
                if (!decrypted) {
                    qCWarning(logger)
                        << "Skipping invalid account:" << id
                        << "Unable to decrypt token secret";
                    settings.endGroup();
                    failed = true;
                    continue;
                }

                if (type == QStringLiteral("totp")) {
                    ok = false;
                    const uint timeStep = settings.value(QStringLiteral("timeStep")).toUInt(&ok);
                    if (!ok || !checkTimeStep(timeStep)) {
                        qCWarning(logger)
                            << "Skipping invalid account:" << id
                            << "Invalid time step";
                        settings.endGroup();
                        failed = true;
                        continue;
                    }

                    const QDateTime epoch = settings.value(QStringLiteral("epoch"), QDateTime::fromMSecsSinceEpoch(0))
                        .toDateTime();
                    if (!checkEpoch(epoch, m_clock)) {
                        qCWarning(logger)
                            << "Skipping invalid account:" << id
                            << "Invalid epoch";
                        settings.endGroup();
                        failed = true;
                        continue;
                    }

                    ok = false;

                    const auto hashEnum = QMetaEnum::fromType<accounts::Account::Hash>();
                    const auto hashDefault = QVariant::fromValue<accounts::Account::Hash>(accounts::Account::Sha1);
                    const QByteArray hashName = settings.value(QStringLiteral("hash"), hashDefault).toByteArray();
                    int hash = hashEnum.keyToValue(hashName.constData(), &ok);
                    if (!ok) {
                        qCWarning(logger)
                            << "Skipping invalid account:" << id
                            << "Invalid hash";
                        settings.endGroup();
                        failed = true;
                        continue;
                    }

                    qCInfo(logger) << "Found valid TOTP account:" << id;
                    Q_EMIT foundTotp(id, accountName, issuer, secret, nonce, tokenLength,
                                     timeStep, epoch, (Account::Hash) hash);
                }

                if (type == QStringLiteral("hotp")) {
                    ok = false;
                    const quint64 counter = settings.value(QStringLiteral("counter")).toULongLong(&ok);
                    if (!ok) {
                        qCWarning(logger)
                            << "Skipping invalid account:" << id
                            << "Invalid counter";
                        settings.endGroup();
                        failed = true;
                        continue;
                    }

                    const QVariant offsetVariant = settings.value(QStringLiteral("offset"));
                    ok = offsetVariant.isNull();
                    std::optional<uint> offset = ok ? std::nullopt : std::optional<uint>(offsetVariant.toUInt(&ok));

                    if (!ok || !checkOffset(offset, QCryptographicHash::Sha1)) {
                        qCWarning(logger)
                            << "Skipping invalid account:" << id
                            << "Invalid offset";
                        settings.endGroup();
                        failed = true;
                        continue;
                    }

                    const auto checkSumOff = QStringLiteral("false");
                    const auto checksum = settings.value(QStringLiteral("checksum"), checkSumOff).toString();
                    if (checksum != QStringLiteral("true") && checksum != checkSumOff) {
                        qCWarning(logger)
                            << "Skipping invalid account:" << id
                            << "Invalid checksum";
                        settings.endGroup();
                        failed = true;
                        continue;
                    }

                    qCInfo(logger) << "Found valid HOTP account:" << id;
                    Q_EMIT foundHotp(id, accountName, issuer, secret, nonce, tokenLength,
                                     counter, offset.has_value(), offset ? *offset : 0U,
                                     checksum == QStringLiteral("true"));
                }

                settings.endGroup();
            }
        });
        m_settings(act);

        if (failed) {
            Q_EMIT failedToLoadAllAccounts();
        }
        Q_EMIT finished();
    }

    static std::optional<QString> computeToken(const AccountSecret *accountSecret,
                                               const secrets::EncryptedSecret &tokenSecret,
                                               const oath::Algorithm &algorithm,
                                               quint64 counter)
    {
        QScopedPointer<secrets::SecureMemory> secret(accountSecret->decrypt(tokenSecret));
        if (!secret) {
            qCDebug(logger) << "Unable to compute token: failed to decrypt account secret";
            return std::nullopt;
        }

        return algorithm.compute(counter, reinterpret_cast<char*>(secret->data()), secret->size());
    }


    ComputeTotp::ComputeTotp(const AccountSecret *secret,
                             const secrets::EncryptedSecret &tokenSecret, uint tokenLength,
                             const QDateTime &epoch, uint timeStep, const Account::Hash hash,
                             const std::function<qint64(void)> &clock) :
        AccountJob(), m_secret(secret), m_tokenSecret(tokenSecret), m_tokenLength(tokenLength),
        m_epoch(epoch), m_timeStep(timeStep), m_hash(hash), m_clock(clock)
    {
    }

    void ComputeTotp::run(void)
    {
        if (!m_secret || !m_secret->key()) {
            qCDebug(logger) << "Unable to compute TOTP token: secret decryption key not available";
            Q_EMIT finished();
            return;
        }

        if (!checkTokenLength(m_tokenLength)) {
            qCDebug(logger) << "Unable to compute TOTP token: invalid token length:" << m_tokenLength;
            Q_EMIT finished();
            return;
        }

        if (!checkTimeStep(m_timeStep)) {
            qCDebug(logger) << "Unable to compute TOTP token: invalid time step:" << m_timeStep;
            Q_EMIT finished();
            return;
        }

        if (!checkEpoch(m_epoch, m_clock)) {
            qCDebug(logger) << "Unable to compute TOTP token: invalid epoch:" << m_epoch;
            Q_EMIT finished();
            return;
        }

        QCryptographicHash::Algorithm hash;
        switch(m_hash)
        {
        case Account::Hash::Sha1:
            hash = QCryptographicHash::Sha1;
            break;
        case Account::Hash::Sha256:
            hash = QCryptographicHash::Sha256;
            break;
        case Account::Hash::Sha512:
            hash = QCryptographicHash::Sha512;
            break;
        default:
            qCDebug(logger) << "Unable to compute TOTP token: unknown hashing algorithm:" << m_hash;
            Q_EMIT finished();
            return;

        }

        const std::optional<oath::Algorithm> algorithm = oath::Algorithm::totp(hash, m_tokenLength);
        if (!algorithm) {
            qCDebug(logger) << "Unable to compute TOTP token: failed to construct algorithm";
            Q_EMIT finished();
            return;
        }

        const std::optional<quint64> counter = oath::count(m_epoch, m_timeStep, m_clock);
        if (!counter) {
            qCDebug(logger) << "Unable to compute TOTP token: failed to count time steps";
            Q_EMIT finished();
            return;
        }

        const auto counterValue = *counter;
        const auto validFrom = counterValue < maxCounter
            ? oath::fromCounter(counterValue + 1ULL, m_epoch, m_timeStep)
            : std::nullopt;
        const auto validUntil = counterValue < (maxCounter - 1ULL)
            ? oath::fromCounter(counterValue + 2ULL, m_epoch, m_timeStep)
            : std::nullopt;
        if (!validFrom || !validUntil) {
            qCDebug(logger) << "Unable to compute TOTP token: failed to determine expiry datetime of tokens";
            Q_EMIT finished();
            return;
        }

        const auto token = computeToken(m_secret, m_tokenSecret, *algorithm, counterValue);
        const auto nextToken = token
            ? computeToken(m_secret, m_tokenSecret, *algorithm, counterValue + 1ULL)
            : std::nullopt;
        if (token && nextToken) {
            Q_EMIT otp(*token, *nextToken, *validFrom, *validUntil);
        } else {
            qCDebug(logger) << "Failed to compute TOTP tokens";
        }

        Q_EMIT finished();
    }

    ComputeHotp::ComputeHotp(const AccountSecret *secret,
                             const secrets::EncryptedSecret &tokenSecret, uint tokenLength,
                             quint64 counter, const std::optional<uint> offset, bool checksum) :
        AccountJob(), m_secret(secret), m_tokenSecret(tokenSecret), m_tokenLength(tokenLength),
        m_counter(counter), m_offset(offset), m_checksum(checksum)
    {
    }

    void ComputeHotp::run(void)
    {
        if (!m_secret || !m_secret->key()) {
            qCDebug(logger) << "Unable to compute HOTP token: secret decryption key not available";
            Q_EMIT finished();
            return;
        }

        if (!checkTokenLength(m_tokenLength)) {
            qCDebug(logger) << "Unable to compute HOTP token: invalid token length:" << m_tokenLength;
            Q_EMIT finished();
            return;
        }

        if (!checkOffset(m_offset, QCryptographicHash::Sha1)) {
            qCDebug(logger) << "Unable to compute HOTP token: invalid offset:" << *m_offset;
            Q_EMIT finished();
            return;
        }

        const std::optional<oath::Algorithm> algorithm = oath::Algorithm::hotp(m_offset, m_tokenLength, m_checksum);
        if (!algorithm) {
            qCDebug(logger) << "Unable to compute HOTP token: failed to construct algorithm";
            Q_EMIT finished();
            return;
        }

        if (m_counter == maxCounter) {
            qCDebug(logger) << "Unable to compute HOTP token: counter reached its limit";
            Q_EMIT finished();
            return;
        }

        const auto token = computeToken(m_secret, m_tokenSecret, *algorithm, m_counter);
        const auto nextToken = token
            ? computeToken(m_secret, m_tokenSecret, *algorithm, m_counter + 1ULL)
            : std::nullopt;
        if (token && nextToken) {
            Q_EMIT otp(*token, *nextToken, m_counter + 1ULL);
        } else {
            qCDebug(logger) << "Failed to compute HOTP tokens";
        }

        Q_EMIT finished();
    }

    Dispatcher::Dispatcher(QThread *thread, QObject *parent) :
        QObject(parent), m_thread(thread),  m_current(nullptr)
    {
    }

    bool Dispatcher::empty(void) const
    {
        return m_pending.isEmpty();
    }

    void Dispatcher::queueAndProceed(AccountJob *job, const std::function<void(void)> &setup_callbacks)
    {
        if (job) {
            qCDebug(dispatcherLogger) << "Queuing job for dispatcher";
            job->moveToThread(m_thread);
            setup_callbacks();
            m_pending.append(job);
            dispatchNext();
        }
    }

    void Dispatcher::dispatchNext(void)
    {
        qCDebug(dispatcherLogger) << "Handling request to dispatch next job";

        if (!empty() && !m_current) {
            qCDebug(dispatcherLogger) << "Dispatching next job";

            m_current = m_pending.takeFirst();
            QObject::connect(m_current, &AccountJob::finished, this, &Dispatcher::next);
            QObject::connect(this, &Dispatcher::dispatch, m_current, &AccountJob::run);
            Q_EMIT dispatch();
        }
    }

    void Dispatcher::next(void)
    {
        qCDebug(dispatcherLogger) << "Handling next continuation in dispatcher";

        QObject *from = sender();
        AccountJob *job = from ? qobject_cast<AccountJob*>(from) : nullptr;
        if (job) {
            Q_ASSERT_X(job == m_current, Q_FUNC_INFO, "sender() should match 'current' job!");
            QObject::disconnect(this, &Dispatcher::dispatch, job, &AccountJob::run);
            // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks): False positives with QTimer::singleShot
            QTimer::singleShot(0, job, &AccountJob::deleteLater);
            m_current = nullptr;
            dispatchNext();
        }
    }
}
