/**
 * SPDX-FileCopyrightText: 2021 Tobias Fella <tobias.fella@kde.org>
 * SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
 *
 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
 */

#include "sync.h"
#include "synclogging.h"

#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSqlQuery>
#include <QString>
#include <QSysInfo>
#include <QTimer>

#include <KFormat>
#include <KLocalizedString>

#include <qt6keychain/keychain.h>

#include "audiomanager.h"
#include "database.h"
#include "datamanager.h"
#include "entry.h"
#include "fetcher.h"
#include "models/errorlogmodel.h"
#include "settingsmanager.h"
#include "sync/gpodder/devicerequest.h"
#include "sync/gpodder/episodeactionrequest.h"
#include "sync/gpodder/gpodder.h"
#include "sync/gpodder/logoutrequest.h"
#include "sync/gpodder/subscriptionrequest.h"
#include "sync/gpodder/syncrequest.h"
#include "sync/gpodder/updatedevicerequest.h"
#include "sync/gpodder/updatesyncrequest.h"
#include "sync/gpodder/uploadepisodeactionrequest.h"
#include "sync/gpodder/uploadsubscriptionrequest.h"
#include "sync/syncjob.h"
#include "sync/syncutils.h"
#include "utils/fetchfeedsjob.h"
#include "utils/networkconnectionmanager.h"
#include "utils/storagemanager.h"

using namespace SyncUtils;

Sync::Sync()
    : QObject()
{
    connect(this, &Sync::error, &ErrorLogModel::instance(), &ErrorLogModel::monitorErrorMessages);
    connect(&AudioManager::instance(), &AudioManager::playbackStateChanged, this, &Sync::doQuickSync);

    retrieveCredentialsFromConfig();
}

void Sync::retrieveCredentialsFromConfig()
{
    if (!SettingsManager::self()->syncEnabled()) {
        m_syncEnabled = false;
        Q_EMIT syncEnabledChanged();
    } else if (!SettingsManager::self()->syncUsername().isEmpty()) {
        m_username = SettingsManager::self()->syncUsername();
        m_hostname = SettingsManager::self()->syncHostname();
        m_provider = static_cast<Provider>(SettingsManager::self()->syncProvider());

        connect(this, &Sync::passwordRetrievalFinished, this, [=](QString password) {
            disconnect(this, &Sync::passwordRetrievalFinished, this, nullptr);
            if (!password.isEmpty()) {
                m_syncEnabled = SettingsManager::self()->syncEnabled();
                m_password = password;
                m_hostname = SettingsManager::self()->syncHostname();

                if (m_provider == Provider::GPodderNet) {
                    m_device = SettingsManager::self()->syncDevice();
                    m_deviceName = SettingsManager::self()->syncDeviceName();

                    if (m_syncEnabled && !m_username.isEmpty() && !m_password.isEmpty() && !m_device.isEmpty()) {
                        if (m_hostname.isEmpty()) { // use default official server
                            m_gpodder = new GPodder(m_username, m_password, this);
                        } else { // i.e. custom gpodder host
                            m_gpodder = new GPodder(m_username, m_password, m_hostname, m_provider, this);
                        }
                    }
                } else if (m_provider == Provider::GPodderNextcloud) {
                    if (m_syncEnabled && !m_username.isEmpty() && !m_password.isEmpty() && !m_hostname.isEmpty()) {
                        m_gpodder = new GPodder(m_username, m_password, m_hostname, m_provider, this);
                    }
                }

                m_syncEnabled = SettingsManager::self()->syncEnabled();
                Q_EMIT syncEnabledChanged();

                // Now that we have all credentials we can do the initial sync if
                // it's enabled in the config.  If it's not enabled, then we handle
                // the automatic refresh through Main.qml
                if (NetworkConnectionManager::instance().feedUpdatesAllowed()) {
                    if (SettingsManager::self()->refreshOnStartup() && SettingsManager::self()->syncWhenUpdatingFeeds()) {
                        doRegularSync(true);
                    }
                }
            } else {
                // Ask for password and try to log in; if it succeeds, try
                // again to save the password.
                m_syncEnabled = false;
                QTimer::singleShot(0, this, [this]() {
                    Q_EMIT passwordInputRequired();
                });
            }
        });
        retrievePasswordFromKeyChain(m_username);
    }
}

bool Sync::syncEnabled() const
{
    return m_syncEnabled;
}

QString Sync::username() const
{
    return m_username;
}

QString Sync::password() const
{
    return m_password;
}

QString Sync::device() const
{
    return m_device;
}

QString Sync::deviceName() const
{
    return m_deviceName;
}

QString Sync::hostname() const
{
    return m_hostname;
}

Provider Sync::provider() const
{
    return m_provider;
}

QVector<Device> Sync::deviceList() const
{
    return m_deviceList;
}

QString Sync::lastSuccessfulSync(const QStringList &matchingLabels) const
{
    qulonglong timestamp = 0;
    QSqlQuery query;
    query.prepare(QStringLiteral("SELECT * FROM SyncTimeStamps;"));
    Database::instance().execute(query);
    while (query.next()) {
        QString label = query.value(QStringLiteral("syncservice")).toString();
        bool match = matchingLabels.isEmpty() || matchingLabels.contains(label);
        if (match) {
            qulonglong timestampDB = query.value(QStringLiteral("timestamp")).toULongLong();
            if (timestampDB > timestamp) {
                timestamp = timestampDB;
            }
        }
    }

    if (timestamp > 1) {
        QDateTime datetime = QDateTime::fromSecsSinceEpoch(timestamp);
        return m_kformat.formatRelativeDateTime(datetime, QLocale::ShortFormat);
    } else {
        return i18n("Never");
    }
}

QString Sync::lastSuccessfulDownloadSync() const
{
    QStringList labels = {subscriptionTimestampLabel, episodeTimestampLabel};
    return lastSuccessfulSync(labels);
}

QString Sync::lastSuccessfulUploadSync() const
{
    QStringList labels = {uploadSubscriptionTimestampLabel, uploadEpisodeTimestampLabel};
    return lastSuccessfulSync(labels);
}

QString Sync::suggestedDevice() const
{
    return QStringLiteral("kasts-") + QSysInfo::machineHostName();
}

QString Sync::suggestedDeviceName() const
{
    return i18nc("Suggested description for this device on gpodder sync service; argument is the hostname", "Kasts on %1", QSysInfo::machineHostName());
}

void Sync::setSyncEnabled(bool status)
{
    m_syncEnabled = status;
    SettingsManager::self()->setSyncEnabled(m_syncEnabled);
    SettingsManager::self()->save();
    Q_EMIT syncEnabledChanged();
}

void Sync::setPassword(const QString &password)
{
    // this method is used to set the password if the proper credentials could
    // not be retrieved from the keychain or file
    connect(this, &Sync::passwordSaveFinished, this, [=]() {
        disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
        QTimer::singleShot(0, this, [this]() {
            retrieveCredentialsFromConfig();
        });
    });
    savePasswordToKeyChain(m_username, password);
}

void Sync::setDevice(const QString &device)
{
    m_device = device;
    SettingsManager::self()->setSyncDevice(m_device);
    SettingsManager::self()->save();
    Q_EMIT deviceChanged();
}

void Sync::setDeviceName(const QString &deviceName)
{
    m_deviceName = deviceName;
    SettingsManager::self()->setSyncDeviceName(m_deviceName);
    SettingsManager::self()->save();
    Q_EMIT deviceNameChanged();
}

void Sync::setHostname(const QString &hostname)
{
    if (hostname.isEmpty()) {
        m_hostname.clear();
    } else {
        QString cleanedHostname = hostname;
        QUrl hostUrl = QUrl(hostname);

        if (hostUrl.scheme().isEmpty()) {
            hostUrl.setScheme(QStringLiteral("https"));
            if (hostUrl.authority().isEmpty() && !hostUrl.path().isEmpty()) {
                hostUrl.setAuthority(hostUrl.path());
                hostUrl.setPath(QStringLiteral(""));
            }
            cleanedHostname = hostUrl.toString();
        }

        m_hostname = cleanedHostname;
    }

    SettingsManager::self()->setSyncHostname(m_hostname);
    SettingsManager::self()->save();
    Q_EMIT hostnameChanged();
}

void Sync::setProvider(const Provider provider)
{
    m_provider = provider;
    SettingsManager::self()->setSyncProvider(m_provider);
    SettingsManager::self()->save();
    Q_EMIT providerChanged();
}

void Sync::login(const QString &username, const QString &password)
{
    if (m_gpodder) {
        delete m_gpodder;
        m_gpodder = nullptr;
    }

    m_deviceList.clear();

    if (m_provider == Provider::GPodderNextcloud) {
        m_gpodder = new GPodder(username, password, m_hostname, Provider::GPodderNextcloud, this);

        SubscriptionRequest *subRequest = m_gpodder->getSubscriptionChanges(0, QStringLiteral(""));
        connect(subRequest, &SubscriptionRequest::finished, this, [=]() {
            if (subRequest->error() || subRequest->aborted()) {
                if (subRequest->error()) {
                    Q_EMIT error(Error::Type::SyncError,
                                 QStringLiteral(""),
                                 QStringLiteral(""),
                                 subRequest->error(),
                                 subRequest->errorString(),
                                 i18n("Could not log into GPodder-nextcloud server"));
                }
                if (m_syncEnabled) {
                    setSyncEnabled(false);
                }
            } else {
                connect(this, &Sync::passwordSaveFinished, this, [=](bool success) {
                    disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
                    if (success) {
                        m_username = username;
                        m_password = password;
                        SettingsManager::self()->setSyncUsername(username);
                        SettingsManager::self()->save();
                        Q_EMIT credentialsChanged();

                        setSyncEnabled(true);
                        Q_EMIT loginSucceeded();
                    }
                });
                savePasswordToKeyChain(username, password);
            }
            subRequest->deleteLater();
        });
    } else {
        if (m_hostname.isEmpty()) { // official gpodder.net server
            m_gpodder = new GPodder(username, password, this);
        } else { // custom server
            m_gpodder = new GPodder(username, password, m_hostname, Provider::GPodderNet, this);
        }

        DeviceRequest *deviceRequest = m_gpodder->getDevices();
        connect(deviceRequest, &DeviceRequest::finished, this, [=]() {
            if (deviceRequest->error() || deviceRequest->aborted()) {
                if (deviceRequest->error()) {
                    Q_EMIT error(Error::Type::SyncError,
                                 QStringLiteral(""),
                                 QStringLiteral(""),
                                 deviceRequest->error(),
                                 deviceRequest->errorString(),
                                 i18n("Could not log into GPodder server"));
                }
                m_gpodder->deleteLater();
                m_gpodder = nullptr;
                if (m_syncEnabled) {
                    setSyncEnabled(false);
                }
            } else {
                m_deviceList = deviceRequest->devices();

                connect(this, &Sync::passwordSaveFinished, this, [=](bool success) {
                    disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
                    if (success) {
                        m_username = username;
                        m_password = password;
                        SettingsManager::self()->setSyncUsername(username);
                        SettingsManager::self()->save();
                        Q_EMIT credentialsChanged();

                        Q_EMIT loginSucceeded();
                        Q_EMIT deviceListReceived(); // required in order to open follow-up device-pick dialog
                    }
                });
                savePasswordToKeyChain(username, password);
            }
            deviceRequest->deleteLater();
        });
    }
}

void Sync::logout()
{
    if (m_provider == Provider::GPodderNextcloud) {
        clearSettings();
    } else {
        if (!m_gpodder) {
            clearSettings();
            return;
        }
        LogoutRequest *logoutRequest = m_gpodder->logout();
        connect(logoutRequest, &LogoutRequest::finished, this, [=]() {
            if (logoutRequest->error() || logoutRequest->aborted()) {
                if (logoutRequest->error()) {
                    // Let's not report this error, since it doesn't matter anyway:
                    // 1) If we're not logged in, there's no problem
                    // 2) If we are logged in, but somehow cannot log out, then it
                    //    shouldn't matter either, since the session probably expired
                    /*
                    Q_EMIT error(Error::Type::SyncError,
                                QStringLiteral(""),
                                QStringLiteral(""),
                                logoutRequest->error(),
                                logoutRequest->errorString(),
                                i18n("Could not log out of GPodder server"));
                    */
                }
            }
            clearSettings();
        });
    }
}

void Sync::clearSettings()
{
    if (m_gpodder) {
        m_gpodder->deleteLater();
        m_gpodder = nullptr;
    }

    QSqlQuery query;
    // Delete pending EpisodeActions
    query.prepare(QStringLiteral("DELETE FROM EpisodeActions;"));
    Database::instance().execute(query);

    // Delete pending FeedActions
    query.prepare(QStringLiteral("DELETE FROM FeedActions;"));
    Database::instance().execute(query);

    // Delete SyncTimestamps
    query.prepare(QStringLiteral("DELETE FROM SyncTimestamps;"));
    Database::instance().execute(query);

    setSyncEnabled(false);

    // Delete password from keychain and password file
    deletePasswordFromKeychain(m_username);

    m_username.clear();
    m_password.clear();
    m_device.clear();
    m_deviceName.clear();
    m_hostname.clear();
    m_provider = Provider::GPodderNet;
    SettingsManager::self()->setSyncUsername(m_username);
    SettingsManager::self()->setSyncDevice(m_device);
    SettingsManager::self()->setSyncDeviceName(m_deviceName);
    SettingsManager::self()->setSyncHostname(m_hostname);
    SettingsManager::self()->setSyncProvider(static_cast<int>(m_provider));
    SettingsManager::self()->save();

    Q_EMIT credentialsChanged();
    Q_EMIT hostnameChanged();
    Q_EMIT syncProgressChanged();
}

void Sync::onWritePasswordJobFinished(QKeychain::WritePasswordJob *job, const QString &username, const QString &password)
{
    if (job->error()) {
        qCDebug(kastsSync) << "Could not save password to the keychain: " << qPrintable(job->errorString());
        // fall back to file
        savePasswordToFile(username, password);
    } else {
        qCDebug(kastsSync) << "Password saved to keychain";
        Q_EMIT passwordSaveFinished(true);
    }
    job->deleteLater();
}

void Sync::savePasswordToKeyChain(const QString &username, const QString &password)
{
    qCDebug(kastsSync) << "Save the password to the keychain for" << username;

#ifndef Q_OS_WINDOWS
    QKeychain::WritePasswordJob *job = new QKeychain::WritePasswordJob(qAppName(), this);
    job->setAutoDelete(false);
    job->setKey(username);
    job->setTextData(password);

    QKeychain::WritePasswordJob::connect(job, &QKeychain::Job::finished, this, [this, username, password, job]() {
        onWritePasswordJobFinished(job, username, password);
    });
    job->start();
#endif
}

void Sync::savePasswordToFile(const QString &username, const QString &password)
{
    qCDebug(kastsSync) << "Save the password to file for" << username;

    // NOTE: Store in the same location as database, which can be different from
    //       the storagePath
    QString filePath = StorageManager::instance().passwordFilePath(username);

    QFile passwordFile(filePath);
    passwordFile.remove();

    QDir fileDir = QFileInfo(passwordFile).dir();
    if (!((fileDir.exists() || fileDir.mkpath(QStringLiteral("."))) && passwordFile.open(QFile::WriteOnly))) {
        Q_EMIT error(Error::Type::SyncError,
                     passwordFile.fileName(),
                     QStringLiteral(""),
                     0,
                     i18n("I/O denied: Cannot save password."),
                     i18n("I/O denied: Cannot save password."));
        Q_EMIT passwordSaveFinished(false);
    } else {
        passwordFile.write(password.toUtf8());
        passwordFile.close();
        Q_EMIT passwordSaveFinished(true);
    }
}

void Sync::retrievePasswordFromKeyChain(const QString &username)
{
    // Workaround: first try and store a dummy entry to the keychain to ensure
    // that the keychain is unlocked before we try to retrieve the real password

    QKeychain::WritePasswordJob *writeDummyJob = new QKeychain::WritePasswordJob(qAppName(), this);
    writeDummyJob->setAutoDelete(false);
    writeDummyJob->setKey(QStringLiteral("dummy"));
    writeDummyJob->setTextData(QStringLiteral("dummy"));

    QKeychain::WritePasswordJob::connect(writeDummyJob, &QKeychain::Job::finished, this, [=]() {
        if (writeDummyJob->error()) {
            qCDebug(kastsSync) << "Could not open keychain: " << qPrintable(writeDummyJob->errorString());
            // fall back to password from file
            Q_EMIT passwordRetrievalFinished(retrievePasswordFromFile(username));
        } else {
            // opening keychain succeeded, let's try to read the password

            QKeychain::ReadPasswordJob *readJob = new QKeychain::ReadPasswordJob(qAppName());
            readJob->setAutoDelete(false);
            readJob->setKey(username);

            connect(readJob, &QKeychain::Job::finished, this, [=]() {
                if (readJob->error() == QKeychain::Error::NoError) {
                    Q_EMIT passwordRetrievalFinished(readJob->textData());
                    // if a password file is present, delete it
                    QFile(StorageManager::instance().passwordFilePath(username)).remove();
                } else {
                    qCDebug(kastsSync) << "Could not read the access token from the keychain: " << qPrintable(readJob->errorString());
                    // no password from the keychain, try token file
                    QString password = retrievePasswordFromFile(username);
                    Q_EMIT passwordRetrievalFinished(password);
                    if (readJob->error() == QKeychain::Error::EntryNotFound) {
                        if (!password.isEmpty()) {
                            qCDebug(kastsSync) << "Migrating password from file to the keychain for " << username;
                            connect(this, &Sync::passwordSaveFinished, this, [=](bool saved) {
                                disconnect(this, &Sync::passwordSaveFinished, this, nullptr);
                                bool removed = false;
                                if (saved) {
                                    QFile passwordFile(StorageManager::instance().passwordFilePath(username));
                                    removed = passwordFile.remove();
                                }
                                if (!(saved && removed)) {
                                    qCDebug(kastsSync) << "Migrating password from the file to the keychain failed";
                                }
                            });
                            savePasswordToKeyChain(username, password);
                        }
                    }
                }
                readJob->deleteLater();
            });
            readJob->start();
        }
        writeDummyJob->deleteLater();
    });
    writeDummyJob->start();
}

QString Sync::retrievePasswordFromFile(const QString &username)
{
    QFile passwordFile(StorageManager::instance().passwordFilePath(username));

    if (passwordFile.open(QFile::ReadOnly)) {
        qCDebug(kastsSync) << "Retrieved password from file for user" << username;
        return QString::fromUtf8(passwordFile.readAll());
    } else {
        Q_EMIT error(Error::Type::SyncError,
                     passwordFile.fileName(),
                     QStringLiteral(""),
                     0,
                     i18n("I/O denied: Cannot access password file."),
                     i18n("I/O denied: Cannot access password file."));

        return QStringLiteral("");
    }
}

void Sync::onDeleteJobFinished(QKeychain::DeletePasswordJob *deleteJob, const QString &username)
{
    if (deleteJob->error() == QKeychain::Error::NoError) {
        qCDebug(kastsSync) << "Password for username" << username << "successfully deleted from keychain";

        // now also delete the dummy entry
        QKeychain::DeletePasswordJob *deleteDummyJob = new QKeychain::DeletePasswordJob(qAppName());
        deleteDummyJob->setAutoDelete(true);
        deleteDummyJob->setKey(QStringLiteral("dummy"));

        QKeychain::DeletePasswordJob::connect(deleteDummyJob, &QKeychain::Job::finished, this, [=]() {
            if (deleteDummyJob->error()) {
                qCDebug(kastsSync) << "Deleting dummy from keychain unsuccessful";
            } else {
                qCDebug(kastsSync) << "Deleting dummy from keychain successful";
            }
        });
        deleteDummyJob->start();
    } else if (deleteJob->error() == QKeychain::Error::EntryNotFound) {
        qCDebug(kastsSync) << "No password for username" << username << "found in keychain";
    } else {
        qCDebug(kastsSync) << "Could not access keychain to delete password for username" << username;
    }
}

void Sync::onWriteDummyJobFinished(QKeychain::WritePasswordJob *writeDummyJob, const QString &username)
{
    if (writeDummyJob->error()) {
        qCDebug(kastsSync) << "Could not open keychain: " << qPrintable(writeDummyJob->errorString());
    } else {
        // opening keychain succeeded, let's try to delete the password

        QFile(StorageManager::instance().passwordFilePath(username)).remove();

        QKeychain::DeletePasswordJob *deleteJob = new QKeychain::DeletePasswordJob(qAppName());
        deleteJob->setAutoDelete(true);
        deleteJob->setKey(username);

        QKeychain::DeletePasswordJob::connect(deleteJob, &QKeychain::Job::finished, this, [this, deleteJob, username]() {
            onDeleteJobFinished(deleteJob, username);
        });
        deleteJob->start();
    }
    writeDummyJob->deleteLater();
}

void Sync::deletePasswordFromKeychain(const QString &username)
{
    // Workaround: first try and store a dummy entry to the keychain to ensure
    // that the keychain is unlocked before we try to delete the real password

    QKeychain::WritePasswordJob *writeDummyJob = new QKeychain::WritePasswordJob(qAppName(), this);
    writeDummyJob->setAutoDelete(false);
    writeDummyJob->setKey(QStringLiteral("dummy"));
    writeDummyJob->setTextData(QStringLiteral("dummy"));

    QKeychain::WritePasswordJob::connect(writeDummyJob, &QKeychain::Job::finished, this, [this, writeDummyJob, username]() {
        onWriteDummyJobFinished(writeDummyJob, username);
    });
    writeDummyJob->start();
}

void Sync::registerNewDevice(const QString &id, const QString &caption, const QString &type)
{
    if (!m_gpodder) {
        return;
    }
    UpdateDeviceRequest *updateDeviceRequest = m_gpodder->updateDevice(id, caption, type);
    connect(updateDeviceRequest, &UpdateDeviceRequest::finished, this, [=]() {
        if (updateDeviceRequest->error() || updateDeviceRequest->aborted()) {
            if (updateDeviceRequest->error()) {
                Q_EMIT error(Error::Type::SyncError,
                             QStringLiteral(""),
                             QStringLiteral(""),
                             updateDeviceRequest->error(),
                             updateDeviceRequest->errorString(),
                             i18n("Could not create GPodder device"));
            }
        } else {
            setDevice(id);
            setDeviceName(caption);
            setSyncEnabled(true);
            Q_EMIT deviceCreated();
        }
        updateDeviceRequest->deleteLater();
    });
}

void Sync::linkUpAllDevices()
{
    if (!m_gpodder) {
        return;
    }
    SyncRequest *syncRequest = m_gpodder->getSyncStatus();
    connect(syncRequest, &SyncRequest::finished, this, [=]() {
        if (syncRequest->error() || syncRequest->aborted()) {
            if (syncRequest->error()) {
                Q_EMIT error(Error::Type::SyncError,
                             QStringLiteral(""),
                             QStringLiteral(""),
                             syncRequest->error(),
                             syncRequest->errorString(),
                             i18n("Could not retrieve synced device status"));
            }
            syncRequest->deleteLater();
            return;
        }

        QSet<QString> syncDevices;
        for (const QStringList &group : syncRequest->syncedDevices()) {
            syncDevices += QSet(group.begin(), group.end());
        }
        syncDevices += QSet(syncRequest->unsyncedDevices().begin(), syncRequest->unsyncedDevices().end());

        QVector<QStringList> syncDeviceGroups;
        syncDeviceGroups += QStringList(syncDevices.values());
        if (!m_gpodder) {
            return;
        }
        UpdateSyncRequest *upSyncRequest = m_gpodder->updateSyncStatus(syncDeviceGroups, QStringList());
        connect(upSyncRequest, &UpdateSyncRequest::finished, this, [=]() {
            // For some reason, the response is always "Internal Server Error"
            // even though the request is processed properly.  So we just
            // continue rather than abort...
            if (upSyncRequest->error() || upSyncRequest->aborted()) {
                if (upSyncRequest->error()) {
                    // Q_EMIT error(Error::Type::SyncError,
                    //            QStringLiteral(""),
                    //            QStringLiteral(""),
                    //            upSyncRequest->error(),
                    //            upSyncRequest->errorString(),
                    //            i18n("Could not update synced device status"));
                }
                // upSyncRequest->deleteLater();
                // return;
            }

            // Assemble a list of all subscriptions of all devices
            m_syncUpAllSubscriptions.clear();
            m_deviceResponses = 0;
            for (const QString &device : syncDevices) {
                if (!m_gpodder) {
                    return;
                }
                SubscriptionRequest *subRequest = m_gpodder->getSubscriptionChanges(0, device);
                connect(subRequest, &SubscriptionRequest::finished, this, [=]() {
                    if (subRequest->error() || subRequest->aborted()) {
                        if (subRequest->error()) {
                            Q_EMIT error(Error::Type::SyncError,
                                         QStringLiteral(""),
                                         QStringLiteral(""),
                                         subRequest->error(),
                                         subRequest->errorString(),
                                         i18n("Could not retrieve subscriptions for device %1", device));
                        }
                    } else {
                        m_syncUpAllSubscriptions += subRequest->addList();
                    }
                    if (syncDevices.count() == ++m_deviceResponses) {
                        // We have now received all responses for all devices
                        for (const QString &syncdevice : syncDevices) {
                            if (!m_gpodder) {
                                return;
                            }
                            UploadSubscriptionRequest *upSubRequest = m_gpodder->uploadSubscriptionChanges(m_syncUpAllSubscriptions, QStringList(), syncdevice);
                            connect(upSubRequest, &UploadSubscriptionRequest::finished, this, [this, upSubRequest, syncdevice]() {
                                if (upSubRequest->error()) {
                                    Q_EMIT error(Error::Type::SyncError,
                                                 QStringLiteral(""),
                                                 QStringLiteral(""),
                                                 upSubRequest->error(),
                                                 upSubRequest->errorString(),
                                                 i18n("Could not upload subscriptions for device %1", syncdevice));
                                }
                                upSubRequest->deleteLater();
                            });
                        }
                    }
                    subRequest->deleteLater();
                });
            }
            upSyncRequest->deleteLater();
        });
        syncRequest->deleteLater();
    });
}

void Sync::doSync(SyncStatus status, bool forceFetchAll)
{
    if (!m_syncEnabled || !m_gpodder || !(m_syncStatus == SyncStatus::NoSync || m_syncStatus == SyncStatus::UploadOnlySync)) {
        return;
    }

    if (m_provider == Provider::GPodderNet && (m_username.isEmpty() || m_device.isEmpty())) {
        return;
    }

    if (m_provider == Provider::GPodderNextcloud && (m_username.isEmpty() || m_hostname.isEmpty())) {
        return;
    }

    // If a quick upload-only sync is running, abort it
    if (m_syncStatus == SyncStatus::UploadOnlySync) {
        abortSync();
    }

    m_syncStatus = status;

    if (status == SyncUtils::SyncStatus::PushAllSync) {
        retrieveAllLocalEpisodeStates();
    }

    SyncJob *syncJob = new SyncJob(status, m_gpodder, m_device, forceFetchAll, this);
    connect(this, &Sync::abortSync, syncJob, &SyncJob::abort);
    connect(syncJob, &SyncJob::infoMessage, this, [this](KJob *job, const QString &message) {
        m_syncProgressTotal = job->totalAmount(KJob::Unit::Items);
        m_syncProgress = job->processedAmount(KJob::Unit::Items);
        m_syncProgressText = message;
        Q_EMIT syncProgressChanged();
    });
    connect(syncJob, &SyncJob::finished, this, [this](KJob *job) {
        if (job->error()) {
            Q_EMIT error(Error::Type::SyncError, QStringLiteral(""), QStringLiteral(""), job->error(), job->errorText(), job->errorString());
        }
        m_syncStatus = SyncStatus::NoSync;
        Q_EMIT syncProgressChanged();
    });
    syncJob->start();
}

void Sync::doRegularSync(bool forceFetchAll)
{
    doSync(SyncStatus::RegularSync, forceFetchAll);
}

void Sync::doForceSync()
{
    doSync(SyncStatus::ForceSync, true);
}

void Sync::doSyncPushAll()
{
    doSync(SyncStatus::PushAllSync, false);
}

void Sync::doQuickSync()
{
    if (!SettingsManager::self()->syncWhenPlayerstateChanges()) {
        return;
    }

    // since this method is supposed to be called automatically, we cannot check
    // the network state from the UI, so we have to do it here
    if (!NetworkConnectionManager::instance().feedUpdatesAllowed()) {
        qCDebug(kastsSync) << "Not uploading episode actions on metered connection due to settings";
        return;
    }

    if (!m_syncEnabled || !m_gpodder || m_syncStatus != SyncStatus::NoSync) {
        return;
    }

    if (m_provider == Provider::GPodderNet && (m_username.isEmpty() || m_device.isEmpty())) {
        return;
    }

    if (m_provider == Provider::GPodderNextcloud && (m_username.isEmpty() || m_hostname.isEmpty())) {
        return;
    }

    m_syncStatus = SyncStatus::UploadOnlySync;

    SyncJob *syncJob = new SyncJob(m_syncStatus, m_gpodder, m_device, false, this);
    connect(this, &Sync::abortSync, syncJob, &SyncJob::abort);
    connect(syncJob, &SyncJob::finished, this, [this]() {
        // don't do error reporting or status updates on quick upload-only syncs
        m_syncStatus = SyncStatus::NoSync;
    });
    syncJob->start();
}

void Sync::applySubscriptionChangesLocally(const QStringList &addList, const QStringList &removeList)
{
    m_allowSyncActionLogging = false;

    // removals
    DataManager::instance().removeFeeds(removeList);

    // additions
    DataManager::instance().addFeeds(addList, false);

    m_allowSyncActionLogging = true;
}

void Sync::applyEpisodeActionsLocally(const QHash<QString, QHash<QString, EpisodeAction>> &episodeActionHash)
{
    m_allowSyncActionLogging = false;

    for (const QHash<QString, EpisodeAction> &actions : episodeActionHash) {
        for (const EpisodeAction &action : actions) {
            if (action.action == QStringLiteral("play")) {
                Entry *entry = DataManager::instance().getEntry(action.id);
                if (entry && entry->hasEnclosure()) {
                    qCDebug(kastsSync) << action.position << action.total << static_cast<qint64>(action.position) << entry->enclosure()->duration()
                                       << SettingsManager::self()->markAsPlayedBeforeEnd();
                    if ((action.position >= action.total - SettingsManager::self()->markAsPlayedBeforeEnd()
                         || static_cast<qint64>(action.position) >= entry->enclosure()->duration() - SettingsManager::self()->markAsPlayedBeforeEnd())
                        && action.total > 0) {
                        // Episode has been played
                        qCDebug(kastsSync) << "mark as played:" << entry->title();
                        entry->setRead(true);
                    } else if (action.position > 0 && static_cast<qint64>(action.position) * 1000 >= entry->enclosure()->duration()) {
                        // Episode is being listened to
                        qCDebug(kastsSync) << "set play position and add to queue:" << entry->title();
                        entry->enclosure()->setPlayPosition(action.position * 1000);
                        entry->setQueueStatus(true);
                        if (AudioManager::instance().entry() == entry) {
                            AudioManager::instance().setPosition(action.position * 1000);
                        }
                    } else {
                        // Episode has not been listened to yet
                        qCDebug(kastsSync) << "reset play position:" << entry->title();
                        entry->enclosure()->setPlayPosition(0);
                    }
                }
            }

            if (action.action == QStringLiteral("delete")) {
                Entry *entry = DataManager::instance().getEntry(action.id);
                if (entry && entry->hasEnclosure()) {
                    // "delete" means that at least the Episode has been played
                    qCDebug(kastsSync) << "mark as played:" << entry->title();
                    entry->setRead(true);
                }
            }

            QCoreApplication::processEvents(); // keep the main thread semi-responsive
        }
    }

    m_allowSyncActionLogging = true;

    // Don't sync the download or delete status since it's broken in gpodder.net:
    // the service only allows to upload only one download or delete action per
    // episode; afterwards, it's not possible to override it with a similar action
    // with a newer timestamp.  Hence we consider this information not reliable.
}

void Sync::storeAddFeedAction(const QString &url)
{
    if (syncEnabled() && m_allowSyncActionLogging) {
        QSqlQuery query;
        query.prepare(QStringLiteral("INSERT INTO FeedActions VALUES (:url, :action, :timestamp);"));
        query.bindValue(QStringLiteral(":url"), url);
        query.bindValue(QStringLiteral(":action"), QStringLiteral("add"));
        query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
        Database::instance().execute(query);
        qCDebug(kastsSync) << "Logged a feed add action for" << url;
    }
}

void Sync::storeRemoveFeedAction(const QString &url)
{
    if (syncEnabled() && m_allowSyncActionLogging) {
        QSqlQuery query;
        query.prepare(QStringLiteral("INSERT INTO FeedActions VALUES (:url, :action, :timestamp);"));
        query.bindValue(QStringLiteral(":url"), url);
        query.bindValue(QStringLiteral(":action"), QStringLiteral("remove"));
        query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
        Database::instance().execute(query);
        qCDebug(kastsSync) << "Logged a feed remove action for" << url;
    }
}

void Sync::storePlayEpisodeAction(const QString &id, const qulonglong started, const qulonglong position)
{
    if (syncEnabled() && m_allowSyncActionLogging) {
        Entry *entry = DataManager::instance().getEntry(id);
        if (entry && entry->hasEnclosure()) {
            const qulonglong started_sec = started / 1000; // convert to seconds
            const qulonglong position_sec = position / 1000; // convert to seconds
            const qulonglong total =
                (entry->enclosure()->duration() > 0) ? entry->enclosure()->duration() : 1; // workaround for episodes with bad metadata on gpodder server

            QSqlQuery query;
            query.prepare(QStringLiteral("INSERT INTO EpisodeActions VALUES (:podcast, :url, :id, :action, :started, :position, :total, :timestamp);"));
            query.bindValue(QStringLiteral(":podcast"), entry->feed()->url());
            query.bindValue(QStringLiteral(":url"), entry->enclosure()->url());
            query.bindValue(QStringLiteral(":id"), entry->id());
            query.bindValue(QStringLiteral(":action"), QStringLiteral("play"));
            query.bindValue(QStringLiteral(":started"), started_sec);
            query.bindValue(QStringLiteral(":position"), position_sec);
            query.bindValue(QStringLiteral(":total"), total);
            query.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
            Database::instance().execute(query);

            qCDebug(kastsSync) << "Logged an episode play action for" << entry->title() << "play position changed:" << started_sec << position_sec << total;
        }
    }
}

void Sync::storePlayedEpisodeAction(const QString &id)
{
    if (syncEnabled() && m_allowSyncActionLogging) {
        if (DataManager::instance().getEntry(id)->hasEnclosure()) {
            Entry *entry = DataManager::instance().getEntry(id);
            const qulonglong duration =
                (entry->enclosure()->duration() > 0) ? entry->enclosure()->duration() : 1; // crazy workaround for episodes with bad metadata
            storePlayEpisodeAction(id, duration * 1000, duration * 1000);
        }
    }
}

void Sync::retrieveAllLocalEpisodeStates()
{
    QVector<SyncUtils::EpisodeAction> actions;

    QSqlQuery query;
    query.prepare(QStringLiteral("SELECT * FROM Enclosures INNER JOIN Entries ON Enclosures.id = Entries.id WHERE Entries.hasEnclosure = 1;"));
    Database::instance().execute(query);
    while (query.next()) {
        qulonglong position_sec = query.value(QStringLiteral("playposition")).toInt() / 1000;
        qulonglong duration = query.value(QStringLiteral("duration")).toInt();
        bool read = query.value(QStringLiteral("read")).toBool();
        if (read) {
            if (duration == 0)
                duration = 1; // crazy workaround for episodes with bad metadata
            position_sec = duration;
        }
        if (position_sec > 0 && duration > 0) {
            SyncUtils::EpisodeAction action;
            action.podcast = query.value(QStringLiteral("feed")).toString();
            action.id = query.value(QStringLiteral("id")).toString();
            action.url = query.value(QStringLiteral("url")).toString();
            action.started = position_sec;
            action.position = position_sec;
            action.total = duration;

            actions << action;

            qCDebug(kastsSync) << "Logged an episode play action for" << action.id << "play position:" << position_sec << duration << read;
        }
    }

    QSqlQuery writeQuery;
    Database::instance().transaction();
    for (SyncUtils::EpisodeAction &action : actions) {
        writeQuery.prepare(QStringLiteral("INSERT INTO EpisodeActions VALUES (:podcast, :url, :id, :action, :started, :position, :total, :timestamp);"));
        writeQuery.bindValue(QStringLiteral(":podcast"), action.podcast);
        writeQuery.bindValue(QStringLiteral(":url"), action.url);
        writeQuery.bindValue(QStringLiteral(":id"), action.id);
        writeQuery.bindValue(QStringLiteral(":action"), QStringLiteral("play"));
        writeQuery.bindValue(QStringLiteral(":started"), action.started);
        writeQuery.bindValue(QStringLiteral(":position"), action.position);
        writeQuery.bindValue(QStringLiteral(":total"), action.total);
        writeQuery.bindValue(QStringLiteral(":timestamp"), QDateTime::currentSecsSinceEpoch());
        Database::instance().execute(writeQuery);
    }
    Database::instance().commit();
}
