#include "./app.h"

#include "./helpers.h"

#ifdef Q_OS_ANDROID
#include "./android.h"
#endif

#include "../misc/internalerror.h"
#include "../misc/otherdialogs.h"

#ifdef SYNCTHINGWIDGETS_USE_LIBSYNCTHING
#include <syncthing/interface.h>
#endif

#include <syncthingmodel/syncthingicons.h>

#include <syncthingconnector/syncthingconnectionsettings.h>
#include <syncthingconnector/utils.h>

#include <qtutilities/resources/resources.h>

#include <qtforkawesome/renderer.h>
#include <qtquickforkawesome/imageprovider.h>

#include <c++utilities/conversion/stringbuilder.h>

#include <QDebug>
#include <QDir>
#include <QGuiApplication>
#include <QJsonDocument>
#include <QJsonValue>
#include <QNetworkReply>
#include <QQmlContext>
#include <QQuickView>
#include <QStringBuilder>
#include <QUrlQuery>
#include <QtConcurrent/QtConcurrentRun>

#ifdef Q_OS_ANDROID
#include <QJniEnvironment>
#endif

#include <cstdlib>
#include <filesystem>
#include <functional>
#include <iostream>
#include <numeric>
#include <stdexcept>

#include "resources/config.h"

using namespace Data;

namespace QtGui {

/*!
 * \class App
 * \brief The App class provides various functionality for the Qt Quick GUI.
 * \remarks
 * - This class is backed by AppService which manages the runtime of Syncthing.
 * - This class provides the Qml engine (unless supplied externally, e.g. for testing) and is
 *   accessible from Qml via the App singleton.
 * - Under Android this class is accompanied by the Java class Activity which implements certain
 *   Android-specific functionality in Java.
 */

/*!
 * \brief Initializes the Qt Quick GUI and related platform-specific functionality.
 * \remarks
 * - Registers JNI functions for the Java class Activity under Android and signals the Activity
 *   class via "onNativeReady" that these functions are available. This will also trigger the
 *   service startup.
 */
App::App(bool insecure, QQmlEngine *engine, QObject *parent)
    : AppBase(insecure, false, true, parent)
    , m_engine(engine)
    , m_app(static_cast<QGuiApplication *>(QCoreApplication::instance()))
    , m_ui(m_app, m_qtSettings, m_engine)
    , m_models(m_data, m_engine)
    , m_uiObjects({ m_models.dirModel(), m_models.sortFilterDirModel(), m_models.devModel(), m_models.sortFilterDevModel(), m_models.changesModel() })
    , m_importExportStatus(ImportExportStatus::None)
    , m_tabIndex(-1)
    , m_clearingLogfile(false)
    , m_isGuiLoaded(false)
    , m_unloadGuiWhenHidden(false)
    , m_isSyncthingStarting(true)
    , m_isSyncthingRunning(false)
{
    qDebug() << "Initializing UI";

#ifdef Q_OS_ANDROID
    JniFn::registerActivityJniMethods(this);
#endif

    m_app->installEventFilter(this);
    connect(m_app, &QGuiApplication::applicationStateChanged, this, &App::handleStateChanged);

    QtUtilities::deletePipelineCacheIfNeeded();
    loadSettings();
    applySettings();

    // apply dark mode setting to models and keep it in sync
    m_models.setBrightColors(m_ui.isDarkmodeEnabled());
    connect(&m_ui, &QuickUI::darkmodeEnabledChanged, &m_models, &SyncthingModels::setBrightColors);

    auto &iconManager = initIconManager();
    connect(&iconManager, &Data::IconManager::statusIconsChanged, m_data.statusInfo(), &StatusInfo::statusInfoChanged);

    connect(m_data.connection(), &SyncthingConnection::error, this, &App::handleConnectionError);
    connect(m_data.connection(), &SyncthingConnection::statusChanged, this, &App::handleConnectionStatusChanged);
    connect(m_data.connection(), &SyncthingConnection::newDevices, this, &App::handleChangedDevices);
    connect(m_data.connection(), &SyncthingConnection::autoReconnectIntervalChanged, this, &App::invalidateStatus);
    connect(m_data.connection(), &SyncthingConnection::hasOutOfSyncDirsChanged, this, &App::invalidateStatus);
    connect(m_data.connection(), &SyncthingConnection::devStatusChanged, this, &App::handleChangedDevices);
    connect(m_data.connection(), &SyncthingConnection::newErrors, this, &App::handleNewErrors);
    connect(&m_models, &SyncthingModels::info, this, &App::info);
    connect(&m_models, &SyncthingModels::error, this, &App::error);
    connect(&m_models, &SyncthingModels::savingConfigChanged, this, &App::invalidateStatus);
    connect(&m_models, &SyncthingModels::hapticFeedbackRequested, &m_ui, &QuickUI::performHapticFeedback);
#ifdef Q_OS_ANDROID
    connect(m_data.connection(), &SyncthingConnection::errorsCleared, this, [this] { sendMessageToService(ServiceAction::RequestErrors); });
#endif
    connect(this, &App::syncthingRunningChanged, this, &App::handleRunningChanged);
    connect(this, &App::syncthingGuiUrlChanged, this, &App::handleGuiUrlChanged);

    // stop libsyncthing when the app is about to quit
    if constexpr (!isServiceShutdownManual()) {
        connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, this, &App::shutdownService);
    }

#ifdef Q_OS_ANDROID
    QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<void>("onNativeReady", QLocale().bcp47Name());
#endif

    initEngine();
}

App::~App()
{
    qDebug() << "Destroying app";
#ifdef Q_OS_ANDROID
    JniFn::unregisterActivityJniMethods(this);
#endif
}

App *App::create(QQmlEngine *qmlEngine, QJSEngine *engine)
{
    return dataObjectFromProperty<App>(qmlEngine, engine);
}

const QString &App::status()
{
    if (m_status.has_value()) {
        return *m_status;
    }
    switch (m_importExportStatus) {
    case ImportExportStatus::None:
        break;
    case ImportExportStatus::Checking:
        return m_status.emplace(tr("Checking for data to import …"));
    case ImportExportStatus::Importing:
        return m_status.emplace(tr("Importing configuration …"));
    case ImportExportStatus::Exporting:
        return m_status.emplace(tr("Exporting configuration …"));
    case ImportExportStatus::CheckingMove:
        return m_status.emplace(tr("Checking locations to move home directory …"));
    case ImportExportStatus::Moving:
        return m_status.emplace(tr("Moving home directory …"));
    case ImportExportStatus::Cleaning:
        return m_status.emplace(tr("Cleaning home directory …"));
    case ImportExportStatus::SavingSupportBundle:
        return m_status.emplace(tr("Saving support bundle …"));
    }
    if (m_connectToLaunched) {
        if (!m_isSyncthingRunning && !m_syncthingRunningStatus.isEmpty()) {
            return m_status.emplace(m_syncthingRunningStatus);
        } else if (m_isSyncthingStarting) {
            return m_status.emplace(tr("Backend is starting …"));
        }
    }
    switch (m_data.connection()->status()) {
    case Data::SyncthingStatus::Disconnected:
    case Data::SyncthingStatus::Reconnecting:
        break;
    default:
        if (m_models.pendingConfigChange().reply) {
            return m_status.emplace(tr("Saving configuration …"));
        }
    }
    return AppBase::status();
}

bool App::initEngine()
{
    if (!m_engine) {
        qDebug() << "Initializing QML engine";
        m_appEngine.emplace();
        m_engine = &*m_appEngine;
        connect(
            &*m_appEngine, &QQmlApplicationEngine::objectCreated, m_app,
            [](QObject *obj, const QUrl &objUrl) {
                if (!obj) {
                    std::cerr << "Unable to load " << objUrl.toString().toStdString() << '\n';
                    QCoreApplication::exit(EXIT_FAILURE);
                }
            },
            Qt::QueuedConnection);
        connect(&*m_appEngine, &QQmlApplicationEngine::quit, m_app, &QGuiApplication::quit);
    }
    dataObjectToProperty(m_engine, &m_data);
    dataObjectToProperty(m_engine, &m_models);
    dataObjectToProperty(m_engine, &m_ui);
    dataObjectToProperty(m_engine, this);
    return loadMain();
}

#ifdef Q_OS_ANDROID
bool App::destroyEngine()
{
    unloadMain();
    if (m_appEngine) {
        qDebug() << "Destroying QML engine";
        if (m_engine == &*m_appEngine) {
            m_engine = nullptr;
        }
        m_appEngine.reset();
    }
    return true;
}
#endif

bool App::loadMain()
{
    if (!m_appEngine) {
        return false;
    }
    // allow overriding QML entry point for hot-reloading; otherwise load QML module from resources
    qDebug() << "Loading Qt Quick GUI";
    if (const auto path = qEnvironmentVariable(PROJECT_VARNAME_UPPER "_QML_ENTRY_POINT_PATH"); !path.isEmpty()) {
        qDebug() << "Path to QML entry point for Qt Quick GUI was overridden to: " << path;
        m_appEngine->load(path);
    } else if (const auto type = qEnvironmentVariable(PROJECT_VARNAME_UPPER "_QML_ENTRY_POINT_TYPE"); !type.isEmpty()) {
        m_appEngine->loadFromModule("Main", type);
        if (const auto rootObjects = m_appEngine->rootObjects(); !rootObjects.isEmpty()) {
            auto *const window = new QQuickView(&m_appEngine.value(), nullptr);
            window->loadFromModule("Main", type);
            window->setTitle(type);
            window->setWidth(700);
            window->setHeight(500);
            window->setVisible(true);
        }
    } else {
        m_appEngine->loadFromModule("Main", "AppWindow");
    }
    m_isGuiLoaded = true;
#ifdef Q_OS_ANDROID
    QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<void>("onGuiLoaded");
#endif
    return true;
}

bool App::reloadMain()
{
    qDebug() << "Reloading Qt Quick GUI";
    unloadMain();
    if (m_engine) {
        m_engine->clearComponentCache();
    }
    return loadMain();
}

bool App::unloadMain()
{
    if (!m_appEngine) {
        return false;
    }
    qDebug() << "Unloading Qt Quick GUI";
    const auto rootObjects = m_appEngine->rootObjects();
    for (auto *const rootObject : rootObjects) {
        rootObject->deleteLater();
    }
    m_isGuiLoaded = false;
    return true;
}

void App::shutdownService()
{
#ifdef Q_OS_ANDROID
    QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<void>("stopSyncthingService");
#else
    emit stoppingLibSyncthingRequested();
#endif
}

bool App::storagePermissionGranted() const
{
#ifdef Q_OS_ANDROID
    if (!m_storagePermissionGranted.has_value()) {
        m_storagePermissionGranted = QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<jboolean>("storagePermissionGranted");
    }
    return m_storagePermissionGranted.value();
#else
    return true;
#endif
}

bool App::notificationPermissionGranted() const
{
#ifdef Q_OS_ANDROID
    if (!m_notificationPermissionGranted.has_value()) {
        m_notificationPermissionGranted
            = QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<jboolean>("notificationPermissionGranted");
    }
    return m_notificationPermissionGranted.value();
#else
    return true;
#endif
}

bool App::requestStoragePermission()
{
#ifdef Q_OS_ANDROID
    return QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<jboolean>("requestStoragePermission");
#else
    return false;
#endif
}

bool App::requestNotificationPermission()
{
#ifdef Q_OS_ANDROID
    return QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<jboolean>("requestNotificationPermission");
#else
    return false;
#endif
}

bool App::showLog(QObject *textArea)
{
    textArea->setProperty("text", QString());
    connect(this, &App::logsAvailable, textArea, [textArea](const QString &newLogs) {
        QMetaObject::invokeMethod(textArea, "insert", Q_ARG(int, textArea->property("length").toInt()), Q_ARG(QString, newLogs));
    });
#ifdef Q_OS_ANDROID
    connect(textArea, &QObject::destroyed, this, [this] { sendMessageToService(ServiceAction::CloseLog); });
    sendMessageToService(ServiceAction::FollowLog);
#else
    emit replayLogRequested();
#endif
    return true;
}

void App::clearLog()
{
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::ClearLog);
#else
    emit clearLogRequested();
#endif
}

bool App::loadDirErrors(const QString &dirId, QObject *view)
{
    auto connection
        = connect(m_data.connection(), &Data::SyncthingConnection::dirStatusChanged, view, [this, dirId, view](const Data::SyncthingDir &dir) {
              if (dir.id != dirId) {
                  return;
              }
              auto array = m_engine->newArray(static_cast<quint32>(dir.itemErrors.size()));
              auto index = quint32();
              for (const auto &itemError : dir.itemErrors) {
                  auto error = m_engine->newObject();
                  error.setProperty(QStringLiteral("path"), itemError.path);
                  error.setProperty(QStringLiteral("message"), itemError.message);
                  array.setProperty(index++, error);
              }
              view->setProperty("model", array.toVariant());
              view->setProperty("enabled", true);
          });
    connect(this, &QObject::destroyed, [connection] () mutable { disconnect(connection); });
    m_data.connection()->requestDirPullErrors(dirId);
    return true;
}

bool App::eventFilter(QObject *object, QEvent *event)
{
    if (object != m_app) {
        return false;
    }
    switch (event->type()) {
    case QEvent::ApplicationPaletteChange:
        qDebug() << "Application palette has changed";
        if (m_app) {
            const auto palette = m_app->palette();
            IconManager::instance(&palette).setPalette(palette);
            if (m_ui.imageProvider()) {
                m_ui.imageProvider()->setDefaultColor(palette.color(QPalette::Normal, QPalette::Text));
            }
#ifndef SYNCTHING_APP_DARK_MODE_FROM_COLOR_SCHEME
            m_ui.applyDarkmodeChange(palette);
#endif
        }
        break;
    default:;
    }
    return false;
}

void App::handleConnectionError(
    const QString &errorMessage, Data::SyncthingErrorCategory category, int networkError, const QNetworkRequest &request, const QByteArray &response)
{
    if (!InternalError::isRelevant(*m_data.connection(), category, errorMessage, networkError, false, m_isManuallyStopped)) {
        return;
    }
    qWarning() << "Connection error: " << errorMessage;
    auto error = InternalError(errorMessage, request.url(), response);
    emit internalError(error);
    const auto emitHasInternalErrorsChanged = m_internalErrors.isEmpty();
    m_internalErrors.emplace_back(QVariant::fromValue(std::move(error)));
    if (emitHasInternalErrorsChanged) {
        invalidateStatus();
        emit hasInternalErrorsChanged();
    }
}

void App::setCurrentControls(bool visible, int tabIndex)
{
    auto flags = m_data.connection()->pollingFlags();
    if (tabIndex < 0) {
        tabIndex = m_tabIndex;
    } else {
        m_tabIndex = tabIndex;
    }
    CppUtilities::modFlagEnum(flags, Data::SyncthingConnection::PollingFlags::TrafficStatistics, visible && tabIndex == 0);
    CppUtilities::modFlagEnum(flags, Data::SyncthingConnection::PollingFlags::DeviceStatistics, visible && tabIndex == 2);
    CppUtilities::modFlagEnum(flags, Data::SyncthingConnection::PollingFlags::DiskEvents, visible && tabIndex == 3);
    m_data.connection()->setPollingFlags(flags);
}

bool App::loadStatistics(const QJSValue &callback)
{
    if (!callback.isCallable()) {
        return false;
    }
    auto query = m_data.connection()->requestJsonData(
        QByteArrayLiteral("GET"), QStringLiteral("svc/report"), QUrlQuery(), QByteArray(), [this, callback](QJsonDocument &&doc, QString &&error) {
            auto report = doc.object().toVariantMap();
            report[QStringLiteral("uptime")] = QString::fromStdString(CppUtilities::TimeSpan::fromSeconds(report[QStringLiteral("uptime")].toDouble())
                    .toString(CppUtilities::TimeSpanOutputFormat::WithMeasures));
            report[QStringLiteral("memoryUsage")] = QString::fromStdString(
                CppUtilities::dataSizeToString(static_cast<std::uint64_t>(report.take(QStringLiteral("memoryUsageMiB")).toDouble() * 1024 * 1024)));
            report[QStringLiteral("processRSS")] = QString::fromStdString(
                CppUtilities::dataSizeToString(static_cast<std::uint64_t>(report.take(QStringLiteral("processRSSMiB")).toDouble() * 1024 * 1024)));
            report[QStringLiteral("memorySize")] = QString::fromStdString(
                CppUtilities::dataSizeToString(static_cast<std::uint64_t>(report[QStringLiteral("memorySize")].toDouble() * 1024 * 1024)));
            report[QStringLiteral("totSize")] = QString::fromStdString(
                CppUtilities::dataSizeToString(static_cast<std::uint64_t>(report.take(QStringLiteral("totMiB")).toDouble() * 1024 * 1024)));
            statistics(report);
            callback.call(QJSValueList({ m_engine->toScriptValue(report), QJSValue(std::move(error)) }));
        });
    connect(this, &QObject::destroyed, query.reply, &QNetworkReply::deleteLater);
    connect(this, &QObject::destroyed, [c = query.connection] () mutable { disconnect(c); });
    return true;
}

void App::invalidateStatus()
{
    AppBase::invalidateStatus();
    m_closePreference.reset();
    emit m_data.statusInfo()->statusInfoChanged();
    emit statusChanged();
}

void App::handleRunningChanged(bool isRunning)
{
    if (m_connectToLaunched) {
        invalidateStatus();
    }
    if (!m_settingsImport.availableSettings.isEmpty() && !isRunning) {
        importSettings(m_settingsImport.availableSettings, m_settingsImport.selectedSettings, m_settingsImport.callback);
    } else if (m_settingsExport.url.has_value() && !isRunning) {
        exportSettings(m_settingsExport.url.value(), m_settingsExport.callback);
    } else if (m_homeDirMove.newHomeDir.has_value()) {
        moveSyncthingHome(m_homeDirMove.newHomeDir.value(), m_homeDirMove.callback);
    } else if (m_clearingLogfile) {
        m_clearingLogfile = false;
        clearLogfile();
    }
}

void App::handleChangedDevices()
{
    m_data.updateDeviceInfo();
    emit m_data.statusInfo()->statusInfoChanged();
}

void App::handleNewErrors(const std::vector<Data::SyncthingError> &errors)
{
    Q_UNUSED(errors)
    invalidateStatus();
}

void App::handleStateChanged(Qt::ApplicationState state)
{
    if (m_isGuiLoaded && ((state == Qt::ApplicationSuspended) || (state & Qt::ApplicationHidden))) {
        qDebug() << "App considered suspended/hidden, reducing polling, stopping UI processing";
        setCurrentControls(false);
        for (auto *const uiObject : m_uiObjects) {
            uiObject->moveToThread(nullptr);
        }
        if (m_unloadGuiWhenHidden) {
            m_unloadGuiWhenHidden = false;
            unloadMain();
        }
    } else if (state & Qt::ApplicationActive) {
        qDebug() << "App considered active, continuing polling, resuming UI processing";
        setCurrentControls(true);
        auto *const uiThread = thread();
        for (auto *const uiObject : m_uiObjects) {
            uiObject->moveToThread(uiThread);
        }
        if (!m_isGuiLoaded) {
            QtUtilities::deletePipelineCacheIfNeeded();
            m_appEngine ? loadMain() : initEngine();
        }
    }
}

void App::handleConnectionStatusChanged(Data::SyncthingStatus newStatus)
{
    invalidateStatus();
    Q_UNUSED(newStatus)
}

void App::handleLauncherStatusBroadcast(const QVariant &status)
{
    const auto launcherStatus = status.toMap();
    const auto isStarting = launcherStatus.value(QStringLiteral("isStarting")).toBool();
    const auto isStartingChanged = isStarting != m_isSyncthingStarting;
    const auto isRunning = launcherStatus.value(QStringLiteral("isRunning")).toBool();
    const auto isRunningChanged = isRunning != m_isSyncthingRunning;
    const auto guiUrl = launcherStatus.value(QStringLiteral("guiUrl")).toUrl();
    const auto unixSocketPath = launcherStatus.value(QStringLiteral("unixSocketPath")).toString();
    const auto guiUrlChanged = guiUrl != m_syncthingGuiUrl || unixSocketPath != m_syncthingUnixSocketPath;
    const auto runningStatus = launcherStatus.value(QStringLiteral("runningStatus")).toString();
    const auto runningStatusChanged = runningStatus != m_syncthingRunningStatus;
    const auto meteredStatus = launcherStatus.value(QStringLiteral("meteredStatus")).toString();
    const auto hasMeteredStatusChanged = meteredStatus != m_meteredStatus;
    m_isSyncthingStarting = isStarting;
    m_isSyncthingRunning = isRunning;
    m_syncthingGuiUrl = guiUrl;
    m_syncthingRunningStatus = runningStatus;
    m_meteredStatus = meteredStatus;
    m_syncthingUnixSocketPath = unixSocketPath;
    if (isRunningChanged && isRunning) {
        m_isManuallyStopped = false;
    }
    if (isStartingChanged) {
        emit syncthingStartingChanged(isStarting);
    }
    if (isRunningChanged || runningStatusChanged) {
        qDebug() << "Launcher running status has changed: " << runningStatus;
        emit syncthingRunningChanged(isRunning);
        emit syncthingRunningStatusChanged(runningStatus);
    }
    if (hasMeteredStatusChanged) {
        emit meteredStatusChanged(meteredStatus);
    }
    if (guiUrlChanged || !m_data.connection()->isConnected()) {
        qDebug() << "GUI URL changed: " << guiUrl;
        emit syncthingGuiUrlChanged(guiUrl);
    }
}

#ifdef Q_OS_ANDROID
static auto splitFolderRef(QStringView folderRef)
{
    struct {
        QStringView deviceId, folderId, folderLabel;
    } res;
    if (const auto separatorPos1 = folderRef.indexOf(QChar(':')); separatorPos1 >= 0) {
        res.deviceId = folderRef.mid(0, separatorPos1);
        res.folderId = folderRef.mid(separatorPos1 + 1);
        if (const auto separatorPos2 = res.folderId.indexOf(QChar(':')); separatorPos2 >= 0) {
            res.folderLabel = res.folderId.mid(separatorPos2 + 1);
            res.folderId = res.folderId.mid(0, separatorPos2);
        }
    } else {
        res.folderId = folderRef;
    }
    return res;
}

/// \cond
static QVariant deserializeVariant(const QByteArray &variantData)
{
    auto stream = QDataStream(variantData);
    auto res = QVariant();
    stream >> res;
    return res;
}
/// \endcond

void App::sendMessageToService(ServiceAction action, int arg1, int arg2, const QString &str)
{
    QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<void>("sendMessageToService", action, arg1, arg2, str);
}

void App::handleMessageFromService(ActivityAction action, int arg1, int arg2, const QString &str, const QByteArray &variant)
{
    Q_UNUSED(arg1)
    Q_UNUSED(arg2)
    switch (action) {
    case ActivityAction::ShowError:
        QMetaObject::invokeMethod(this, "showError", Qt::QueuedConnection, Q_ARG(QString, str));
        break;
    case ActivityAction::AppendLog:
        emit logsAvailable(str);
        break;
    case ActivityAction::UpdateLauncherStatus:
        QMetaObject::invokeMethod(this, "handleLauncherStatusBroadcast", Qt::QueuedConnection, Q_ARG(QVariant, deserializeVariant(variant)));
        break;
    case ActivityAction::FlagManualStop:
        m_isManuallyStopped = true;
        break;
    default:;
    }
}

void App::handleAndroidIntent(const QString &data, bool fromNotification)
{
    qDebug() << "Handling Android intent: " << data;
    Q_UNUSED(fromNotification)
    if (data == QLatin1String("internalErrors")) {
        emit internalErrorsRequested();
    } else if (data == QLatin1String("connectionErrors")) {
        emit connectionErrorsRequested();
    } else if (data.startsWith(QLatin1String("sharedtext:"))) {
        emit textShared(data.mid(11));
    } else if (data.startsWith(QLatin1String("newdev:"))) {
        emit newDeviceTriggered(data.mid(7));
    } else if (data.startsWith(QLatin1String("newfolder:"))) {
        const auto folderRef = splitFolderRef(QStringView(data).mid(10));
        emit newDirTriggered(folderRef.deviceId.toString(), folderRef.folderId.toString(), folderRef.folderLabel.toString());
    }
}

void App::handleStoragePermissionChanged(bool storagePermissionGranted)
{
    if (!m_storagePermissionGranted.has_value() || m_storagePermissionGranted.value() != storagePermissionGranted) {
        emit storagePermissionGrantedChanged(m_storagePermissionGranted.emplace(storagePermissionGranted));
    }
}

void App::handleNotificationPermissionChanged(bool notificationPermissionGranted)
{
    if (!m_notificationPermissionGranted.has_value() || m_notificationPermissionGranted.value() != notificationPermissionGranted) {
        emit notificationPermissionGrantedChanged(m_notificationPermissionGranted.emplace(notificationPermissionGranted));
    }
}
#endif

void App::clearInternalErrors()
{
    if (m_internalErrors.isEmpty()) {
        return;
    }
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::ClearInternalErrorNotifications);
#endif
    m_internalErrors.clear();
    invalidateStatus();
    emit hasInternalErrorsChanged();
}

bool App::minimize()
{
#ifdef Q_OS_ANDROID
    if (!QJniObject(QNativeInterface::QAndroidApplication::context()).callMethod<jboolean>("minimize", "()Z")) {
        emit error(tr("Unable to minimize app."));
        return false;
    }
    m_unloadGuiWhenHidden = true;
#else
    const auto windows = QGuiApplication::topLevelWindows();
    for (auto *const window : windows) {
        window->setWindowState(Qt::WindowMinimized);
    }
#endif
    return true;
}

void App::quit()
{
    m_app->quit();
}

bool App::storeSettings()
{
    if (!m_settingsFile.isOpen() && !openSettings()) {
        return false;
    }
    if (!m_settingsFile.seek(0)) {
        return false;
    }
    const auto bytesWritten = m_settingsFile.write(QJsonDocument(m_settings).toJson(QJsonDocument::Compact));
    if (bytesWritten < m_settingsFile.size() && !m_settingsFile.resize(bytesWritten)) {
        return false;
    }
    if (m_settingsFile.error() != QFile::NoError) {
        emit error(tr("Unable to save settings: ") + m_settingsFile.errorString());
        return false;
    }
    reloadSettings();
    return true;
}

/// \cond
static void ensureDefault(bool &mod, QJsonObject &o, QLatin1String member, const QJsonValue &d)
{
    if (!o.contains(member)) {
        o.insert(member, d);
        mod = true;
    }
}
/// \endcond

bool App::applyLauncherSettings()
{
#ifdef SYNCTHINGWIDGETS_USE_LIBSYNCTHING
    static constexpr auto runByDefault = true;
#else
    static constexpr auto runByDefault = false;
#endif
    auto launcherSettingsObj = m_settings.value(QLatin1String("launcher")).toObject();
    auto mod = false;
    ensureDefault(mod, launcherSettingsObj, QLatin1String("run"), runByDefault);
    ensureDefault(mod, launcherSettingsObj, QLatin1String("exePath"), QString());
    ensureDefault(mod, launcherSettingsObj, QLatin1String("stopOnMetered"), false);
    ensureDefault(mod, launcherSettingsObj, QLatin1String("writeLogFile"), false);
#ifdef SYNCTHINGWIDGETS_USE_LIBSYNCTHING
    ensureDefault(mod, launcherSettingsObj, QLatin1String("logLevel"), SyncthingLauncher::libSyncthingLogLevelString(LibSyncthing::LogLevel::Info));
#endif
    ensureDefault(mod, launcherSettingsObj, QLatin1String("stHomeDir"), QString());
    if (mod) {
        m_settings.insert(QLatin1String("launcher"), launcherSettingsObj);
    }
    return true;
}

bool App::applySettings()
{
    applySyncthingSettings();
    applyLauncherSettings();
    applyConnectionSettings(m_syncthingGuiUrl);
    auto mod = false;
    auto tweaksSettings = m_settings.value(QLatin1String("tweaks")).toObject();
    ensureDefault(mod, tweaksSettings, QLatin1String("closePreference"), QString());
    ensureDefault(mod, tweaksSettings, QLatin1String("exportDir"), QString());
    ensureDefault(mod, tweaksSettings, QLatin1String("useUnixDomainSocket"), false);
#ifdef Q_OS_ANDROID
    ensureDefault(mod, tweaksSettings, QLatin1String("importExportAsArchive"), true);
    ensureDefault(mod, tweaksSettings, QLatin1String("importExportEncryptionPassword"), QString());
#endif
    if (mod) {
        m_settings.insert(QLatin1String("tweaks"), tweaksSettings);
    }
    invalidateStatus();
    emit info(tr("App settings saved"));
    return true;
}

bool App::reloadSettings()
{
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::ReloadSettings);
#else
    emit settingsReloadRequested();
#endif
    return true;
}

bool App::clearLogfile()
{
    if (checkOngoingImportExport()) {
        return false;
    }

    // return early if there's nothing to change/remove
    auto launcherSettings = m_settings.value(QLatin1String("launcher"));
    auto launcherSettingsObj = launcherSettings.toObject();
    auto logfile = QFile(syncthingLogFilePath());
    auto persistentLogfileEnabled = launcherSettingsObj.value(QLatin1String("writeLogFile")).toBool();
    if (!persistentLogfileEnabled && !logfile.exists()) {
        emit info(tr("No logfile present anyway"));
        return true;
    }

    // stop Syncthing if running
    if (m_isSyncthingRunning) {
        emit info(tr("Waiting for backend to terminate before clearing logs …"));
        m_clearingLogfile = true;
        terminateSyncthing();
        return false;
    }

    // remove log file
    auto ok = !logfile.exists() || logfile.remove();
    if (ok) {
        emit info(tr("Persistent logging disabled and logfile removed"));
    } else {
        emit error(tr("Unable to remove logfile"));
    }

    // disable persistent logging
    if (!persistentLogfileEnabled) {
        return reloadSettings() && ok;
    }
    launcherSettingsObj.insert(QLatin1String("writeLogFile"), false);
    m_settings.insert(QLatin1String("launcher"), launcherSettingsObj);
    emit settingsChanged(m_settings);
    return storeSettings() && ok;
}

/*!
 * \brief Opens the Syncthing config file in the standard editor.
 */
bool App::openSyncthingConfigFile()
{
    return m_models.openPath(m_syncthingConfigDir + QStringLiteral("/config.xml"));
}

/*!
 * \brief Opens the Syncthing log file in the standard editor.
 */
bool App::openSyncthingLogFile()
{
    return m_models.openPath(syncthingLogFilePath());
}

bool App::checkOngoingImportExport()
{
    if (m_importExportStatus != ImportExportStatus::None) {
        emit info(tr("Another import/export still pending"));
        return true;
    } else {
        return false;
    }
}

void App::setImportExportStatus(ImportExportStatus importExportStatus)
{
    if (m_importExportStatus != importExportStatus) {
        m_importExportStatus = importExportStatus;
        emit importExportOngoingChanged(importExportStatus != ImportExportStatus::None);
        invalidateStatus();
    }
}

/*!
 * \brief Checks the location specified via \a url for settings to import.
 * \remarks The \a url is passed by value for consistency with other functions.
 */
bool App::checkSettings(QUrl url, const QJSValue &callback)
{
    if (checkOngoingImportExport()) {
        return false;
    }
    setImportExportStatus(ImportExportStatus::Checking);
    const auto tweaksSettings = m_settings.value(QLatin1String("tweaks")).toObject();
    QtConcurrent::run([this, url, currentHomePath = currentSyncthingHomeDir(),
                          asArchive = tweaksSettings.value(QLatin1String("importExportAsArchive")).toBool(),
                          encryptionPassword = tweaksSettings.value(QLatin1String("importExportEncryptionPassword")).toString()]() mutable {
        auto availableSettings = QVariantMap();
        auto pathStr = m_models.resolveUrl(url);
#ifdef Q_OS_ANDROID
        auto tempDir = QString();
#endif
        auto errors = QStringList();

        if (asArchive) {
#ifdef Q_OS_ANDROID
            if (!m_settingsDir.has_value()) {
                errors.append(tr("Settings directory was not located."));
            } else {
                const auto extStoragePaths = m_models.externalStoragePaths();
                if (!extStoragePaths.isEmpty()) {
                    tempDir = extStoragePaths.back() + QStringLiteral("/import-tmp");
                } else {
                    tempDir = m_settingsDir->path() + QStringLiteral("/../import-tmp");
                }
                try {
                    const auto tempPath = std::filesystem::path(SYNCTHING_APP_STRING_CONVERSION(tempDir));
                    if (std::filesystem::exists(tempPath)) {
                        std::filesystem::remove_all(tempPath);
                    }
                    std::filesystem::create_directories(tempPath);
                    tempDir = QString::fromStdString(std::filesystem::absolute(tempPath));
                    const auto error = QJniObject(QNativeInterface::QAndroidApplication::context())
                                           .callMethod<jstring>("extractArchive", pathStr, tempDir, encryptionPassword)
                                           .toString();
                    if (!error.isEmpty()) {
                        QDir(tempDir).removeRecursively();
                        errors.append(tr("Unable to extract archive: %1").arg(error));
                    }
                    availableSettings.insert(QStringLiteral("tempDir"), tempDir);
                    pathStr = tempDir;
                } catch (const std::runtime_error &e) {
                    errors.append(tr("Unable to create temp dir: %1").arg(e.what()));
                }
            }
#else
            errors.append(tr("archiving is only supported on Android."));
#endif
            if (!errors.isEmpty()) {
                availableSettings.insert(QStringLiteral("error"), errors.join(QChar('\n')));
                return availableSettings;
            }
        }

        auto path = std::filesystem::path(SYNCTHING_APP_STRING_CONVERSION(pathStr));
        availableSettings.insert(QStringLiteral("path"), pathStr);
        availableSettings.insert(QStringLiteral("currentSyncthingHomePath"), std::move(currentHomePath));
        try {
            auto appConfigPath = path / "appconfig.json";
            if (std::filesystem::exists(appConfigPath)) {
                availableSettings.insert(QStringLiteral("appConfigPath"), SYNCTHING_APP_PATH_CONVERSION(appConfigPath));
            }
            auto syncthingHomePath = path;
            auto syncthingHomePathNested = path / "syncthing";
            if (std::filesystem::is_directory(syncthingHomePathNested)) {
                syncthingHomePath = std::move(syncthingHomePathNested);
            }
            if (!std::filesystem::is_empty(syncthingHomePath)) {
                availableSettings.insert(QStringLiteral("syncthingHomePath"), SYNCTHING_APP_PATH_CONVERSION(syncthingHomePath));
            } else {
                errors.append(tr("The Syncthing home directory under \"%1\" is empty.").arg(SYNCTHING_APP_PATH_CONVERSION(syncthingHomePath)));
            }
            auto syncthingConfigPath = syncthingHomePath / "config.xml";
            if (std::filesystem::exists(syncthingConfigPath)) {
                auto syncthingConfigPathStr = SYNCTHING_APP_PATH_CONVERSION(syncthingConfigPath);
                auto syncthingConfig = Data::SyncthingConfig();
                syncthingConfig.restore(syncthingConfigPathStr, true);
                availableSettings.insert(QStringLiteral("syncthingConfigPath"), syncthingConfigPathStr);
                if (syncthingConfig.details.has_value()) {
                    availableSettings.insert(QStringLiteral("folders"), syncthingConfig.details->folders);
                    availableSettings.insert(QStringLiteral("devices"), syncthingConfig.details->devices);
                }
            } else {
                errors.append(tr("No Syncthing configuration file found under \"%1\".").arg(SYNCTHING_APP_PATH_CONVERSION(syncthingConfigPath)));
            }
        } catch (const std::filesystem::filesystem_error &e) {
            errors.append(QString::fromUtf8(e.what()));
        }
        if (!errors.isEmpty()) {
            availableSettings.insert(QStringLiteral("error"), errors.join(QChar('\n')));
        }
        return availableSettings;
    }).then(this, [this, callback](const QVariantMap &availableSettings) {
        setImportExportStatus(ImportExportStatus::None);
        if (callback.isCallable()) {
            callback.call(QJSValueList{ m_engine->toScriptValue(availableSettings) });
        }
    });
    return true;
}

/// \cond
static QJsonObject makeObjectFromDefaults(const QJsonObject &defaults, const QJsonObject &values)
{
    auto res = QJsonObject(defaults);
    const auto keys = values.keys();
    for (const auto &key : keys) {
        const auto value = values.value(key);
        if (!value.isUndefined() && !value.isNull()) {
            res.insert(key, value);
        }
    }
    res.insert(QStringLiteral("paused"), true);
    return res;
}

static QStringList::size_type importObjects(
    const QJsonObject &defaults, const QJsonArray &available, const QVariantList &selected, QJsonArray &destination)
{
    auto imported = QStringList::size_type();
    for (const auto &selectedIndex : selected) {
        auto ok = false;
        auto i = selectedIndex.toInt(&ok);
        if (ok && i >= 0 && i < available.size()) {
            destination.append(makeObjectFromDefaults(defaults, available.at(i).toObject()));
            ++imported;
        }
    }
    return imported;
}
/// \endcond

void App::terminateSyncthing()
{
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::TerminateSyncthing);
#else
    emit syncthingTerminationRequested();
#endif
}

void App::restartSyncthing()
{
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::RestartSyncthing);
#else
    emit syncthingRestartRequested();
#endif
}

void App::shutdownSyncthing()
{
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::ShutdownSyncthing);
#else
    emit syncthingShutdownRequested();
#endif
}

void App::connectToSyncthing()
{
    m_data.connection()->connect();
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::ConnectToSyncthing);
#else
    emit syncthingConnectRequested();
#endif
}

void App::reconnectToSyncthing()
{
    emit info(tr("Triggered re-connect with Syncthing backend"));
    m_data.connection()->reconnect();
#ifdef Q_OS_ANDROID
    sendMessageToService(ServiceAction::ReconnectToSyncthing);
#else
    emit syncthingReconnectRequested();
#endif
}

/*!
 * \brief Imports selected settings (app-related and of Syncthing itself) from the specified \a url.
 * \remarks The settings are passed by value so m_settingsImport can be passed despite being cleared by this function.
 */
bool App::importSettings(QVariantMap availableSettings, QVariantMap selectedSettings, const QJSValue &callback)
{
    if (checkOngoingImportExport()) {
        return false;
    }
    if (!m_settingsDir.has_value()) {
        emit error(tr("Unable to import settings: settings directory was not located."));
        return false;
    }

    // stop Syncthing if necessary
    const auto importSyncthingHome = selectedSettings.value(QStringLiteral("syncthingHome")).toBool();
    if (importSyncthingHome && m_isSyncthingRunning) {
        emit info(tr("Waiting for backend to terminate before importing settings …"));
        m_settingsImport.availableSettings = availableSettings;
        m_settingsImport.selectedSettings = selectedSettings;
        m_settingsImport.callback = callback;
        terminateSyncthing();
        return false;
    }

    setImportExportStatus(ImportExportStatus::Importing);
    m_settingsImport.availableSettings.clear();
    m_settingsImport.selectedSettings.clear();

    struct ImportResult {
        QString message;
        bool hasFailed = false;
        bool shouldReloadSettings = true;
    };
    QtConcurrent::run([this, importSyncthingHome, availableSettings, selectedSettings, rawConfig = m_data.connection()->rawConfig()]() mutable {
        // copy selected files from import directory to settings directory
        auto summary = QStringList();
        auto aborted = selectedSettings.value(QStringLiteral("aborted")).toBool();
        auto syncthingHomePath = availableSettings.value(QStringLiteral("currentSyncthingHomePath")).toString();
        try {
            // copy app config file
            const auto settingsPath = std::filesystem::path(SYNCTHING_APP_STRING_CONVERSION(m_settingsDir->path()));
            const auto importAppConfig = selectedSettings.value(QStringLiteral("appConfig")).toBool();
            if (importAppConfig && !aborted) {
                const auto appConfigSrcPathStr = availableSettings.value(QStringLiteral("appConfigPath")).toString();
                const auto appConfigSrcPath = SYNCTHING_APP_STRING_CONVERSION(appConfigSrcPathStr);
                const auto appConfigDstPath = settingsPath / "appconfig.json";
                auto appConfigSrcFile = QFile();
                auto appConfigObj = QJsonObject();
                if (const auto errorMessage = openSettingFile(appConfigSrcFile, appConfigSrcPathStr); !errorMessage.isEmpty()) {
                    throw std::runtime_error(errorMessage.toStdString());
                }
                if (const auto errorMessage = readSettingFile(appConfigSrcFile, appConfigObj); !errorMessage.isEmpty()) {
                    throw std::runtime_error(errorMessage.toStdString());
                }
                const auto newLauncherSettings = appConfigObj.value(QLatin1String("launcher")).toObject();
                if (newLauncherSettings.contains(QLatin1String("stHomePath"))) {
                    syncthingHomePath = newLauncherSettings.value(QLatin1String("stHomePath")).toString();
                }
                appConfigSrcFile.close();
                std::filesystem::copy_file(appConfigSrcPath, appConfigDstPath, std::filesystem::copy_options::overwrite_existing);
                summary.append(tr("Imported app config from \"%1\".").arg(appConfigSrcPathStr));
            }

            // copy Syncthing home directory
            if (importSyncthingHome && !aborted) {
                const auto homeSrcPathStr = availableSettings.value(QStringLiteral("syncthingHomePath")).toString();
                const auto homeSrcPath = SYNCTHING_APP_STRING_CONVERSION(homeSrcPathStr);
                const auto homeDstPath = syncthingHomePath.isEmpty() ? (settingsPath / "syncthing")
                                                                     : std::filesystem::path(SYNCTHING_APP_STRING_CONVERSION(syncthingHomePath));
                std::filesystem::remove_all(homeDstPath);
                std::filesystem::create_directory(homeDstPath);
                std::filesystem::copy(
                    homeSrcPath, homeDstPath, std::filesystem::copy_options::update_existing | std::filesystem::copy_options::recursive);
                summary.append(tr("Imported Syncthing config and database from \"%1\".").arg(homeSrcPathStr));
            }
        } catch (const std::runtime_error &e) {
            return ImportResult(QString::fromUtf8(e.what()), true);
        }

        // merge selected folders/devices into existing config
        if (!importSyncthingHome && !aborted) {
            const auto availableFolders = availableSettings.value(QStringLiteral("folders")).toJsonArray();
            const auto availableDevices = availableSettings.value(QStringLiteral("devices")).toJsonArray();
            const auto selectedFolders = selectedSettings.value(QStringLiteral("selectedFolders")).value<QVariantList>();
            const auto selectedDevices = selectedSettings.value(QStringLiteral("selectedDevices")).value<QVariantList>();
            const auto defaults = rawConfig.value(QStringLiteral("defaults")).toObject();
            const auto folderTemplate = defaults.value(QStringLiteral("folder")).toObject();
            const auto deviceTemplate = defaults.value(QStringLiteral("device")).toObject();
            const auto foldersVal = rawConfig.value(QStringLiteral("folders"));
            const auto devicesVal = rawConfig.value(QStringLiteral("devices"));
            if (!foldersVal.isArray() || !devicesVal.isArray()) {
                return ImportResult(tr("Unable to find folders/devices in current Syncthing config."), true);
            }
            auto folders = foldersVal.toArray();
            auto devices = devicesVal.toArray();
            const auto importedFolders = importObjects(folderTemplate, availableFolders, selectedFolders, folders);
            const auto importedDevices = importObjects(deviceTemplate, availableDevices, selectedDevices, devices);
            if (importedFolders || importedDevices) {
                if (importedFolders) {
                    rawConfig.insert(QStringLiteral("folders"), folders);
                }
                if (importedDevices) {
                    rawConfig.insert(QStringLiteral("devices"), devices);
                }
                auto newConfigJson = QJsonDocument(rawConfig).toJson(QJsonDocument::Compact);
                if (QMetaObject::invokeMethod(
                        m_data.connection(), &SyncthingConnection::postRawConfig, Qt::QueuedConnection, std::move(newConfigJson))) {
                    summary.append(tr("Merging %1 folders and %2 devices").arg(importedFolders).arg(importedDevices));
                } else {
                    return ImportResult(tr("Unable to import folders/devices."), true);
                }
            }
        }

        if (const auto tempDir = availableSettings.value(QStringLiteral("tempDir")).toString(); !tempDir.isEmpty()) {
            try {
                std::filesystem::remove_all(SYNCTHING_APP_STRING_CONVERSION(tempDir));
            } catch (const std::runtime_error &e) {
                return ImportResult(tr("Unable to remove temp dir: %1").arg(e.what()), true);
            }
        }

        const auto shouldReloadSettings = !summary.isEmpty();
        if (summary.isEmpty() && !aborted) {
            summary.append(tr("Nothing has been imported."));
        }
        return ImportResult(summary.join(QChar('\n')), false, shouldReloadSettings);
    }).then(this, [this, callback](const ImportResult &res) {
        setImportExportStatus(ImportExportStatus::None);

        if (callback.isCallable()) {
            callback.call(QJSValueList{ QJSValue(res.message), QJSValue(res.hasFailed) });
        }
        if (res.hasFailed) {
            emit error(tr("Unable to import settings: %1").arg(res.message));
        } else if (!res.message.isEmpty()) {
            emit info(res.message);
        }

        // reload settings
        if (res.shouldReloadSettings) {
            m_settingsFile.close();
            loadSettings();
            applySettings();
            reloadSettings();
        }
    });
    return true;
}

/*!
 * \brief Returns the path to create a sub directory or file within within \a dir.
 */
static QString makeExportPath(const QString &dir, const QString &name, const QString &extension)
{
    const auto now = CppUtilities::DateTime::now();
    const auto date
        = QStringLiteral("%1-%2-%3").arg(now.year(), 4, 10, QChar('0')).arg(now.month(), 2, 10, QChar('0')).arg(now.day(), 2, 10, QChar('0'));
    const auto time
        = QStringLiteral("%1-%2-%3").arg(now.hour(), 2, 10, QChar('0')).arg(now.minute(), 2, 10, QChar('0')).arg(now.second(), 2, 10, QChar('0'));
    auto path = QString();
    for (int index = 0; path.isEmpty() || QFile::exists(path); ++index) {
        const auto indexStr = index ? QChar('-') % QString::number(index) : QString();
        path = dir % QChar('/') % name % QChar('-') % date % QChar('T') % time % indexStr % extension;
    }
    return path;
}

/*!
 * \brief Exports all settings (app-related and of Syncthing itself) to the specified \a url.
 * \remarks The \a url is passed by value so m_settingsExport can be passed despite being cleared by this function.
 */
bool App::exportSettings(QUrl url, const QJSValue &callback)
{
    if (checkOngoingImportExport()) {
        return false;
    }

    if (m_isSyncthingRunning) {
        emit info(tr("Waiting for backend to terminate before exporting settings …"));
        m_settingsExport.url = url;
        m_settingsExport.callback = callback;
        terminateSyncthing();
        return false;
    }

    setImportExportStatus(ImportExportStatus::Exporting);
    m_settingsExport.url.reset();

    const auto tweaksSettings = m_settings.value(QLatin1String("tweaks")).toObject();
    QtConcurrent::run([this, url, currentHomePath = currentSyncthingHomeDir(),
                          asArchive = tweaksSettings.value(QLatin1String("importExportAsArchive")).toBool(),
                          encryptionPassword = tweaksSettings.value(QLatin1String("importExportEncryptionPassword")).toString(),
                          exportDir = tweaksSettings.value(QLatin1String("exportDir")).toString()] {
        if (!m_settingsDir.has_value()) {
            return std::make_pair(tr("settings directory was not located."), true);
        }

        auto path = QString();
        if (url.isEmpty()) {
            if (exportDir.isEmpty()) {
                return std::make_pair(tr("no destination or file or directory specified/configured."), true);
            }
            path = makeExportPath(exportDir, QStringLiteral("syncthing-app-backup"), asArchive ? QStringLiteral(".zip") : QString());
        } else {
            path = m_models.resolveUrl(url);
        }

        if (asArchive) {
#ifdef Q_OS_ANDROID
            const auto error = QJniObject(QNativeInterface::QAndroidApplication::context())
                                   .callMethod<jstring>("compressArchive", m_settingsDir->path(), currentHomePath, path, encryptionPassword)
                                   .toString();
            return std::make_pair(error.isEmpty() ? tr("Settings have been archived to \"%1\".").arg(path) : error, !error.isEmpty());
#else
            return std::make_pair(tr("Archiving is only supported on Android."), true);
#endif
        }

        const auto dir = QDir(path);
        if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) {
            return std::make_pair(tr("unable to create export directory under \"%1\"").arg(path), true);
        }
        try {
            std::filesystem::copy(SYNCTHING_APP_STRING_CONVERSION(m_settingsDir->path()), SYNCTHING_APP_STRING_CONVERSION(path),
                std::filesystem::copy_options::update_existing | std::filesystem::copy_options::recursive);
            if (!currentHomePath.isEmpty()) {
                std::filesystem::copy(SYNCTHING_APP_STRING_CONVERSION(currentHomePath),
                    SYNCTHING_APP_STRING_CONVERSION(path + QStringLiteral("/syncthing")),
                    std::filesystem::copy_options::update_existing | std::filesystem::copy_options::recursive);
            }
        } catch (const std::filesystem::filesystem_error &e) {
            return std::make_pair(QString::fromUtf8(e.what()), true);
        }
        return std::make_pair(tr("Settings have been exported to \"%1\".").arg(path), false);
    }).then(this, [this, callback](const std::pair<QString, bool> &res) {
        setImportExportStatus(ImportExportStatus::None);
        if (callback.isCallable()) {
            callback.call(QJSValueList{ QJSValue(res.first), QJSValue(res.second) });
        }
        if (res.second) {
            emit error(tr("Unable to export settings: %1").arg(res.first));
        } else {
            emit info(res.first);
        }
        reloadSettings(); // launch Syncthing again if configured
    });
    return true;
}

QString App::currentSyncthingHomeDir() const
{
    return m_settings.value(QLatin1String("launcher")).toObject().value(QLatin1String("stHomeDir")).toString();
}

const QString &App::closePreference()
{
    if (!m_closePreference.has_value()) {
        m_closePreference.emplace(m_settings.value(QLatin1String("tweaks")).toObject().value(QLatin1String("closePreference")).toString());
    }
    return m_closePreference.value();
}

qint64 App::databaseSize(const QString &path, const QString &extension) const
{
    const auto dir = QDir(m_syncthingDataDir % QChar('/') % path);
    const auto files = dir.entryInfoList({ extension }, QDir::Files);
    return std::accumulate(files.begin(), files.end(), qint64(), [](auto size, const auto &file) { return size + file.size(); });
}

QVariant App::formattedDatabaseSize(const QString &path, const QString &extension) const
{
    const auto size = databaseSize(path, extension);
    return size > 0 ? QVariant(QString::fromStdString(CppUtilities::dataSizeToString(static_cast<std::uint64_t>(size)))) : QVariant();
}

QVariantMap App::statistics() const
{
    auto stats = QVariantMap();
    statistics(stats);
    return stats;
}

void App::statistics(QVariantMap &res) const
{
#ifdef Q_OS_ANDROID
    res[QStringLiteral("extFilesDir")] = m_models.externalFilesDir();
    res[QStringLiteral("extStoragePaths")] = m_models.externalStoragePaths();
#endif
    if (!m_connectToLaunched) {
        return;
    }
    res[QStringLiteral("stConfigDir")] = m_syncthingConfigDir;
    res[QStringLiteral("stDataDir")] = m_syncthingDataDir;
    res[QStringLiteral("stLevelDbSize")] = formattedDatabaseSize(QStringLiteral("index-v0.14.0.db"), QStringLiteral("*.ldb"));
    res[QStringLiteral("stLevelDbMigratedSize")] = formattedDatabaseSize(QStringLiteral("index-v0.14.0.db-migrated"), QStringLiteral("*.ldb"));
    res[QStringLiteral("stSQLiteDbSize")] = formattedDatabaseSize(QStringLiteral("index-v2"), QStringLiteral("*.db*"));
}

/*!
 * \brief Checks the current location of the Syncthing home directory and where it could be moved.
 */
bool App::checkSyncthingHome(const QJSValue &callback)
{
    if (checkOngoingImportExport()) {
        return false;
    }
    setImportExportStatus(ImportExportStatus::CheckingMove);
    QtConcurrent::run([this, defaultPath = m_settingsDir.value_or(QDir()).path(), currentVal = currentSyncthingHomeDir()]() mutable {
        const auto externalDirs = m_models.externalStoragePaths();
        auto availableDirs = QVariantList();
        auto defaultDir = QVariantMap();
        defaultDir[QStringLiteral("path")] = (defaultPath += QStringLiteral("/syncthing"));
        defaultDir[QStringLiteral("value")] = QString();
        defaultDir[QStringLiteral("label")] = tr("Default directory");
        defaultDir[QStringLiteral("selected")] = currentVal.isEmpty();
        defaultDir[QStringLiteral("populated")] = m_models.isPopulated(defaultPath);
        availableDirs.reserve(externalDirs.size() + 1);
        availableDirs.emplace_back(defaultDir);
        auto externalStorageNumber = 0;
        auto hasCurrentPath = false;
        for (const auto &externalDir : externalDirs) {
            const auto path = externalDir + QStringLiteral("/syncthing");
            auto dir = QVariantMap();
            auto isCurrentPath = currentVal == path;
            if (isCurrentPath) {
                hasCurrentPath = true;
            }
            dir[QStringLiteral("path")] = path;
            dir[QStringLiteral("value")] = path;
            dir[QStringLiteral("label")] = tr("External storage %1").arg(++externalStorageNumber);
            dir[QStringLiteral("selected")] = isCurrentPath;
            dir[QStringLiteral("populated")] = m_models.isPopulated(path);
            availableDirs.emplace_back(dir);
        }
        if (!currentVal.isEmpty() && !hasCurrentPath) {
            auto dir = QVariantMap();
            dir[QStringLiteral("path")] = currentVal;
            dir[QStringLiteral("value")] = currentVal;
            dir[QStringLiteral("label")] = tr("Current home directory");
            dir[QStringLiteral("selected")] = true;
            dir[QStringLiteral("populated")] = m_models.isPopulated(currentVal);
            availableDirs.emplace_back(dir);
        }
        auto currentHome = currentVal.isEmpty() ? defaultPath : currentVal;
        auto homeDirInfo = QVariantMap();
        homeDirInfo[QStringLiteral("currentHome")] = currentHome;
        homeDirInfo[QStringLiteral("currentHomePopulated")] = m_models.isPopulated(currentHome);
        homeDirInfo[QStringLiteral("availableDirs")] = std::move(availableDirs);
        return homeDirInfo;
    }).then(this, [this, callback](const QVariantMap &homeDirInfo) {
        setImportExportStatus(ImportExportStatus::None);
        if (callback.isCallable()) {
            callback.call(QJSValueList{ m_engine->toScriptValue(homeDirInfo) });
        }
    });
    return true;
}

/*!
 * \brief Moves the Syncthing home directory to the specified \a newHomeDir.
 * \remarks The \a newHomeDir argument is passed by value so m_homeDirMove can be passed despite being cleared by this function.
 */
bool App::moveSyncthingHome(QString newHomeDir, const QJSValue &callback)
{
    if (checkOngoingImportExport()) {
        return false;
    }
    if (!m_settingsDir.has_value()) {
        emit error(tr("Unable to move Syncthing home: settings directory was not located."));
        return false;
    }

    // stop Syncthing if necessary
    if (m_isSyncthingRunning) {
        emit info(tr("Waiting for backend to terminate before moving home …"));
        m_homeDirMove.newHomeDir = newHomeDir;
        m_homeDirMove.callback = callback;
        terminateSyncthing();
        return false;
    }

    setImportExportStatus(ImportExportStatus::Moving);
    m_homeDirMove.newHomeDir.reset();

    QtConcurrent::run([newHomeDir = newHomeDir, customHomeDir = currentSyncthingHomeDir(), defaultPath = m_settingsDir.value_or(QDir()).path(),
                          rawConfig = m_data.connection()->rawConfig()]() mutable {
        // determine paths
        const auto sourceDir = customHomeDir.isEmpty() ? defaultPath + QStringLiteral("/syncthing") : customHomeDir;
        const auto sourceDirStd = std::filesystem::path(SYNCTHING_APP_STRING_CONVERSION(sourceDir));
        const auto destinationDir = newHomeDir.isEmpty() ? defaultPath + QStringLiteral("/syncthing") : newHomeDir;
        const auto destinationDirStd = std::filesystem::path(SYNCTHING_APP_STRING_CONVERSION(destinationDir));

        // return early if source and destination are equivalent
        if (newHomeDir.isNull()) {
            newHomeDir = QLatin1String("");
        }
        if (auto ec = std::error_code(); std::filesystem::equivalent(sourceDirStd, destinationDirStd, ec) && !ec) {
            return std::make_tuple(newHomeDir, tr("Home directory stays the same."), false);
        }

        // copy files from source directory (if existing) to destination directory
        auto summary = QStringList();
        auto copied = false;
        try {
            const auto sourceStatus = std::filesystem::symlink_status(sourceDirStd);
            const auto destinationStatus = std::filesystem::symlink_status(destinationDirStd);
            if (std::filesystem::is_directory(sourceStatus) && !std::filesystem::is_empty(sourceDirStd)) {
                std::filesystem::remove_all(destinationDirStd);
                summary.append(tr("Cleaned up new home directory \"%1\".").arg(destinationDir));

                std::filesystem::create_directory(destinationDirStd);
                std::filesystem::copy(
                    sourceDirStd, destinationDirStd, std::filesystem::copy_options::update_existing | std::filesystem::copy_options::recursive);
                copied = true;
                summary.append(tr("Copied data from previous home directory \"%1\" to new one.").arg(sourceDir));

                std::filesystem::remove_all(sourceDirStd);
                summary.append(tr("Cleaned up previous home directory."));
            } else if (std::filesystem::is_directory(destinationStatus)) {
                if (std::filesystem::is_empty(destinationDirStd)) {
                    summary.append(tr("Configured \"%1\" as new/empty Syncthing home.").arg(destinationDir));
                } else {
                    summary.append(tr("Configured \"%1\" as Syncthing home.").arg(destinationDir));
                }
            }
        } catch (const std::filesystem::filesystem_error &e) {
            summary.append(tr("Unable to move home directory: %1").arg(QString::fromUtf8(e.what())));
            return std::make_tuple(copied ? newHomeDir : QString(), summary.join(QChar('\n')), true);
        }
        return std::make_tuple(newHomeDir, summary.join(QChar('\n')), false);
    }).then(this, [this, callback](const std::tuple<QString, QString, bool> &res) {
        auto [setHomeDir, message, errorOccurred] = res;
        if (!setHomeDir.isNull()) {
            auto launcherSettings = m_settings.value(QLatin1String("launcher")).toObject();
            launcherSettings.insert(QLatin1String("stHomeDir"), setHomeDir);
            m_settings.insert(QLatin1String("launcher"), launcherSettings);
            storeSettings();
            emit settingsChanged(m_settings);
        }

        setImportExportStatus(ImportExportStatus::None);
        if (callback.isCallable()) {
            callback.call(QJSValueList{ QJSValue(message), QJSValue(errorOccurred) });
        }
        emit errorOccurred ? error(message) : info(message);
        applySettings();
        reloadSettings();
    });
    return true;
}

/*!
 * \brief Downloads a support bundle from Syncthing and stores it at the specified \a url.
 * \remarks The \a url argument is passed by value for consistency.
 */
bool App::saveSupportBundle(QUrl url, const QJSValue &callback)
{
    if (checkOngoingImportExport()) {
        return false;
    }
    const auto debuggingSetting = m_data.connection()->rawConfig().value(QLatin1String("gui")).toObject().value(QLatin1String("debugging"));
    if (!debuggingSetting.isUndefined() && !debuggingSetting.toBool()) {
        // before Syncthing commit d682220305a07d2fa31825a4d377d20eb0e1f1fb the route for creating a debug bundle needs to be enabled
        // explicitly
        emit error(tr("Debugging needs to be enabled under advanced GUI settings first."));
        return false;
    }
    auto path = QString();
    if (url.isEmpty()) {
        const auto exportDir = m_settings.value(QLatin1String("tweaks")).toObject().value(QLatin1String("exportDir")).toString();
        if (exportDir.isEmpty()) {
            emit error(tr("No destination or file or directory specified/configured."));
            return false;
        }
        path = makeExportPath(exportDir, QStringLiteral("syncthing-app-support-bundle"), QStringLiteral(".zip"));
    } else {
        path = m_models.resolveUrl(url);
    }
    m_download.outputFile.emplace(path);
    if (!m_download.outputFile->open(QFile::WriteOnly | QFile::Truncate)) {
        emit error(tr("Unable to open output file under \"%1\": %2").arg(path, m_download.outputFile->errorString()));
        return false;
    }
    setImportExportStatus(ImportExportStatus::SavingSupportBundle);
    auto *const reply = m_data.connection()->downloadSupportBundle().reply;
    connect(reply, &QNetworkReply::readyRead, this, [this, reply] {
        m_download.outputFile->write(reply->readAll());
        if (m_download.outputFile->error() != QFile::NoError) {
            reply->abort();
        }
    });
    connect(reply, &QNetworkReply::finished, this, [this, reply, callback] {
        reply->deleteLater();
        m_download.outputFile->write(reply->readAll());
        m_download.outputFile->flush();
        auto errors = QStringList();
        auto message = QString();
        if (m_download.outputFile->error() != QFile::NoError) {
            errors << tr("Unable to write bundle: %1").arg(m_download.outputFile->errorString());
        }
        if (const auto replyError = reply->error(); replyError != QNetworkReply::NoError && replyError != QNetworkReply::OperationCanceledError) {
            errors << tr("Unable to download bundle: %1").arg(reply->errorString());
        }
        m_download.outputFile.reset();
        errors.isEmpty() ? emit info(tr("Support bundle saved")) : emit error(message = errors.join(QChar('\n')));
        setImportExportStatus(ImportExportStatus::None);
        if (callback.isCallable()) {
            callback.call(QJSValueList{ QJSValue(message), QJSValue(errors.isEmpty()) });
        }
    });
    return true;
}

/*!
 * \brief Cleans the Syncthing home directory.
 */
bool App::cleanSyncthingHomeDirectory(const QJSValue &callback)
{
    if (checkOngoingImportExport()) {
        return false;
    }
    setImportExportStatus(ImportExportStatus::Cleaning);
    QtConcurrent::run([
#ifdef Q_OS_ANDROID
                          this,
#endif
                          dataDir = m_syncthingDataDir, settingsDir = m_settingsDir.has_value() ? m_settingsDir->path() : QString()]() mutable {
        auto summary = QStringList();
        auto error = false;

        // remove old database
        if (auto oldDbDir = QDir(dataDir + QStringLiteral("/index-v0.14.0.db-migrated")); oldDbDir.exists()) {
            if (oldDbDir.removeRecursively()) {
                summary.append(tr("Removed old database directory."));
            } else {
                summary.append(tr("Unable to remove old database directory."));
                error = true;
            }
        }

        // remove old support bundles
        // note: Syncthing stores the support bundle when downloading one via the API in the local data directory as well.
        auto dataDirectory = QDir(dataDir);
        auto supportBundlePaths = dataDirectory.entryList(QStringList(QStringLiteral("support-bundle-*")), QDir::Files);
        auto supportBundlesRemoved = false, supportBundlesFailed = false;
        for (const auto &supportBundle : std::as_const(supportBundlePaths)) {
            if (dataDirectory.remove(supportBundle)) {
                supportBundlesRemoved = true;
            } else if (!supportBundlesFailed) {
                error = supportBundlesFailed = true;
                summary.append(tr("Unable to remove all support bundles."));
            }
        }
        if (supportBundlesRemoved && !supportBundlesFailed) {
            summary.append(tr("Support bundles have been removed."));
        }

        // remove potential leftover directory from importing settings
        // note: This is in accordance with paths used in checkSettings(). Normally importSettings() will clean this up. However, if the app is terminated forcefully while
        //       the import page is shown that code might not have a chance to run.
#ifdef Q_OS_ANDROID
        auto tempImportPaths = m_models.externalStoragePaths();
        auto tempImportPathsRemoved = false, tempImportPathsFailed = false;
        tempImportPaths.append(settingsDir + QStringLiteral("/.."));
        for (const auto &tempImportPath : std::as_const(tempImportPaths)) {
            if (auto tempDir = QDir(tempImportPath + QStringLiteral("/import-tmp")); tempDir.exists()) {
                if (tempDir.removeRecursively()) {
                    tempImportPathsRemoved = true;
                } else {
                    error = tempImportPathsFailed = true;
                    summary.append(tr("Unable to remove leftovers from import under \"%1\".").arg(tempDir.path()));
                }
            }
        }
        if (tempImportPathsRemoved && !tempImportPathsFailed) {
            summary.append(tr("Removed leftovers from import."));
        }
#endif

        if (summary.isEmpty()) {
            summary.append(tr("There was nothing to clean up."));
        }
        return std::make_tuple(summary.join(QChar('\n')), error);
    }).then(this, [this, callback](const std::tuple<QString, bool> &res) {
        auto [message, errorOccurred] = res;
        setImportExportStatus(ImportExportStatus::None);
        if (callback.isCallable()) {
            callback.call(QJSValueList{ QJSValue(message), QJSValue(errorOccurred) });
        }
        emit errorOccurred ? error(message) : info(message);
        applySettings();
    });
    return true;
}

} // namespace QtGui
