#include "../syncthingconnection.h"
#include "../syncthingconnectionsettings.h"

#include "../../testhelper/helper.h"
#include "../../testhelper/syncthingtestinstance.h"

#include <qtutilities/misc/conversion.h>

#include <c++utilities/tests/testutils.h>

#include <cppunit/TestFixture.h>

#include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QStringBuilder>

#include <thread>

using namespace std;
using namespace Data;
using namespace CppUtilities;
using namespace CppUtilities::Literals;

using namespace CPPUNIT_NS;

/*!
 * \brief The ConnectionTests class tests the SyncthingConnector.
 */
class ConnectionTests : public TestFixture, private SyncthingTestInstance {
    CPPUNIT_TEST_SUITE(ConnectionTests);
    CPPUNIT_TEST(testConnection);
    CPPUNIT_TEST_SUITE_END();

public:
    ConnectionTests();

    void testConnection();

    void testErrorCases();
    void testInitialConnection();
    void testSendingError();
    void checkDevices();
    void checkDirectories() const;
    void testReconnecting();
    void testResumingAllDevices();
    void testResumingDirectory();
    void testPausingDirectory();
    void testRequestingLog();
    void testRequestingQrCode();
    void testDisconnecting();
    void testConnectingWithSettings();
    void testRequestingRescan();
    void testDealingWithArbitraryConfig();
    void testSuspendResume();

    void setUp() override;
    void tearDown() override;

private:
    template <typename Action, typename... Signalinfo> void waitForConnection(Action action, int timeout, const Signalinfo &...signalInfo);
    template <typename Action, typename FailureSignalInfo, typename... Signalinfo>
    void waitForConnectionOrFail(Action action, int timeout, const FailureSignalInfo &failureSignalInfo, const Signalinfo &...signalInfo);
    template <typename Signal, typename Handler = function<void(void)>>
    SignalInfo<Signal, Handler> connectionSignal(
        Signal signal, const Handler &handler = function<void(void)>(), bool *correctSignalEmitted = nullptr);
    static void (SyncthingConnection::*defaultConnect())(void);
    static void (SyncthingConnection::*defaultReconnect())(void);
    static void (SyncthingConnection::*defaultDisconnect())(void);

    template <typename Handler> TemporaryConnection handleNewDevices(const Handler &handler);
    template <typename Handler> TemporaryConnection handleNewDirs(const Handler &handler);
    WaitForConnected connectedSignal() const;
    void waitForConnected(int timeout = 5000);
    void waitForAllDirsAndDevsReady(bool initialConfig = false);

    SyncthingConnection m_connection;
    QString m_ownDevId;
    QString m_ownDevName;
};

CPPUNIT_TEST_SUITE_REGISTRATION(ConnectionTests);

ConnectionTests::ConnectionTests()
{
}

//
// test setup
//

/*!
 * \brief Starts Syncthing and prepares connecting.
 */
void ConnectionTests::setUp()
{
    setInterleavedOutputEnabledFromEnv();
    SyncthingTestInstance::start();

    cerr << "\n - Preparing connection ..." << endl;
    m_connection.setSyncthingUrl(QStringLiteral("http://127.0.0.1:") + syncthingPort());

    // keep track of status changes
    QObject::connect(&m_connection, &SyncthingConnection::statusChanged, &m_connection,
        [this] { cerr << " - Connection status changed to: " << m_connection.statusText().toLocal8Bit().data() << endl; });

    // log configuration change
    if (qEnvironmentVariableIsSet("SYNCTHING_TEST_DUMP_CONFIG_UPDATES")) {
        QObject::connect(&m_connection, &SyncthingConnection::newConfig, &m_connection, [](const QJsonObject &config) {
            const auto json = QJsonDocument(config).toJson(QJsonDocument::Indented);
            cerr << " - New config: ";
            cerr.write(json.data(), json.size());
            cerr << '\n';
        });
    }

    // log errors
    QObject::connect(&m_connection, &SyncthingConnection::error, &m_connection,
        [](const QString &message) { cerr << " - Connection error: " << message.toLocal8Bit().data() << endl; });

    // reduce traffic poll interval to 10 seconds
    m_connection.setTrafficPollInterval(10000);
}

/*!
 * \brief Terminates Syncthing and prints stdout/stderr from Syncthing.
 */
void ConnectionTests::tearDown()
{
    SyncthingTestInstance::stop();
}

//
// test helper
//

/*!
 * \brief Variant of waitForSignal() where the sender is the connection and the action is a method of the connection.
 */
template <typename Action, typename... SignalInfo>
void ConnectionTests::waitForConnection(Action action, int timeout, const SignalInfo &...signalInfo)
{
    waitForSignals(bind(action, &m_connection), timeout, signalInfo...);
}

/*!
 * \brief Variant of waitForSignalOrFail() where the sender is the connection and the action is a method of the connection.
 */
template <typename Action, typename FailureSignalInfo, typename... SignalInfo>
void ConnectionTests::waitForConnectionOrFail(Action action, int timeout, const FailureSignalInfo &failureSignalInfo, const SignalInfo &...signalInfo)
{
    waitForSignalsOrFail(bind(action, &m_connection), timeout, failureSignalInfo, signalInfo...);
}

/*!
 * \brief Returns a SignalInfo for the test's connection.
 */
template <typename Signal, typename Handler>
SignalInfo<Signal, Handler> ConnectionTests::connectionSignal(Signal signal, const Handler &handler, bool *correctSignalEmitted)
{
    return SignalInfo<Signal, Handler>(&m_connection, signal, handler, correctSignalEmitted);
}

/*!
 * \brief Returns the default connect() signal (no args) for the test's connection.
 */
void (SyncthingConnection::*ConnectionTests::defaultConnect())(void)
{
    return static_cast<void (SyncthingConnection::*)(void)>(&SyncthingConnection::connect);
}

/*!
 * \brief Returns the default reconnect() signal (no args) for the test's connection.
 */
void (SyncthingConnection::*ConnectionTests::defaultReconnect())(void)
{
    return static_cast<void (SyncthingConnection::*)(void)>(&SyncthingConnection::reconnect);
}

/*!
 * \brief Returns the default disconnect() signal (no args) for the test's connection.
 */
void (SyncthingConnection::*ConnectionTests::defaultDisconnect())(void)
{
    return static_cast<void (SyncthingConnection::*)(void)>(&SyncthingConnection::disconnect);
}

/*!
 * \brief Returns a SignalInfo to wait until connected (again).
 */
WaitForConnected ConnectionTests::connectedSignal() const
{
    return WaitForConnected(m_connection);
}

/*!
 * \brief Waits until connected (again).
 * \remarks
 * - Does nothing if already connected.
 * - Used to keep tests passing even though Syncthing dies and restarts during the testrun.
 */
void ConnectionTests::waitForConnected(int timeout)
{
    waitForConnection(defaultConnect(), timeout, connectedSignal());
}

/*!
 * \brief Ensures the connection is established and waits till all dirs and devs are ready.
 * \param initialConfig Whether to check for initial config (at least one dir and one dev is paused).
 */
void ConnectionTests::waitForAllDirsAndDevsReady(const bool initialConfig)
{
    bool allDirsReady, allDevsReady;
    bool isConnected = m_connection.isConnected();
    const auto checkAllDirsReady([this, &allDirsReady, &initialConfig] {
        bool oneDirPaused = false;
        for (const SyncthingDir &dir : m_connection.dirInfo()) {
            if (dir.status == SyncthingDirStatus::Unknown && !dir.paused) {
                allDirsReady = false;
                return;
            }
            oneDirPaused = oneDirPaused || dir.paused;
        }
        allDirsReady = !initialConfig || oneDirPaused;
    });
    const auto checkAllDevsReady([this, &allDevsReady, &initialConfig] {
        bool oneDevPaused = false;
        for (const SyncthingDev &dev : m_connection.devInfo()) {
            if (dev.status == SyncthingDevStatus::Unknown && !dev.paused) {
                allDevsReady = false;
                return;
            }
            oneDevPaused = oneDevPaused || dev.paused;
        }
        allDevsReady = !initialConfig || oneDevPaused;
    });
    auto checkStatus([this, &isConnected](SyncthingStatus) { isConnected = m_connection.isConnected(); });
    checkAllDirsReady();
    checkAllDevsReady();
    if (allDirsReady && allDevsReady) {
        return;
    }

    waitForSignalsOrFail(bind(defaultConnect(), &m_connection), 10000, connectionSignal(&SyncthingConnection::error),
        connectionSignal(&SyncthingConnection::statusChanged, checkStatus, &isConnected),
        connectionSignal(&SyncthingConnection::dirStatusChanged, checkAllDirsReady, &allDirsReady),
        connectionSignal(&SyncthingConnection::newDirs, checkAllDirsReady, &allDirsReady),
        connectionSignal(&SyncthingConnection::devStatusChanged, checkAllDevsReady, &allDevsReady),
        connectionSignal(&SyncthingConnection::newDevices, checkAllDevsReady, &allDevsReady));
}

/*!
 * \brief Helps handling newDevices() signal when waiting for device change.
 */
template <typename Handler> TemporaryConnection ConnectionTests::handleNewDevices(const Handler &handler)
{
    return QObject::connect(&m_connection, &SyncthingConnection::newDevices, [&handler](const std::vector<SyncthingDev> &devs) {
        for (const SyncthingDev &dev : devs) {
            handler(dev, 0);
        }
    });
}

/*!
 * \brief Helps handling newDirs() signal when waiting for directory change.
 */
template <typename Handler> TemporaryConnection ConnectionTests::handleNewDirs(const Handler &handler)
{
    return QObject::connect(&m_connection, &SyncthingConnection::newDirs, [&handler](const std::vector<SyncthingDir> &dirs) {
        for (const SyncthingDir &dir : dirs) {
            handler(dir, 0);
        }
    });
}

//
// actual test
//

/*!
 * \brief Tests basic behaviour of the SyncthingConnection class.
 * \remarks Some tests are currently disabled for release mode because they sometimes fail.
 * \todo Find out why some tests are flaky.
 */
void ConnectionTests::testConnection()
{
    testErrorCases();
    testInitialConnection();
    checkDevices();
    checkDirectories();
    testSendingError();
    testReconnecting();
    testResumingAllDevices();
    testResumingDirectory();
    testPausingDirectory();
    testRequestingLog();
    testRequestingQrCode();
    testDisconnecting();
    testConnectingWithSettings();
    testRequestingRescan();
    testDealingWithArbitraryConfig();
    testSuspendResume();
}

void ConnectionTests::testErrorCases()
{
    cerr << "\n - Error handling in case of insufficient configuration ..." << endl;
    waitForConnection(defaultConnect(), 1000, connectionSignal(&SyncthingConnection::error, [](const QString &errorMessage) {
        CPPUNIT_ASSERT_EQUAL(QStringLiteral("Connection configuration is insufficient."), errorMessage);
    }));

    // setup/define test for error handling
    m_connection.setApiKey(QByteArrayLiteral("wrong API key"));
    bool syncthingAvailable = false;
    constexpr auto syncthingCheckInterval = TimeSpan::fromMilliseconds(200.0);
    const auto maxSyncthingStartupTime = TimeSpan::fromSeconds(15.0 * max(timeoutFactor, 5.0));
    auto remainingTimeForSyncthingToComeUp = maxSyncthingStartupTime;
    bool authErrorStatus = false, authErrorConfig = false;
    bool apiKeyErrorStatus = false, apiKeyErrorConfig = false;
    bool allErrorsEmitted = false;
    const auto errorHandler = [&](const QString &errorMessage) {
        // check whether Syncthing is available
        if ((errorMessage == QStringLiteral("Unable to request Syncthing status: Connection refused"))
            || (errorMessage == QStringLiteral("Unable to request Syncthing config: Connection refused"))) {
            // consider test failed if we receive "Connection refused" when another error has already occurred
            if (syncthingAvailable) {
                CPPUNIT_FAIL("Syncthing became unavailable after another error had already occurred");
            }

            // consider test failed if Syncthing takes too long to come up (or we fail to connect)
            if ((remainingTimeForSyncthingToComeUp -= syncthingCheckInterval).isNegative()) {
                CPPUNIT_FAIL(
                    argsToString("unable to connect to Syncthing within ", maxSyncthingStartupTime.toString(TimeSpanOutputFormat::WithMeasures)));
            }

            // give Syncthing a bit more time and check again
            wait(static_cast<int>(syncthingCheckInterval.totalMilliseconds()));
            return;
        }
        syncthingAvailable = true;

        // check API key error
        if (errorMessage.contains(QStringLiteral("nobody:supersecret@")) && errorMessage.endsWith(QStringLiteral("server replied: Forbidden"))) {
            if (errorMessage.startsWith(QStringLiteral("Unable to request Syncthing status: "))) {
                apiKeyErrorStatus = true;
                return;
            }
            if (errorMessage.startsWith(QStringLiteral("Unable to request Syncthing config: "))) {
                apiKeyErrorConfig = true;
                return;
            }
        }

        // check for HTTP authentication error
        if (errorMessage.endsWith(QStringLiteral("Host requires authentication"))
            || errorMessage.endsWith(QStringLiteral("server replied: Forbidden"))) {
            if (errorMessage.startsWith(QStringLiteral("Unable to request Syncthing status: "))) {
                authErrorStatus = true;
                return;
            }
            if (errorMessage.startsWith(QStringLiteral("Unable to request Syncthing config: "))) {
                authErrorConfig = true;
                return;
            }
        }

        // fail on unexpected error messages
        allErrorsEmitted = authErrorStatus && authErrorConfig && apiKeyErrorStatus && apiKeyErrorConfig;
        CPPUNIT_FAIL(argsToString("wrong error message: ", errorMessage.toLocal8Bit().data()));
    };

    cerr << "\n - Error handling in case of inavailability ..." << endl;
    while (!syncthingAvailable) {
        waitForConnection(defaultConnect(), 5000, connectionSignal(&SyncthingConnection::error, errorHandler));
    }

    cerr << "\n - Error handling in case of wrong credentials ..." << endl;
    waitForConnection(defaultConnect(), 5000, connectionSignal(&SyncthingConnection::error, errorHandler));
    while (!authErrorStatus && !authErrorConfig) {
        waitForSignals(noop, 5000, connectionSignal(&SyncthingConnection::error, errorHandler));
    }

    cerr << "\n - Error handling in case of wrong API key  ..." << endl;
    m_connection.setCredentials(QStringLiteral("nobody"), QStringLiteral("supersecret"));
    waitForConnection(defaultConnect(), 5000, connectionSignal(&SyncthingConnection::error, errorHandler));
    while (!apiKeyErrorStatus && !apiKeyErrorConfig) {
        waitForSignals(noop, 5000, connectionSignal(&SyncthingConnection::error, errorHandler));
    }
}

void ConnectionTests::testInitialConnection()
{
    cerr << "\n - Connecting initially ..." << endl;
    m_connection.setApiKey(apiKey().toUtf8());
    waitForAllDirsAndDevsReady(true);
    CPPUNIT_ASSERT_EQUAL_MESSAGE(
        "connected and paused (one dev is initially paused)", QStringLiteral("connected, paused"), m_connection.statusText());
    CPPUNIT_ASSERT_MESSAGE("no dirs out-of-sync", !m_connection.hasOutOfSyncDirs());
}

void ConnectionTests::testSendingError()
{
    auto newNotificationEmitted = false;
    const auto sentTime(DateTime::now());
    const auto sentMessage(QStringLiteral("test notification"));
    const auto newNotificationHandler = [&](DateTime receivedTime, const QString &receivedMessage) {
        newNotificationEmitted |= receivedTime == sentTime && receivedMessage == sentMessage;
    };
    waitForSignals([this, sentTime, &sentMessage] { emit m_connection.newNotification(sentTime, sentMessage); }, 500,
        connectionSignal(&SyncthingConnection::newNotification, newNotificationHandler, &newNotificationEmitted));
}

void ConnectionTests::checkDevices()
{
    const auto &devInfo = m_connection.devInfo();
    CPPUNIT_ASSERT_EQUAL_MESSAGE("3 devs present", 3_st, devInfo.size());
    for (const SyncthingDev &dev : devInfo) {
        if (dev.id != QStringLiteral("MMGUI6U-WUEZQCP-XZZ6VYB-LCT4TVC-ER2HAVX-QYT6X7D-S6ZSG2B-323KLQ7")
            && dev.id != QStringLiteral("6EIS2PN-J2IHWGS-AXS3YUL-HC5FT3K-77ZXTLL-AKQLJ4C-7SWVPUS-AZW4RQ4")) {
            CPPUNIT_ASSERT_EQUAL_MESSAGE("this device", QStringLiteral("This Device"), dev.statusString());
            m_ownDevId = dev.id;
            m_ownDevName = dev.name;
        }
    }
    const SyncthingDev *dev1 = nullptr, *dev2 = nullptr;
    int index = 0, dev1Index, dev2Index;
    for (const SyncthingDev &dev : devInfo) {
        CPPUNIT_ASSERT(!dev.isConnected());
        if (dev.id == QStringLiteral("MMGUI6U-WUEZQCP-XZZ6VYB-LCT4TVC-ER2HAVX-QYT6X7D-S6ZSG2B-323KLQ7")) {
            CPPUNIT_ASSERT_EQUAL_MESSAGE("paused device", QStringLiteral("Paused"), dev.statusString());
            CPPUNIT_ASSERT_EQUAL_MESSAGE("name", QStringLiteral("Test dev 2"), dev.name);
            CPPUNIT_ASSERT_MESSAGE("no introducer", !dev.introducer);
            CPPUNIT_ASSERT_EQUAL(static_cast<decltype(dev.addresses.size())>(2), dev.addresses.size());
            CPPUNIT_ASSERT_EQUAL(QStringLiteral("tcp://192.168.2.2:22001"), dev.addresses.front());
            CPPUNIT_ASSERT_EQUAL(QStringLiteral("tcp://192.168.2.2:22002"), dev.addresses.back());
            dev2 = &dev;
            dev2Index = index;
        } else if (dev.id == QStringLiteral("6EIS2PN-J2IHWGS-AXS3YUL-HC5FT3K-77ZXTLL-AKQLJ4C-7SWVPUS-AZW4RQ4")) {
            CPPUNIT_ASSERT_EQUAL_MESSAGE("disconnected device", QStringLiteral("Disconnected"), dev.statusString());
            CPPUNIT_ASSERT_EQUAL_MESSAGE("name", QStringLiteral("Test dev 1"), dev.name);
            CPPUNIT_ASSERT_MESSAGE("introducer", dev.introducer);
            CPPUNIT_ASSERT_EQUAL(static_cast<decltype(dev.addresses.size())>(1), dev.addresses.size());
            CPPUNIT_ASSERT_EQUAL(QStringLiteral("dynamic"), dev.addresses.front());
            dev1 = &dev;
            dev1Index = index;
        }
        ++index;
    }

    CPPUNIT_ASSERT(dev1 && dev2);
    CPPUNIT_ASSERT(dev1 == m_connection.findDevInfo(QStringLiteral("6EIS2PN-J2IHWGS-AXS3YUL-HC5FT3K-77ZXTLL-AKQLJ4C-7SWVPUS-AZW4RQ4"), index));
    CPPUNIT_ASSERT_EQUAL(dev1Index, index);
    CPPUNIT_ASSERT(dev2 == m_connection.findDevInfoByName(QStringLiteral("Test dev 2"), index));
    CPPUNIT_ASSERT_EQUAL(dev2Index, index);
    CPPUNIT_ASSERT(!m_connection.findDevInfoByName(QStringLiteral("does not exist"), index));
}

void ConnectionTests::checkDirectories() const
{
    const auto &dirInfo = m_connection.dirInfo();
    CPPUNIT_ASSERT_EQUAL_MESSAGE("2 dirs present", 2_st, dirInfo.size());
    const SyncthingDir &dir1 = dirInfo.front();
    const auto tempDir = QtUtilities::fromNativeFileName(tempDirectory());
    CPPUNIT_ASSERT_EQUAL(QStringLiteral("test1"), dir1.id);
    CPPUNIT_ASSERT_EQUAL(QString(), dir1.label);
    CPPUNIT_ASSERT_EQUAL(QStringLiteral("test1"), dir1.displayName());
    CPPUNIT_ASSERT_EQUAL(tempDir + QStringLiteral("some/path/1/"), dir1.path);
    CPPUNIT_ASSERT_EQUAL(QStringLiteral("Up to Date"), dir1.statusString());
    CPPUNIT_ASSERT_EQUAL(SyncthingDirType::SendReceive, dir1.dirType);
    CPPUNIT_ASSERT(!dir1.paused);
#if QT_VERSION < QT_VERSION_CHECK(5, 14, 0)
    const auto devIds = dir1.deviceIds.toSet();
    const auto devNames = dir1.deviceNames.toSet();
#else
    const auto devIds = QSet(dir1.deviceIds.begin(), dir1.deviceIds.end());
    const auto devNames = QSet(dir1.deviceNames.begin(), dir1.deviceNames.end());
#endif
    CPPUNIT_ASSERT_EQUAL(QSet<QString>({ QStringLiteral("MMGUI6U-WUEZQCP-XZZ6VYB-LCT4TVC-ER2HAVX-QYT6X7D-S6ZSG2B-323KLQ7"),
                             QStringLiteral("6EIS2PN-J2IHWGS-AXS3YUL-HC5FT3K-77ZXTLL-AKQLJ4C-7SWVPUS-AZW4RQ4") }),
        devIds);
    CPPUNIT_ASSERT_EQUAL(QSet<QString>({ QStringLiteral("Test dev 2"), QStringLiteral("Test dev 1") }), devNames);
    const SyncthingDir &dir2 = dirInfo.back();
    CPPUNIT_ASSERT_EQUAL(QStringLiteral("test2"), dir2.id);
    CPPUNIT_ASSERT_EQUAL(QStringLiteral("Test dir 2"), dir2.label);
    CPPUNIT_ASSERT_EQUAL(QStringLiteral("Test dir 2"), dir2.displayName());
    CPPUNIT_ASSERT_EQUAL(tempDir + QStringLiteral("some/path/2/"), dir2.path);
    CPPUNIT_ASSERT_EQUAL(tempDir + QStringLiteral("some/path/2"), dir2.pathWithoutTrailingSlash().toString());
    CPPUNIT_ASSERT_EQUAL(QStringLiteral("Paused"), dir2.statusString());
    CPPUNIT_ASSERT_EQUAL(SyncthingDirType::SendReceive, dir2.dirType);
    CPPUNIT_ASSERT(dir2.paused);
#if QT_VERSION < QT_VERSION_CHECK(5, 14, 0)
    const auto devIds2 = dir2.deviceIds.toSet();
    const auto devNames2 = dir2.deviceNames.toSet();
#else
    const auto devIds2 = QSet(dir2.deviceIds.begin(), dir2.deviceIds.end());
    const auto devNames2 = QSet(dir2.deviceNames.begin(), dir2.deviceNames.end());
#endif
    CPPUNIT_ASSERT_EQUAL(QSet<QString>({ QStringLiteral("MMGUI6U-WUEZQCP-XZZ6VYB-LCT4TVC-ER2HAVX-QYT6X7D-S6ZSG2B-323KLQ7") }), devIds2);
    CPPUNIT_ASSERT_EQUAL(QSet<QString>({ QStringLiteral("Test dev 2") }), devNames2);
}

void ConnectionTests::testReconnecting()
{
    cerr << "\n - Reconnecting ...\n";
    waitForConnection(defaultReconnect(), 1000, connectionSignal(&SyncthingConnection::statusChanged));
    cerr << "\n - Waiting for dirs/devs after reconnect ...\n";
    waitForAllDirsAndDevsReady(true);
    CPPUNIT_ASSERT_EQUAL_MESSAGE("connected again", QStringLiteral("connected, paused"), m_connection.statusText());
}

void ConnectionTests::testResumingAllDevices()
{
    cerr << "\n - Resuming all devices ..." << endl;
    bool devResumed = false;
    const auto devResumedHandler = [&devResumed](const SyncthingDev &dev, int) {
        if (dev.name == QStringLiteral("Test dev 2") && !dev.paused) {
            devResumed = true;
        }
    };
    const auto newDevsHandler = [&devResumedHandler](const std::vector<SyncthingDev> &devs) {
        for (const auto &dev : devs) {
            devResumedHandler(dev, 0);
        }
    };
    const auto devResumedTriggeredHandler = [this](const QStringList &devIds) { CPPUNIT_ASSERT_EQUAL(m_connection.deviceIds(), devIds); };
    const auto newDevsConnection = handleNewDevices(devResumedHandler);
    waitForConnected();
    waitForConnection(&SyncthingConnection::resumeAllDevs, 7500, connectedSignal(),
        connectionSignal(&SyncthingConnection::devStatusChanged, devResumedHandler, &devResumed),
        connectionSignal(&SyncthingConnection::newDevices, newDevsHandler, &devResumed),
        connectionSignal(&SyncthingConnection::deviceResumeTriggered, devResumedTriggeredHandler));
    CPPUNIT_ASSERT(devResumed);
    for (const QJsonValueRef devValue : m_connection.m_rawConfig.value(QStringLiteral("devices")).toArray()) {
        const QJsonObject &devObj(devValue.toObject());
        CPPUNIT_ASSERT(!devObj.isEmpty());
        CPPUNIT_ASSERT_MESSAGE("raw config updated accordingly", !devObj.value(QStringLiteral("paused")).toBool(true));
    }
    CPPUNIT_ASSERT_MESSAGE("resuming all devs should not cause another request again", !m_connection.resumeAllDevs());
}

void ConnectionTests::testResumingDirectory()
{
    cerr << "\n - Resuming all dirs ..." << endl;
    bool dirResumed = false;
    const auto dirResumedHandler = [&dirResumed](const SyncthingDir &dir, int) {
        if (dir.id == QStringLiteral("test2") && !dir.paused) {
            dirResumed = true;
        }
    };
    const auto newDirsHandler = [&dirResumedHandler](const std::vector<SyncthingDir> &dirs) {
        for (const auto &dir : dirs) {
            dirResumedHandler(dir, 0);
        }
    };
    const auto dirResumedTriggeredHandler = [this](const QStringList &devIds) { CPPUNIT_ASSERT_EQUAL(m_connection.directoryIds(), devIds); };
    const auto newDirsConnection = handleNewDirs(dirResumedHandler);
    waitForConnected();
    waitForConnection(&SyncthingConnection::resumeAllDirs, 7500, connectedSignal(),
        connectionSignal(&SyncthingConnection::dirStatusChanged, dirResumedHandler, &dirResumed),
        connectionSignal(&SyncthingConnection::newDirs, newDirsHandler, &dirResumed),
        connectionSignal(&SyncthingConnection::directoryResumeTriggered, dirResumedTriggeredHandler));
    CPPUNIT_ASSERT(dirResumed);
    CPPUNIT_ASSERT_EQUAL_MESSAGE("still 2 dirs present", 2_st, m_connection.dirInfo().size());
    CPPUNIT_ASSERT_MESSAGE("resuming all dirs should not cause another request again", !m_connection.resumeAllDirs());
}

void ConnectionTests::testPausingDirectory()
{
    cerr << "\n - Pause dir 1 ..." << endl;
    bool dirPaused = false;
    const auto dirPausedHandler = [&dirPaused](const SyncthingDir &dir, int) {
        if (dir.id == QStringLiteral("test1") && dir.paused) {
            dirPaused = true;
        }
    };
    const QStringList ids({ QStringLiteral("test1") });
    const auto dirPausedTriggeredHandler = [&ids](const QStringList &devIds) { CPPUNIT_ASSERT_EQUAL(ids, devIds); };
    const auto newDirsConnection = handleNewDirs(dirPausedHandler);
    waitForConnected();
    waitForSignals(bind(&SyncthingConnection::pauseDirectories, &m_connection, ids), 7500, connectedSignal(),
        connectionSignal(&SyncthingConnection::dirStatusChanged, dirPausedHandler, &dirPaused),
        connectionSignal(&SyncthingConnection::directoryPauseTriggered, dirPausedTriggeredHandler));
    CPPUNIT_ASSERT(dirPaused);
    CPPUNIT_ASSERT_EQUAL_MESSAGE("still 2 dirs present", 2_st, m_connection.dirInfo().size());
    CPPUNIT_ASSERT_MESSAGE("pausing should not cause another request again", !m_connection.pauseDirectories(ids));
}

void ConnectionTests::testRequestingLog()
{
    cerr << "\n - Requesting log ..." << endl;
    waitForConnected();

    const auto handleLogAvailable = [](const vector<SyncthingLogEntry> &logEntries) {
        CPPUNIT_ASSERT(!logEntries.empty());
        CPPUNIT_ASSERT(!logEntries[0].when.isEmpty());
        CPPUNIT_ASSERT(!logEntries[0].message.isEmpty());
    };
    waitForConnectionOrFail(&SyncthingConnection::requestLog, 5000, connectionSignal(&SyncthingConnection::error),
        connectionSignal(&SyncthingConnection::logAvailable, handleLogAvailable));
}

void ConnectionTests::testRequestingQrCode()
{
    cerr << "\n - Requesting QR-Code for own device ID ..." << endl;
    waitForConnected();

    const auto handleQrCodeAvailable = [](const QString &qrText, const QByteArray &data) {
        CPPUNIT_ASSERT_EQUAL(QStringLiteral("some text"), qrText);
        CPPUNIT_ASSERT(!data.isEmpty());
    };
    waitForSignalsOrFail(bind(&SyncthingConnection::requestQrCode, &m_connection, QStringLiteral("some text")), 5000,
        connectionSignal(&SyncthingConnection::error), connectionSignal(&SyncthingConnection::qrCodeAvailable, handleQrCodeAvailable));
}

void ConnectionTests::testDisconnecting()
{
    cerr << "\n - Disconnecting while there are outstanding requests ..." << endl;
    waitForConnected();
    m_connection.requestVersion();
    m_connection.requestDirStatistics();
    waitForSignals(
        [this] {
            m_connection.requestDeviceStatistics();
            m_connection.requestCompletion("MMGUI6U-WUEZQCP-XZZ6VYB-LCT4TVC-ER2HAVX-QYT6X7D-S6ZSG2B-323KLQ7", "test1");
            QTimer::singleShot(0, &m_connection, defaultDisconnect());
        },
        5000, connectionSignal(&SyncthingConnection::statusChanged));
    CPPUNIT_ASSERT_EQUAL_MESSAGE("disconnected", QStringLiteral("disconnected"), m_connection.statusText());
}

void ConnectionTests::testConnectingWithSettings()
{
    cerr << "\n - Connecting with settings ..." << endl;
    SyncthingConnectionSettings settings;
    settings.syncthingUrl = m_connection.syncthingUrl();
    settings.apiKey = m_connection.apiKey();
    settings.userName = m_connection.user();
    settings.password = m_connection.password();

    bool isConnected;
    const auto checkStatus([this, &isConnected](SyncthingStatus) { isConnected = m_connection.isConnected(); });
    waitForSignals(
        bind(static_cast<void (SyncthingConnection::*)(SyncthingConnectionSettings &)>(&SyncthingConnection::connect), &m_connection, ref(settings)),
        5000, connectionSignal(&SyncthingConnection::statusChanged, checkStatus, &isConnected));
}

void ConnectionTests::testRequestingRescan()
{
    cerr << "\n - Requesting rescan ..." << endl;
    waitForConnected();

    bool rescanTriggered = false;
    const auto rescanTriggeredHandler = [&rescanTriggered](const QString &dir) {
        CPPUNIT_ASSERT_EQUAL(QStringLiteral("test2"), dir);
        rescanTriggered = true;
    };
    waitForSignalsOrFail(bind(&SyncthingConnection::rescanAllDirs, &m_connection), 5000, connectionSignal(&SyncthingConnection::error),
        connectionSignal(&SyncthingConnection::rescanTriggered, rescanTriggeredHandler, &rescanTriggered));

    bool errorOccured = false;
    const auto errorHandler = [&errorOccured](const QString &message) {
        errorOccured |= message.startsWith(QStringLiteral("Unable to request rescan: Error transferring"))
            && message.contains(QStringLiteral("/rest/db/scan?folder=non-existing-dir&sub=sub%2Fpath - server replied: "));
    };
    waitForSignals(bind(&SyncthingConnection::rescan, &m_connection, QStringLiteral("non-existing-dir"), QStringLiteral("sub/path")), 5000,
        connectionSignal(&SyncthingConnection::error, errorHandler, &errorOccured));
}

void ConnectionTests::testDealingWithArbitraryConfig()
{
    cerr << "\n - Changing arbitrary config ...\n";
    waitForConnected();

    // read some value, eg. options.relayReconnectIntervalM
    auto rawConfig = m_connection.rawConfig();
    auto optionsIterator = rawConfig.find(QLatin1String("options"));
    CPPUNIT_ASSERT(optionsIterator != rawConfig.end());
    auto optionsRef = optionsIterator.value();
    CPPUNIT_ASSERT_EQUAL(QJsonValue::Object, optionsRef.type());
    auto options = optionsRef.toObject();
    CPPUNIT_ASSERT_EQUAL(10, options.value(QLatin1String("relayReconnectIntervalM")).toInt());

    // change a value
    options.insert(QLatin1String("relayReconnectIntervalM"), 75);
    optionsRef = options;

    // expect the change via newConfig() signal
    auto hasNewConfig = false;
    const auto handleNewConfig([&hasNewConfig](const QJsonObject &newConfig) {
        const auto newIntervall = newConfig.value(QLatin1String("options")).toObject().value(QLatin1String("relayReconnectIntervalM")).toInt();
        if (newIntervall == 75) {
            hasNewConfig = true;
        }
    });

    // post new config
    waitForConnected();
    waitForSignalsOrFail([this, &rawConfig] { m_connection.postConfigFromJsonObject(rawConfig); }, 10000,
        connectionSignal(&SyncthingConnection::error), connectionSignal(&SyncthingConnection::newConfigTriggered),
        connectionSignal(&SyncthingConnection::newConfig, handleNewConfig, &hasNewConfig));
}

void ConnectionTests::testSuspendResume()
{
    cerr << "\n - Suspending Syncthing ...\n";
    waitForConnected();

    // check config before suspending Syncthing
    const auto myId = m_connection.myId();
    CPPUNIT_ASSERT(!myId.isEmpty());
    const auto rawConfig = m_connection.rawConfig();
    const auto options = rawConfig.value(QLatin1String("options")).toObject();
    CPPUNIT_ASSERT(options.value(QLatin1String("globalAnnounceEnabled")).toBool());
    CPPUNIT_ASSERT(options.value(QLatin1String("localAnnounceEnabled")).toBool());
    CPPUNIT_ASSERT(options.value(QLatin1String("relaysEnabled")).toBool());
    const auto devices = rawConfig.value(QLatin1String("devices")).toArray();
    for (const auto &device : devices) {
        CPPUNIT_ASSERT(!device.toObject().value(QLatin1String("paused")).toBool());
    }

    // expect the change via newConfig() signal
    auto hasNewConfig = false, hasSuspensionOrResumeTriggered = false, triggeringSuspension = true;
    const auto handleNewConfig([&hasNewConfig, &triggeringSuspension](const QJsonObject &newConfig) {
        // check some arbitrary option to determine whether this newConfig() signal was relevant
        if (newConfig.value(QLatin1String("options")).toObject().value(QLatin1String("globalAnnounceEnabled")).toBool() != triggeringSuspension) {
            hasNewConfig = true;
        }
    });
    const auto handlesuspensionOrResumeTriggered = [&hasSuspensionOrResumeTriggered, &triggeringSuspension](bool suspend) {
        if (triggeringSuspension == suspend) {
            hasSuspensionOrResumeTriggered = true;
        }
    };

    // suspend Syncthing
    waitForSignalsOrFail([this] { CPPUNIT_ASSERT(m_connection.setForceSuspendEnabled(true)); }, 10000, connectionSignal(&SyncthingConnection::error),
        connectionSignal(&SyncthingConnection::newConfig, handleNewConfig, &hasNewConfig),
        connectionSignal(&SyncthingConnection::suspensionOrResumeTriggered, handlesuspensionOrResumeTriggered, &hasSuspensionOrResumeTriggered));

    // check whether options/devices have been altered as expected
    const auto &alteredRawConfig = m_connection.rawConfig();
    const auto alteredOptions = rawConfig.value(QLatin1String("options")).toObject();
    CPPUNIT_ASSERT(alteredOptions.value(QLatin1String("globalAnnounceEnabled")).toBool());
    CPPUNIT_ASSERT(alteredOptions.value(QLatin1String("localAnnounceEnabled")).toBool());
    CPPUNIT_ASSERT(alteredOptions.value(QLatin1String("relaysEnabled")).toBool());
    const auto alteredDevices = alteredRawConfig.value(QLatin1String("devices")).toArray();
    for (const auto &deviceVal : alteredDevices) {
        const auto device = deviceVal.toObject();
        const auto deviceId = device.value(QLatin1String("deviceID")).toString();
        CPPUNIT_ASSERT(!deviceId.isEmpty());
        if (deviceId != myId) {
            CPPUNIT_ASSERT(device.value(QLatin1String("paused")).toBool());
        }
    }

    // re-connect - that should not prevent resuming Syncthing again
    testReconnecting();

    // resume Syncthing
    cerr << "\n - Resuming Syncthing ...\n";
    hasNewConfig = false;
    triggeringSuspension = false;
    waitForSignalsOrFail([this] { CPPUNIT_ASSERT(m_connection.setForceSuspendEnabled(false)); }, 10000, connectionSignal(&SyncthingConnection::error),
        connectionSignal(&SyncthingConnection::newConfig, handleNewConfig, &hasNewConfig),
        connectionSignal(&SyncthingConnection::suspensionOrResumeTriggered, handlesuspensionOrResumeTriggered, &hasSuspensionOrResumeTriggered));

    // check whether config is back to normal after resuming Syncthing
    const auto configBackToNormal = rawConfig == m_connection.rawConfig();
    if (!configBackToNormal) {
        cerr << " - Config has NOT been restored: " << QJsonDocument(m_connection.rawConfig()).toJson().data() << '\n';
    }
    CPPUNIT_ASSERT(configBackToNormal);

    auto suspendedItemsDir = QDir(dataDir().path());
    CPPUNIT_ASSERT_MESSAGE("persistent storage created", suspendedItemsDir.cd(QStringLiteral("suspended-items")));
    CPPUNIT_ASSERT_EQUAL_MESSAGE("persistent storage cleaned up", QStringList(), suspendedItemsDir.entryList(QDir::NoDotAndDotDot));
}
