// SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net>
// SPDX-License-Identifier: LGPL-2.1-or-later

#include <Quotient/connection.h>
#include <Quotient/room.h>
#include <Quotient/settings.h>
#include <Quotient/thread.h>
#include <Quotient/user.h>
#include <Quotient/uriresolver.h>
#include <Quotient/networkaccessmanager.h>
#include <Quotient/qt_connection_util.h>

#include <Quotient/csapi/joining.h>
#include <Quotient/csapi/leaving.h>
#include <Quotient/csapi/room_send.h>

#include <Quotient/events/reactionevent.h>
#include <Quotient/events/redactionevent.h>
#include <Quotient/events/simplestateevents.h>
#include <Quotient/events/roommemberevent.h>

#include <QtTest/QSignalSpy>
#include <QtCore/QCoreApplication>
#include <QtCore/QFileInfo>
#include <QtCore/QStringBuilder>
#include <QtCore/QTemporaryFile>
#include <QtCore/QTimer>
#include <QtConcurrent/QtConcurrent>
#include <QtNetwork/QNetworkReply>

#include <iostream>

using namespace Quotient;
using std::clog, std::endl;

class TestSuite;

class TestManager : public QCoreApplication {
public:
    TestManager(int& argc, char** argv);

private:
    void setupAndRun();
    void onNewRoom(Room* r);
    void doTests();
    void conclude();
    void finalize(const QString& lastWords = {});

private:
    Connection* c = nullptr;
    QString origin;
    QString targetRoomName;
    TestSuite* testSuite = nullptr;
    QByteArrayList running {}, succeeded {}, failed {};
};

using TestToken = decltype(std::declval<QMetaMethod>().name());
Q_DECLARE_METATYPE(TestToken)

// For now, the token itself is the test name but that may change.
const char* testName(const TestToken& token) { return token.constData(); }

/// Test function declaration
/*!
 * \return true, if the test finished (successfully or unsuccessfully);
 *         false, if the test went async and will complete later
 */
#define TEST_DECL(Name) bool Name(const TestToken& thisTest);

/// The holder for the actual tests
/*!
 * This class takes inspiration from Qt Test in terms of tests invocation;
 * TestManager instantiates it and runs all public slots (cf. private slots in
 * Qt Test) one after another. An important diversion from Qt Test is that
 * the tests are assumed to by asynchronous rather than synchronous; so it's
 * perfectly normal to have a few tests running at the same time. To avoid
 * context clashes a special parameter with the name thisTest is passed to
 * each test. Each test must conclude (synchronously or asynchronously) with
 * an invocation of FINISH_TEST() macro (or FAIL_TEST() macro that expands to
 * FINISH_TEST) that expects thisTest variable to be reachable. If FINISH_TEST()
 * is invoked twice with the same thisTest, the second call will cause assertion
 * failure; if FINISH_TEST() is not invoked at all, the test will be killed
 * by a watchdog after a timeout and marked in the final report as not finished.
 */
class TestSuite : public QObject {
    Q_OBJECT
public:
    TestSuite(Room* testRoom, QString source, TestManager* parent)
        : QObject(parent), targetRoom(testRoom), origin(std::move(source))
    {
        qRegisterMetaType<TestToken>();
        Q_ASSERT(testRoom && parent);
    }

signals:
    void finishedItem(QByteArray /*name*/, bool /*condition*/);

public slots:
    void doTest(const QByteArray& testName);

private slots:
    TEST_DECL(findRoomByAlias)
    TEST_DECL(loadMembers)
    TEST_DECL(sendMessage)
    TEST_DECL(sendReaction)
    TEST_DECL(sendFile)
    TEST_DECL(sendCustomEvent)
    TEST_DECL(setTopic)
    TEST_DECL(redactEvent)
    TEST_DECL(changeName)
    TEST_DECL(showLocalUsername)
    TEST_DECL(addAndRemoveTag)
    TEST_DECL(markDirectChat)
    TEST_DECL(visitResources)
    TEST_DECL(thread)
    // Add more tests above here

public:
    [[nodiscard]] Room* room() const { return targetRoom; }
    [[nodiscard]] Connection* connection() const
    {
        return targetRoom->connection();
    }

private:
    [[nodiscard]] bool checkFileSendingOutcome(const TestToken& thisTest,
                                               const QString& txnId,
                                               const QString& fileName);

    template <EventClass<RoomEvent> EventT>
    [[nodiscard]] bool validatePendingEvent(const QString& txnId);
    [[nodiscard]] bool checkDirectChat() const;
    void finishTest(const TestToken& token, bool condition,
                    std::source_location loc = std::source_location::current());

private:
    Room* targetRoom;
    QString origin;
};

#define TEST_IMPL(Name) bool TestSuite::Name(const TestToken& thisTest)

// Returning true (rather than a void) allows to reuse the convention with
// connectUntil() to break the QMetaObject::Connection upon finishing the test
// item.
#define FINISH_TEST(Condition) return (finishTest(thisTest, (Condition)), true)

#define FINISH_TEST_IF(Condition) \
    do {                          \
        if (Condition)            \
            FINISH_TEST(true);    \
    } while (false)

#define FAIL_TEST() FINISH_TEST(false)

#define FAIL_TEST_IF(Condition, ...)                           \
    do {                                                       \
        if (Condition) {                                       \
            __VA_OPT__(clog << QUO_CSTR(__VA_ARGS__) << endl;) \
            FAIL_TEST();                                       \
        }                                                      \
    } while (false)

void TestSuite::doTest(const QByteArray& testName)
{
    clog << "Starting: " << testName.constData() << endl;
    QMetaObject::invokeMethod(this, testName.constData(), Qt::DirectConnection,
                              Q_ARG(TestToken, testName));
}

template <EventClass<RoomEvent> EventT>
bool TestSuite::validatePendingEvent(const QString& txnId)
{
    auto it = targetRoom->findPendingEvent(txnId);
    return it != targetRoom->pendingEvents().end()
           && it->deliveryStatus() == EventStatus::Submitted
           && (*it)->transactionId() == txnId && is<EventT>(**it)
           && (*it)->matrixType() == EventT::TypeId;
}

void TestSuite::finishTest(const TestToken& token, bool condition, std::source_location loc)
{
    const auto& item = testName(token);
    if (condition) {
        clog << item << " successful" << endl;
        if (targetRoom)
            targetRoom->postText<MessageEventType::Notice>(
                origin % ": "_L1 % QString::fromUtf8(item) % " successful"_L1);
    } else {
        clog << item << " FAILED at " << loc.file_name() << ":" << loc.line() << endl;
        if (targetRoom)
            targetRoom->postText(origin % ": "_L1 % QString::fromUtf8(item) % " FAILED at "_L1
                                 % QString::fromUtf8(loc.file_name()) % ", line "_L1
                                 % QString::number(loc.line()));
    }

    emit finishedItem(item, condition);
}

TestManager::TestManager(int& argc, char** argv)
    : QCoreApplication(argc, argv), c(new Connection(this))
{
    Q_ASSERT(argc >= 5);
    // NOLINTBEGIN(cppcoreguidelines-pro-bounds-pointer-arithmetic)
    clog << "Connecting to Matrix as " << argv[1] << endl;
    c->loginWithPassword(QString::fromUtf8(argv[1]), QString::fromUtf8(argv[2]),
                         QString::fromUtf8(argv[3]));
    targetRoomName = QString::fromUtf8(argv[4]);
    clog << "Test room name: " << argv[4] << '\n';
    if (argc > 5) {
        origin = QString::fromUtf8(argv[5]);
        clog << "Origin for the test message: " << origin.toStdString() << '\n';
    }
    clog.flush();
    // NOLINTEND(cppcoreguidelines-pro-bounds-pointer-arithmetic)

    connect(c, &Connection::connected, this, [this] {
        if (QUO_ALARM(c->homeserver().isEmpty() || !c->homeserver().isValid())
            || QUO_ALARM(c->domain() != c->userId().section(u':', 1))) {
            clog << "Connection information doesn't look right, "
                 << "check the parameters passed to quotest" << endl;
            QCoreApplication::exit(-2);
            return;
        }
        clog << "Connected, server: " << c->homeserver().toDisplayString().toStdString() << '\n'
             << "Access token: " << c->accessToken().toStdString() << endl;

        // Test Connection::assumeIdentity() while we can replace connection objects
        auto* newC = new Connection(c->homeserver(), this);
        newC->assumeIdentity(c->userId(), c->deviceId(), QString::fromLatin1(c->accessToken()));
        // NB: this will need to change when we switch E2EE on in quotest because encryption
        //     data is initialised asynchronously
        if (QUO_ALARM(newC->homeserver() != c->homeserver())
            || QUO_ALARM(newC->userId() != c->userId()) || QUO_ALARM(!newC->isLoggedIn())) {
            clog << "Connection::assumeIdentity() is broken" << endl;
            QCoreApplication::exit(-2);
            return;
        }

        c->deleteLater();
        c = newC;
        setupAndRun();
    });
    connect(c, &Connection::resolveError, this,
        [](const QString& error) {
            clog << "Could not start testing: " << error.toStdString() << endl;
            QCoreApplication::exit(-2);
        },
        Qt::QueuedConnection);
    connect(c, &Connection::loginError, this,
        [this](const QString& message, const QString& details) {
            clog << "Failed to login to " << c->homeserver().toDisplayString().toStdString() << ": "
                 << message.toStdString() << "\nDetails:\n"
                 << details.toStdString() << endl;
            QCoreApplication::exit(-2);
        },
        Qt::QueuedConnection);

    // Big countdown watchdog
    QTimer::singleShot(180000, this, [this] {
        clog << "Time is up, stopping the session\n";
        if (testSuite)
            conclude();
        else
            finalize();
    });
}

void TestManager::setupAndRun()
{
    Q_ASSERT(!c->homeserver().isEmpty() && c->homeserver().isValid());
    Q_ASSERT(c->domain() == c->userId().section(u':', 1));
    clog << "Connected, server: " << c->homeserver().toDisplayString().toStdString() << '\n'
         << "Access token: " << c->accessToken().toStdString() << endl;

    connect(c, &Connection::loadedRoomState, this, &TestManager::onNewRoom);

    c->setLazyLoading(true);

    clog << "Joining " << targetRoomName.toStdString() << endl;
    c->joinAndGetRoom(targetRoomName).then(this, [this](Room* room) {
        if (!room) {
            clog << "Failed to join the test room" << endl;
            finalize();
            return;
        }
        // Ensure that the room has been joined and filled with some events
        // so that other tests could use that
        testSuite = new TestSuite(room, origin, this);
        // Only start the sync after joining, to make sure the room just
        // joined is in it
        c->syncLoop();
        connect(c, &Connection::syncDone, this, [this] {
            static int i = 0;
            clog << "Sync " << ++i << " complete" << endl;
            if (auto* r = testSuite->room()) {
                clog << "Test room timeline size = " << r->timelineSize();
                if (!r->pendingEvents().empty())
                    clog << ", pending size = " << r->pendingEvents().size();
                clog << endl;
            }
            if (!running.empty()) {
                clog << running.size() << " test(s) in the air:";
                for (const auto& test: std::as_const(running))
                    clog << " " << testName(test);
                clog << endl;
            }
            if (i == 1) {
                testSuite->room()->getPreviousContent().then(this, &TestManager::doTests);
            }
        });
    });
}

void TestManager::onNewRoom(Room* r)
{
    clog << "New room: " << r->id().toStdString() << "\n  Name: " << r->name().toStdString()
         << "\n  Canonical alias: " << r->canonicalAlias().toStdString() << endl;
    connect(r, &Room::aboutToAddNewMessages, r, [r](RoomEventsRange timeline) {
        clog << timeline.size() << " new event(s) in room " << r->objectName().toStdString() << endl;
    });
}

void TestManager::doTests()
{
    const auto* metaObj = testSuite->metaObject();
    for (auto i = metaObj->methodOffset(); i < metaObj->methodCount(); ++i) {
        const auto metaMethod = metaObj->method(i);
        if (metaMethod.access() != QMetaMethod::Private
            || metaMethod.methodType() != QMetaMethod::Slot)
            continue;

        const auto testName = metaMethod.name();
        running.push_back(testName);
        // Some tests return the result immediately but we queue everything
        // and process all tests asynchronously.
        QMetaObject::invokeMethod(testSuite, "doTest", Qt::QueuedConnection,
                                  Q_ARG(QByteArray, testName));
    }
    clog << "Tests to do:";
    for (const auto& test: std::as_const(running))
        clog << " " << testName(test);
    clog << endl;
    connect(testSuite, &TestSuite::finishedItem, this,
            [this](const QByteArray& itemName, bool condition) {
                if (auto i = running.indexOf(itemName); i != -1)
                    (condition ? succeeded : failed).push_back(running.takeAt(i));
                else
                    Q_ASSERT_X(false, itemName.constData(),
                               "Test item is not in running state");
                if (running.empty()) {
                    clog << "All tests finished" << endl;
                    conclude();
                }
            });
}

TEST_IMPL(findRoomByAlias)
{
    auto* roomByAlias = connection()->roomByAlias(targetRoom->canonicalAlias(),
                                        JoinState::Join);
    FINISH_TEST(roomByAlias == targetRoom);
}

TEST_IMPL(loadMembers)
{
    // It's not exactly correct because an arbitrary server might not support
    // lazy loading; but in the absence of capabilities framework we assume
    // it does.
    if (targetRoom->joinedMembers().size() >= targetRoom->joinedCount()) {
        clog << "Lazy loading doesn't seem to be enabled" << endl;
        FAIL_TEST();
    }
    targetRoom->setDisplayed();
    connect(targetRoom, &Room::allMembersLoaded, this, [this, thisTest] {
        FINISH_TEST(targetRoom->joinedMembers().size() >= targetRoom->joinedCount());
    });
    return false;
}

TEST_IMPL(sendMessage)
{
    auto txnId = targetRoom->postText("Hello, "_L1 % origin % " is here"_L1);
    if (!validatePendingEvent<RoomMessageEvent>(txnId)) {
        clog << "Invalid pending event right after submitting" << endl;
        FAIL_TEST();
    }
    targetRoom->whenMessageMerged(txnId).then(this, [this, thisTest, txnId](const RoomEvent& evt) {
        const auto pendingIt = targetRoom->findPendingEvent(txnId);
        if (pendingIt == targetRoom->pendingEvents().end()) {
            clog << "Pending event not found at the moment of local echo merging\n";
            FAIL_TEST();
        }
        FINISH_TEST(evt.is<RoomMessageEvent>() && !evt.id().isEmpty()
                    && txnId == (*pendingIt)->transactionId() && txnId == evt.transactionId());
    });
    return false;
}

TEST_IMPL(sendReaction)
{
    return targetRoom->post<RoomMessageEvent>(u"Reaction target"_s)
        .whenMerged()
        .then([this, thisTest](const RoomEvent& targetEvt) {
            const auto targetEvtId = targetEvt.id();
            clog << "Reacting to the message just sent to the room: " << targetEvtId.toStdString()
                 << endl;

            // TODO: a separate test unit for reactionevent.h
            if (loadEvent<ReactionEvent>(RoomEvent::basicJson(
                    ReactionEvent::TypeId,
                    { { RelatesToKey, toJson(EventRelation::replace(targetEvtId)) } }))) {
                clog << "ReactionEvent can be created with an invalid relation type" << endl;
                FAIL_TEST();
            }

            const auto key = u"+"_s;
            const auto txnId = targetRoom->postReaction(targetEvtId, key);
            FAIL_TEST_IF(!validatePendingEvent<ReactionEvent>(txnId),
                         "Invalid pending event right after submitting");

            connectUntil(targetRoom, &Room::updatedEvent, this,
                         [this, thisTest, txnId, key, targetEvtId](const QString& actualTargetEvtId) {
                             if (actualTargetEvtId != targetEvtId)
                                 return false;
                             const auto reactions =
                                 targetRoom->relatedEvents(targetEvtId,
                                                           EventRelation::AnnotationType);
                             FAIL_TEST_IF(reactions.size() != 1);

                             const auto* evt = eventCast<const ReactionEvent>(reactions.back());
                             FINISH_TEST(is<ReactionEvent>(*evt) && !evt->id().isEmpty()
                                         && evt->key() == key && evt->transactionId() == txnId);
                             // TODO: Test removing the reaction
                         });
            return false;
        })
        .isRunning();
}

TEST_IMPL(sendFile)
{
    auto* tf = new QTemporaryFile;
    if (!tf->open()) {
        clog << "Failed to create a temporary file" << endl;
        FAIL_TEST();
    }
    tf->write("Test");
    tf->close();
    const QFileInfo tfi { *tf };
    // QFileInfo::fileName brings only the file name; QFile::fileName brings
    // the full path
    const auto tfName = tfi.fileName();
    clog << "Sending file " << tfName.toStdString() << endl;
    const auto txnId = targetRoom->postFile(
        "Test file"_L1, std::make_unique<EventContent::FileContent>(tfi));
    if (!validatePendingEvent<RoomMessageEvent>(txnId)) {
        clog << "Invalid pending event right after submitting" << endl;
        tf->deleteLater();
        FAIL_TEST();
    }

    // Using tf as a context object to clean away both connections
    // once either of them triggers.
    connectUntil(targetRoom, &Room::fileTransferCompleted, tf,
        [this, thisTest, txnId, tf, tfName](const QString& id) {
            auto fti = targetRoom->fileTransferInfo(id);
            Q_ASSERT(fti.status == FileTransferInfo::Completed);

            if (id != txnId)
                return false;

            tf->deleteLater();
            return checkFileSendingOutcome(thisTest, txnId, tfName);
        });
    connectUntil(targetRoom, &Room::fileTransferFailed, tf,
        [this, thisTest, txnId, tf](const QString& id, const QString& error) {
            if (id != txnId)
                return false;

            targetRoom->postText(origin % ": File upload failed: "_L1 % error);
            tf->deleteLater();
            FAIL_TEST();
        });
    return false;
}

using NetworkReplyPtr = QObjectHolder<QNetworkReply>;

void getResource(const QUrl& url, NetworkReplyPtr& r, QEventLoop& el)
{
    r.reset(NetworkAccessManager::instance()->get(QNetworkRequest(url)));
    QObject::connect(
        r.get(), &QNetworkReply::finished, &el,
        [url, &r, &el] {
            if (r->error() != QNetworkReply::NoError)
                getResource(url, r, el);
            else
                el.exit();
        },
        Qt::QueuedConnection);
}

bool testDownload(const QUrl& url)
{
    // The actual test is separate from the download invocation to help debugging
    const auto results = QtConcurrent::blockingMapped(QVector<int>{ 1, 2, 3 }, [url](int) {
        thread_local QEventLoop el;
        thread_local NetworkReplyPtr reply{};
        getResource(url, reply, el);
        el.exec();
        return reply->error();
    });
    return results == QVector<QNetworkReply::NetworkError>(3, QNetworkReply::NoError);
}

bool TestSuite::checkFileSendingOutcome(const TestToken& thisTest,
                                        const QString& txnId,
                                        const QString& fileName)
{
    auto it = targetRoom->findPendingEvent(txnId);
    if (it == targetRoom->pendingEvents().end()) {
        clog << "Pending file event dropped before upload completion" << endl;
        FAIL_TEST();
    }
    if (it->deliveryStatus() != EventStatus::FileUploaded) {
        clog << "Pending file event status upon upload completion is "
             << it->deliveryStatus() << " != FileUploaded("
             << EventStatus::FileUploaded << ')' << endl;
        FAIL_TEST();
    }

    targetRoom->whenMessageMerged(txnId).then(
        this, [this, thisTest, txnId, fileName](const RoomEvent& evt) {
            clog << "File event " << txnId.toStdString() << " arrived in the timeline" << endl;
            using EventContent::FileContent;
            evt.switchOnType(
                [&](const RoomMessageEvent& e) {
                    // TODO: check #366 once #368 is implemented
                    FINISH_TEST(!e.id().isEmpty() && evt.transactionId() == txnId
                                && e.has<FileContent>()
                                && e.get<FileContent>()->originalName == fileName
                                && testDownload(targetRoom->connection()->makeMediaUrl(
                                    e.get<FileContent>()->url())));
                },
                [this, thisTest](const RoomEvent&) { FAIL_TEST(); });
        });
    return true;
}

DEFINE_SIMPLE_EVENT(CustomEvent, RoomEvent, "quotest.custom", int, testValue,
                    "test_value")

TEST_IMPL(sendCustomEvent)
{
    const auto& pendingEventItem = targetRoom->post<CustomEvent>(42);
    if (!validatePendingEvent<CustomEvent>(pendingEventItem->transactionId())) {
        clog << "Invalid pending event right after submitting" << endl;
        FAIL_TEST();
    }
    pendingEventItem.whenMerged().then(
        this, [this, thisTest, txnId = pendingEventItem->transactionId()](const RoomEvent& evt) {
            evt.switchOnType(
                [this, thisTest, txnId, &evt](const CustomEvent& e) {
                    FINISH_TEST(!evt.id().isEmpty() && evt.transactionId() == txnId
                                && e.testValue() == 42);
                },
                [this, thisTest](const RoomEvent&) { FAIL_TEST(); });
        });
    return false;

}

TEST_IMPL(setTopic)
{
    const auto newTopic = connection()->generateTxnId(); // Just a way to make a unique id
    targetRoom->setTopic(newTopic);
    connectUntil(targetRoom, &Room::topicChanged, this,
        [this, thisTest, newTopic] {
            if (targetRoom->topic() == newTopic)
                FINISH_TEST(true);

            clog << "Requested topic was " << newTopic.toStdString() << ", "
                 << targetRoom->topic().toStdString() << " arrived instead"
                 << endl;
            return false;
        });
    return false;
}

// TODO: maybe move it to Room?..
QFuture<void> ensureEvent(Room* room, const QString& evtId, QPromise<void>&& p = QPromise<void>{})
{
    auto future = p.future();
    if (room->findInTimeline(evtId) == room->historyEdge()) {
        clog << "Loading a page of history, " << room->timelineSize() << " events so far\n";
        room->getPreviousContent().then(std::bind_front(ensureEvent, room, evtId, std::move(p)));
    } else
        p.finish();
    return future;
}

TEST_IMPL(redactEvent)
{
    using TargetEventType = RoomMemberEvent;

    // We use currentState() to quickly get an id of our own joining event,
    // to try to redact it. As long as the homeserver is compliant to the spec
    // nothing bad will happen upon an attempt to redact that member event,
    // the test user will remain a member of the room, while the library is
    // tested to implement MSC2176 correctly (see also our own bug #664).
    const auto* memberEventToRedact =
        targetRoom->currentState().get<TargetEventType>(connection()->userId());
    Q_ASSERT(memberEventToRedact); // ...or the room state is totally screwed
    const auto& evtId = memberEventToRedact->id();

    // Make sure the event is loaded in the timeline before proceeding with the test, to make sure
    // the replacement tracked below actually occurs
    ensureEvent(targetRoom, evtId).then([this, thisTest, evtId] {
        clog << "Redacting the latest member event" << endl;
        targetRoom->redactEvent(evtId, origin);
        connectUntil(targetRoom, &Room::replacedEvent, this,
                     [this, thisTest, evtId](const RoomEvent* evt) {
                         // Concurrent replacement/redaction shouldn't happen as of now; but if/when
                         // event editing is added to the test suite, this may become a thing
                         if (evt->id() != evtId)
                             return false;
                         FINISH_TEST(evt->switchOnType([this](const TargetEventType& e) {
                             return e.redactionReason() == origin && e.membership() == Membership::Join;
                             // The second condition above tests MSC2176 - if it's violated (pre 0.8
                             // beta), membership() ends up being Membership::Undefined
                         }));
                     });
    });

    return false;
}

TEST_IMPL(changeName)
{
    // NB: this test races against redactEvent(); both update the same event
    // type and state key. In an extremely improbable case when changeName()
    // completes (with server roundtrips etc.) the first rename before
    // redactEvent() even starts, redactEvent() will capture the rename event
    // instead of the join, and likely break changeName() as a result.
    QtFuture::connect(targetRoom, &Room::allMembersLoaded).then([this, thisTest] {
        auto* const localUser = connection()->user();
        const auto& newName = connection()->generateTxnId(); // See setTopic()
        clog << "Renaming the user to " << newName.toStdString()
             << " in the target room" << endl;
        localUser->rename(newName, targetRoom);
        connectUntil(
            targetRoom, &Room::aboutToAddNewMessages, this,
            [this, thisTest, localUser, newName](RoomEventsRange evts) {
                for (const auto& e : evts) {
                    if (const auto* rme = eventCast<const RoomMemberEvent>(e)) {
                        if (rme->stateKey() != localUser->id()
                            || !rme->isRename())
                            continue;
                        if (!rme->newDisplayName()
                            || *rme->newDisplayName() != newName)
                            FAIL_TEST();
                        // State events coming in the timeline are first
                        // processed to change the room state and then as
                        // timeline messages; aboutToAddNewMessages is triggered
                        // when the state is already updated, so check that
                        if (targetRoom->currentState().get<RoomMemberEvent>(
                                localUser->id())
                            != rme)
                            FAIL_TEST();
                        clog << "Member rename successful, renaming the account"
                             << endl;
                        const auto newN = newName.mid(0, 5);
                        localUser->rename(newN);
                        connectUntil(localUser, &User::defaultNameChanged, this,
                                     [this, thisTest, localUser, newN] {
                                         localUser->rename({});
                                         FINISH_TEST(localUser->name() == newN);
                                     });
                        return true;
                    }
                }
                return false;
            });
    });
    return false;
}

TEST_IMPL(showLocalUsername)
{
    auto* const localUser = connection()->user();
    FINISH_TEST(!localUser->name().contains("@"_L1));
}

TEST_IMPL(addAndRemoveTag)
{
    static const auto TestTag = u"im.quotient.test"_s;
    // Pre-requisite
    if (targetRoom->tags().contains(TestTag))
        targetRoom->removeTag(TestTag);

    // Unlike for most of Quotient, tags are applied and tagsChanged is emitted
    // synchronously, with the server being notified async. The test checks
    // that the signal is emitted, not only that tags have changed; but there's
    // (currently) no way to check that the server has been correctly notified
    // of the tag change.
    const QSignalSpy spy(targetRoom, &Room::tagsChanged);
    targetRoom->addTag(TestTag);
    if (spy.size() != 1 || !targetRoom->tags().contains(TestTag)) {
        clog << "Tag adding failed" << endl;
        FAIL_TEST();
    }
    const auto& tagsToRooms = connection()->tagsToRooms();
    if (!tagsToRooms.contains(TestTag) || !tagsToRooms[TestTag].contains(targetRoom)) {
        clog << "Tag adding succeeded but the connection doesn't know about it\n";
        FAIL_TEST();
    }
    clog << "Test tag set, removing it now" << endl;
    targetRoom->removeTag(TestTag);
    FINISH_TEST(spy.size() == 2 && !targetRoom->tags().contains(TestTag));
}

bool TestSuite::checkDirectChat() const
{
    return targetRoom->directChatMembers().contains(targetRoom->member(connection()->user()->id()));
}

TEST_IMPL(markDirectChat)
{
    if (checkDirectChat())
        connection()->removeFromDirectChats(targetRoom->id(),
                                            connection()->user()->id());

    const auto id = qRegisterMetaType<DirectChatsMap>(); // For QSignalSpy
    Q_ASSERT(id != -1);

    // Same as with tags (and unusual for the rest of Quotient), direct chat
    // operations are synchronous.
    const QSignalSpy spy(connection(), &Connection::directChatsListChanged);
    clog << "Marking the room as a direct chat" << endl;
    connection()->addToDirectChats(targetRoom, connection()->user()->id());
    if (spy.size() != 1 || !checkDirectChat())
        FAIL_TEST();

    // Check that the first argument (added DCs) actually contains the room
    const auto& addedDCs = spy.back().front().value<DirectChatsMap>();
    if (addedDCs.size() != 1
        || !addedDCs.contains(connection()->user(), targetRoom->id())) {
        clog << "The room is not in added direct chats" << endl;
        FAIL_TEST();
    }

    clog << "Unmarking the direct chat" << endl;
    connection()->removeFromDirectChats(targetRoom->id(), connection()->user()->id());
    if (spy.size() != 2 && checkDirectChat())
        FAIL_TEST();

    // Check that the second argument (removed DCs) actually contains the room
    const auto& removedDCs = spy.back().back().value<DirectChatsMap>();
    FINISH_TEST(removedDCs.size() == 1
                && removedDCs.contains(connection()->user(), targetRoom->id()));
}

TEST_IMPL(visitResources)
{
    // Same as the two tests above, ResourceResolver emits signals
    // synchronously so we use signal spies to intercept them instead of
    // connecting lambdas before calling openResource(). NB: this test
    // assumes that ResourceResolver::openResource is implemented in terms
    // of ResourceResolver::visitResource, so the latter doesn't need a
    // separate test.
    static UriDispatcher ud;

    // This lambda returns true in case of error, false if it's fine so far
    const auto testResourceResolver = [this, thisTest](const QStringList& uris, auto signal,
                                                       auto* target, QVariantList otherArgs = {}) {
        const auto r = qRegisterMetaType<decltype(target)>();
        Q_ASSERT(r != 0);
        QSignalSpy spy(&ud, signal);
        for (const auto& uriString: uris) {
            const Uri uri { uriString };
            clog << "Checking " << uriString.toStdString()
                 << " -> " << uri.toDisplayString().toStdString() << endl;
            if (auto matrixToUrl = uri.toUrl(Uri::MatrixToUri).toDisplayString();
                !matrixToUrl.startsWith("https://matrix.to/#/"_L1)) {
                clog << "Incorrect matrix.to representation:"
                     << matrixToUrl.toStdString() << endl;
            }
            const auto checkResult = checkResource(connection(), uriString);
            if ((checkResult != UriResolved && uri.type() != Uri::NonMatrix)
                || (uri.type() == Uri::NonMatrix
                    && checkResult != CouldNotResolve)) {
                clog << "checkResource() returned incorrect result:"
                     << checkResult;
                FAIL_TEST();
            }
            ud.visitResource(connection(), uriString);
            if (spy.size() != 1) {
                clog << "Wrong number of signal emissions (" << spy.size()
                     << ')' << endl;
                FAIL_TEST();
            }
            const auto& emission = spy.front();
            Q_ASSERT(emission.size() >= 2);
            if (emission.front().value<decltype(target)>() != target) {
                clog << "Signal emitted with an incorrect target" << endl;
                FAIL_TEST();
            }
            if (!otherArgs.empty()) {
                if (emission.size() < otherArgs.size() + 1) {
                    clog << "Emission doesn't include all arguments" << endl;
                    FAIL_TEST();
                }
                for (auto i = 0; i < otherArgs.size(); ++i)
                    if (otherArgs[i] != emission[i + 1]) {
                        clog << "Mismatch in argument #" << i + 1 << endl;
                        FAIL_TEST();
                    }
            }
            spy.clear();
        }
        return false;
    };

    // Basic tests
    for (const auto& u: { Uri {}, Uri { QUrl {} } })
        if (u.isValid() || !u.isEmpty()) {
            clog << "Empty Matrix URI test failed" << endl;
            FAIL_TEST();
        }
    if (Uri { u"#"_s }.isValid()) {
        clog << "Bare sigil URI test failed" << endl;
        FAIL_TEST();
    }
    QUrl invalidUrl { "https://"_L1 };
    invalidUrl.setAuthority("---:@@@"_L1);
    const Uri matrixUriFromInvalidUrl{ invalidUrl }, invalidMatrixUri{ u"matrix:&invalid@"_s };
    if (matrixUriFromInvalidUrl.isEmpty() || matrixUriFromInvalidUrl.isValid()) {
        clog << "Invalid Matrix URI test failed" << endl;
        FAIL_TEST();
    }
    if (invalidMatrixUri.isEmpty() || invalidMatrixUri.isValid()) {
        clog << "Invalid sigil in a Matrix URI - test failed" << endl;
        FAIL_TEST();
    }

    // Matrix identifiers used throughout all URI tests
    const auto& roomId = room()->id();
    const auto& roomAlias = room()->canonicalAlias();
    const auto& userId = connection()->userId();
    const auto& eventId = room()->messageEvents().back()->id();
    Q_ASSERT(!roomId.isEmpty());
    Q_ASSERT(!roomAlias.isEmpty());
    Q_ASSERT(!userId.isEmpty());
    Q_ASSERT(!eventId.isEmpty());

    const QStringList roomUris {
        roomId, "matrix:roomid/"_L1 + roomId.mid(1),
        "https://matrix.to/#/%21"_L1/*`!`*/ + roomId.mid(1),
        roomAlias, "matrix:room/"_L1 + roomAlias.mid(1),
        "matrix:r/"_L1 + roomAlias.mid(1),
        "https://matrix.to/#/"_L1 + roomAlias,
    };
    const QStringList userUris { userId, "matrix:user/"_L1 + userId.mid(1),
                                 "matrix:u/"_L1 + userId.mid(1),
                                 "https://matrix.to/#/"_L1 + userId };
    const QStringList eventUris {
        "matrix:room/"_L1 + roomAlias.mid(1) + "/event/"_L1 + eventId.mid(1),
        "matrix:r/"_L1 + roomAlias.mid(1) + "/e/"_L1 + eventId.mid(1),
        "https://matrix.to/#/"_L1 + roomId + u'/' + eventId
    };
    // Check that reserved characters are correctly processed.
    static const auto joinRoomAlias = u"##/?.@\"unjoined:example.org"_s;
    static const auto& encodedRoomAliasNoSigil =
        QString::fromLatin1(QUrl::toPercentEncoding(joinRoomAlias.mid(1), ":"_ba));
    static const auto joinQuery = u"?action=join"_s;
    // These URIs are not supposed to be actually joined (and even exist,
    // as yet) - only to be syntactically correct
    static const QStringList joinByAliasUris{
        Uri(joinRoomAlias.toUtf8(), {}, joinQuery.mid(1)).toDisplayString(),
        "matrix:room/"_L1 % encodedRoomAliasNoSigil % joinQuery,
        "matrix:r/"_L1 % encodedRoomAliasNoSigil % joinQuery,
        "https://matrix.to/#/%23"_L1 /*`#`*/ % encodedRoomAliasNoSigil % joinQuery,
        "https://matrix.to/#/%23"_L1 % joinRoomAlias.mid(1) /* unencoded */ % joinQuery
    };
    static const auto joinRoomId = u"!anyid:example.org"_s;
    static constexpr auto viaServers = std::to_array({ "matrix.org"_L1, "example.org"_L1 });
    static const auto viaQuery = std::apply(
        [](const auto&... servers) { return QString((joinQuery % ... % (u"&via="_s % servers))); },
        viaServers);
    static const QStringList joinByIdUris{ "matrix:roomid/"_L1 % joinRoomId.mid(1) % viaQuery,
                                           "https://matrix.to/#/"_L1 % joinRoomId % viaQuery };
    // If any test breaks, the breaking call will return true, and further
    // execution will be cut by ||'s short-circuiting
    if (testResourceResolver(roomUris, &UriDispatcher::roomAction, room())
        || testResourceResolver(userUris, &UriDispatcher::userAction, connection()->user())
        || testResourceResolver(eventUris, &UriDispatcher::roomAction, room(), { eventId })
        || testResourceResolver(joinByAliasUris, &UriDispatcher::joinAction, connection(),
                                { joinRoomAlias })
        || testResourceResolver(joinByIdUris, &UriDispatcher::joinAction, connection(),
                                { joinRoomId, QStringList(viaServers.cbegin(), viaServers.cend()) }))
        return true;
    // TODO: negative cases
    FINISH_TEST(true);
}

TEST_IMPL(thread)
{
    auto rootTxnId = targetRoom->postText("Threadroot"_L1);
    connect(targetRoom, &Room::pendingEventAboutToMerge, this, [this, thisTest, rootTxnId](Quotient::RoomEvent* rootEvt) {
        if (rootEvt->transactionId() == rootTxnId) {
            const auto relation = EventRelation::replyInThread(rootEvt->id(), true, rootEvt->id());
            targetRoom->post<Quotient::RoomMessageEvent>(u"Thread reply 1"_s, Quotient::RoomMessageEvent::MsgType::Text, nullptr, relation)
                .whenMerged()
                .then([this, thisTest](const RoomEvent& replyEvt) {
                    replyEvt.switchOnType(
                        [&](const RoomMessageEvent& rmReplyEvt) {
                            const auto thread = targetRoom->threads()[rmReplyEvt.threadRootEventId()];
                            FINISH_TEST(thread.threadRootId == rmReplyEvt.threadRootEventId() &&
                                        thread.latestEventId == rmReplyEvt.id() &&
                                        thread.size == 2
                            );
                        },
                        [this, thisTest](const RoomEvent&) { FAIL_TEST(); }
                    );
                });
        }
    });

    return false;
}

bool checkPrettyPrint(
    std::initializer_list<std::pair<const char*, const char*>> tests)
{
    bool result = true;
    for (const auto& [test, etalon] : tests) {
        const auto is = prettyPrint(QString::fromUtf8(test)).toStdString();
        const auto shouldBe = std::string("<span style='white-space:pre-wrap'>")
                              + etalon + "</span>";
        if (is == shouldBe)
            continue;
        clog << is << " != " << shouldBe << endl;
        result = false;
    }
    return result;
}

void TestManager::conclude()
{
    // Clean up the room (best effort)
    auto* room = testSuite->room();
    room->setTopic({});
    c->user()->rename({});

    const QString succeededRec{ QString::number(succeeded.size()) % " of "_L1
                                % QString::number(succeeded.size() + failed.size() + running.size())
                                % " tests succeeded"_L1 };
    QString plainReport = origin % ": Testing complete, "_L1 % succeededRec;
    const QString color = failed.empty() && running.empty() ? "00AA00"_L1 : "AA0000"_L1;
    QString htmlReport = origin % ": <strong><font data-mx-color='#"_L1 % color
                         % "' color='#"_L1 % color
                         % "'>Testing complete</font></strong>, "_L1 % succeededRec;
    if (!failed.empty()) {
        QByteArray failedList;
        for (const auto& f : std::as_const(failed))
            failedList += ' ' + f;
        plainReport += "\nFAILED:"_L1 + QString::fromUtf8(failedList);
        htmlReport += "<br><strong>Failed:</strong>"_L1 + QString::fromUtf8(failedList);
    }
    if (!running.empty()) {
        QByteArray dnfList;
        for (const auto& r : std::as_const(running))
            dnfList += ' ' + r;
        plainReport += "\nDID NOT FINISH:"_L1 + QString::fromUtf8(dnfList);
        htmlReport += "<br><strong>Did not finish:</strong>"_L1 + QString::fromUtf8(dnfList);
    }

    auto txnId = room->postText(plainReport, htmlReport);
    // Now just wait until all the pending events reach the server
    connectUntil(room, &Room::messageSent, this, [this, txnId, room, plainReport] {
        const auto& pendingEvents = room->pendingEvents();
        if (const auto stillFlyingCount =
                std::ranges::count_if(pendingEvents,
                                      [](const PendingEventItem& pe) {
                                          return pe.deliveryStatus() < EventStatus::ReachedServer;
                                      });
            stillFlyingCount > 0) {
            clog << "Events to reach the server: " << stillFlyingCount << ", not leaving yet\n";
            return false;
        }

        clog << "Leaving the room" << endl;
        room->leaveRoom().then(this, std::bind_front(&TestManager::finalize, this, plainReport));
        return true;
    });
}

void TestManager::finalize(const QString& lastWords)
{
    if (!c->isLoggedIn()) {
        clog << "No usable connection reached" << endl;
        QCoreApplication::exit(-2);
        return; // NB: QCoreApplication::exit() does return to the caller
    }
    clog << "Logging out" << endl;
    c->logout().then(
        this, [this, lastWords] {
            clog << lastWords.toStdString() << endl;
            QCoreApplication::exit(!testSuite ? -3
                                   : succeeded.empty() && failed.empty()
                                           && running.empty()
                                       ? -4
                                       : static_cast<int>(failed.size() + running.size()));
        });
}

int main(int argc, char* argv[])
{
    // TODO: use QCommandLineParser
    if (argc < 5) {
        clog << "Usage: quotest <user> <passwd> <device_name> <room_alias> [origin]"
             << endl;
        return -1;
    }
    // NOLINTNEXTLINE(readability-static-accessed-through-instance)
    return TestManager(argc, argv).exec();
}

#include "quotest.moc"
