/******************************************************************************
 * Copyright (C) 2015 Felix Rohrbach <kde@fxrh.de>
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 */

#include "connection.h"
#include "connectiondata.h"
#include "user.h"
#include "events/directchatevent.h"
#include "events/eventloader.h"
#include "room.h"
#include "settings.h"
#include "csapi/login.h"
#include "csapi/capabilities.h"
#include "csapi/logout.h"
#include "csapi/receipts.h"
#include "csapi/leaving.h"
#include "csapi/account-data.h"
#include "csapi/joining.h"
#include "csapi/to_device.h"
#include "csapi/room_send.h"
#include "csapi/wellknown.h"
#include "csapi/versions.h"
#include "jobs/syncjob.h"
#include "jobs/mediathumbnailjob.h"
#include "jobs/downloadfilejob.h"
#include "csapi/voip.h"

#include <QtCore/QFile>
#include <QtCore/QDir>
#include <QtCore/QStandardPaths>
#include <QtCore/QStringBuilder>
#include <QtCore/QElapsedTimer>
#include <QtCore/QRegularExpression>
#include <QtCore/QMimeDatabase>
#include <QtCore/QCoreApplication>

using namespace QMatrixClient;

// This is very much Qt-specific; STL iterators don't have key() and value()
template <typename HashT, typename Pred>
HashT erase_if(HashT& hashMap, Pred pred)
{
    HashT removals;
    for (auto it = hashMap.begin(); it != hashMap.end();)
    {
        if (pred(it))
        {
            removals.insert(it.key(), it.value());
            it = hashMap.erase(it);
        } else
            ++it;
    }
    return removals;
}

class Connection::Private
{
    public:
        explicit Private(std::unique_ptr<ConnectionData>&& connection)
            : data(move(connection))
        { }
        Q_DISABLE_COPY(Private)
        DISABLE_MOVE(Private)

        Connection* q = nullptr;
        std::unique_ptr<ConnectionData> data;
        // A complex key below is a pair of room name and whether its
        // state is Invited. The spec mandates to keep Invited room state
        // separately; specifically, we should keep objects for Invite and
	    // Leave state of the same room if the two happen to co-exist.
        QHash<QPair<QString, bool>, Room*> roomMap;
        /// Mapping from aliases to room ids, as of the last sync
        QHash<QString, QString> roomAliasMap;
        QVector<QString> roomIdsToForget;
        QVector<Room*> firstTimeRooms;
        QVector<QString> pendingStateRoomIds;
        QMap<QString, User*> userMap;
        DirectChatsMap directChats;
        DirectChatUsersMap directChatUsers;
        // The below two variables track local changes between sync completions.
        // See also: https://github.com/QMatrixClient/libqmatrixclient/wiki/Handling-direct-chat-events
        DirectChatsMap dcLocalAdditions;
        DirectChatsMap dcLocalRemovals;
        std::unordered_map<QString, EventPtr> accountData;
        QString userId;
        int syncLoopTimeout = -1;

        GetCapabilitiesJob* capabilitiesJob = nullptr;
        GetCapabilitiesJob::Capabilities capabilities;

        QVector<GetLoginFlowsJob::LoginFlow> loginFlows;

        SyncJob* syncJob = nullptr;

        bool cacheState = true;
        bool cacheToBinary = SettingsGroup("libqmatrixclient")
                             .value("cache_type").toString() != "json";
        bool lazyLoading = false;

        template <typename... LoginArgTs>
        void loginToServer(LoginArgTs&&... loginArgs);
        void assumeIdentity(const QString& newUserId, const QString& accessToken,
                            const QString& deviceId);

        template <typename EventT>
        EventT* unpackAccountData() const
        {
            const auto& eventIt = accountData.find(EventT::matrixTypeId());
            return eventIt == accountData.end()
                    ? nullptr : weakPtrCast<EventT>(eventIt->second);
        }

        void packAndSendAccountData(EventPtr&& event)
        {
            const auto eventType = event->matrixType();
            q->callApi<SetAccountDataJob>(userId, eventType,
                                          event->contentJson());
            accountData[eventType] = std::move(event);
            emit q->accountDataChanged(eventType);
        }

        template <typename EventT, typename ContentT>
        void packAndSendAccountData(ContentT&& content)
        {
            packAndSendAccountData(
                        makeEvent<EventT>(std::forward<ContentT>(content)));
        }
};

Connection::Connection(const QUrl& server, QObject* parent)
    : QObject(parent)
    , d(std::make_unique<Private>(std::make_unique<ConnectionData>(server)))
{
    d->q = this; // All d initialization should occur before this line
}

Connection::Connection(QObject* parent)
    : Connection({}, parent)
{ }

Connection::~Connection()
{
    qCDebug(MAIN) << "deconstructing connection object for" << d->userId;
    stopSync();
}

void Connection::resolveServer(const QString& mxidOrDomain)
{
    // At this point we may have something as complex as
    // @username:[IPv6:address]:port, or as simple as a plain domain name.

    // Try to parse as an FQID; if there's no @ part, assume it's a domain name.
    QRegularExpression parser(
        "^(@.+?:)?" // Optional username (allow everything for compatibility)
        "(\\[[^]]+\\]|[^:@]+)" // Either IPv6 address or hostname/IPv4 address
        "(:\\d{1,5})?$", // Optional port
        QRegularExpression::UseUnicodePropertiesOption); // Because asian digits
    auto match = parser.match(mxidOrDomain);

    QUrl maybeBaseUrl = QUrl::fromUserInput(match.captured(2));
    maybeBaseUrl.setScheme("https"); // Instead of the Qt-default "http"
    if (!match.hasMatch() || !maybeBaseUrl.isValid())
    {
        emit resolveError(tr("%1 is not a valid homeserver address")
                              .arg(maybeBaseUrl.toString()));
        return;
    }

    auto domain = maybeBaseUrl.host();
    qCDebug(MAIN) << "Finding the server" << domain;

    d->data->setBaseUrl(maybeBaseUrl); // Just enough to check .well-known file
    auto getWellKnownJob = callApi<GetWellknownJob>();
    // This is a workaround for 0.5.x; due to the way Quaternion's login dialog
    // operates, Connection can disappear any moment during server resolution.
    // Quotient 0.6 will reparent all jobs to enforce lifetimes. See also #398.
    getWellKnownJob->setParent(this);
    connect(getWellKnownJob, &BaseJob::finished, this,
        [this, getWellKnownJob, maybeBaseUrl] {
            if (getWellKnownJob->status() != BaseJob::NotFoundError) {
                if (getWellKnownJob->status() != BaseJob::Success) {
                    qCWarning(MAIN)
                        << "Fetching .well-known file failed, FAIL_PROMPT";
                    emit resolveError(tr("Failed resolving the homeserver"));
                    return;
                }
                QUrl baseUrl { getWellKnownJob->data().homeserver.baseUrl };
                if (baseUrl.isEmpty()) {
                    qCWarning(MAIN) << "base_url not provided, FAIL_PROMPT";
                    emit resolveError(
                        tr("The homeserver base URL is not provided"));
                    return;
                }
                if (!baseUrl.isValid()) {
                    qCWarning(MAIN) << "base_url invalid, FAIL_ERROR";
                    emit resolveError(tr("The homeserver base URL is invalid"));
                    return;
                }
                qCInfo(MAIN) << ".well-known URL for" << maybeBaseUrl.host()
                             << "is" << baseUrl.authority();
                setHomeserver(baseUrl);
            } else {
                qCInfo(MAIN) << "No .well-known file, using" << maybeBaseUrl
                             << "for base URL";
                setHomeserver(maybeBaseUrl);
            }

            auto getVersionsJob = callApi<GetVersionsJob>();
            getVersionsJob->setParent(this); // Same workaround as above
            connect(getVersionsJob, &BaseJob::success, this,
                    &Connection::resolved);
            connect(getVersionsJob, &BaseJob::failure, this, [this] {
                qCWarning(MAIN) << "Homeserver base URL invalid";
                emit resolveError(tr("The homeserver base URL "
                                     "doesn't seem to work"));
            });
        });
}

inline UserIdentifier makeUserIdentifier(const QString& id)
{
    return { QStringLiteral("m.id.user"), { { QStringLiteral("user"), id } } };
}

inline UserIdentifier make3rdPartyIdentifier(const QString& medium,
                                             const QString& address)
{
    return { QStringLiteral("m.id.thirdparty"),
             { { QStringLiteral("medium"), medium },
               { QStringLiteral("address"), address } } };
}

void Connection::connectToServer(const QString& user, const QString& password,
                                 const QString& initialDeviceName,
                                 const QString& deviceId)
{
    checkAndConnect(user,
        [=] {
            doConnectToServer(user, password, initialDeviceName, deviceId);
        });
}

void Connection::doConnectToServer(const QString& user, const QString& password,
                                   const QString& initialDeviceName,
                                   const QString& deviceId)
{
    d->loginToServer(LoginFlows::Password.type, makeUserIdentifier(user),
                     password, /*token*/ "", deviceId, initialDeviceName);
}

SsoSession* Connection::prepareForSso(const QString& initialDeviceName,
                                      const QString& deviceId)
{
    return new SsoSession(this, initialDeviceName, deviceId);
}

void Connection::loginWithToken(const QByteArray& loginToken,
                                const QString& initialDeviceName,
                                const QString& deviceId)
{
    d->loginToServer(LoginFlows::Token.type,
                     makeUserIdentifier(/*user is encoded in loginToken*/ {}),
                     /*password*/ "", loginToken, deviceId, initialDeviceName);
}

void Connection::syncLoopIteration()
{
    sync(d->syncLoopTimeout);
}

void Connection::connectWithToken(const QString& userId,
                                  const QString& accessToken,
                                  const QString& deviceId)
{
	assumeIdentity(userId, accessToken, deviceId);
}

void Connection::assumeIdentity(const QString& userId,
                                const QString& accessToken,
                                const QString& deviceId)
{
    checkAndConnect(userId,
                    [=] { d->assumeIdentity(userId, accessToken, deviceId); });
}

void Connection::reloadCapabilities()
{
    d->capabilitiesJob = callApi<GetCapabilitiesJob>(BackgroundRequest);
    connect(d->capabilitiesJob, &BaseJob::finished, this, [this] {
        if (d->capabilitiesJob->error() == BaseJob::Success)
            d->capabilities = d->capabilitiesJob->capabilities();
        else if (d->capabilitiesJob->error() == BaseJob::IncorrectRequestError)
            qCDebug(MAIN) << "Server doesn't support /capabilities";

        if (d->capabilities.roomVersions.omitted())
        {
            qCWarning(MAIN) << "Pinning supported room version to 1";
            d->capabilities.roomVersions = { "1", {{ "1", "stable" }} };
        } else {
            qCDebug(MAIN) << "Room versions:"
                << defaultRoomVersion() << "is default, full list:"
                << availableRoomVersions();
        }
        Q_ASSERT(!d->capabilities.roomVersions.omitted());
        emit capabilitiesLoaded();
        for (auto* r: d->roomMap)
            r->checkVersion();
    });
}

bool Connection::loadingCapabilities() const
{
    // (Ab)use the fact that room versions cannot be omitted after
    // the capabilities have been loaded (see reloadCapabilities() above).
    return d->capabilities.roomVersions.omitted();
}

template <typename... LoginArgTs>
void Connection::Private::loginToServer(LoginArgTs&&... loginArgs)
{
    auto loginJob =
            q->callApi<LoginJob>(std::forward<LoginArgTs>(loginArgs)...);
    connect(loginJob, &BaseJob::success, q, [this, loginJob] {
        assumeIdentity(loginJob->userId(), loginJob->accessToken(),
                       loginJob->deviceId());
    });
    connect(loginJob, &BaseJob::failure, q, [this, loginJob] {
        emit q->loginError(loginJob->errorString(), loginJob->rawDataSample());
    });
}

void Connection::Private::assumeIdentity(const QString& newUserId,
                                         const QString& accessToken,
                                         const QString& deviceId)
{
    userId = newUserId;
    q->user(); // Creates a User object for the local user
    data->setToken(accessToken.toLatin1());
    data->setDeviceId(deviceId);
    qCDebug(MAIN) << "Using server" << data->baseUrl().toDisplayString()
                  << "by user" << userId << "from device" << deviceId;
    emit q->stateChanged();
    emit q->connected();
    q->reloadCapabilities();
}

void Connection::checkAndConnect(const QString& userId,
                                 std::function<void()> connectFn)
{
    if (d->data->baseUrl().isValid())
    {
        connectFn();
        return;
    }
    // Not good to go, try to fix the homeserver URL.
    if (userId.startsWith('@') && userId.indexOf(':') != -1)
    {
        connectSingleShot(this, &Connection::homeserverChanged, this, connectFn);
        // NB: doResolveServer can emit resolveError, so this is a part of
        // checkAndConnect function contract.
        resolveServer(userId);
    } else
        emit resolveError(
            tr("%1 is an invalid homeserver URL")
                .arg(d->data->baseUrl().toString()));
}

void Connection::logout()
{
    auto job = callApi<LogoutJob>();
    connect( job, &LogoutJob::finished, this, [job,this] {
        if (job->status().good() || job->error() == BaseJob::ContentAccessError)
        {
            stopSync();
            d->data->setToken({});
            emit stateChanged();
            emit loggedOut();
        }
    });
}

void Connection::sync(int timeout)
{
    if (d->syncJob)
        return;

    Filter filter;
    filter.room->timeline->limit = 100;
    filter.room->state->lazyLoadMembers = d->lazyLoading;
    auto job = d->syncJob = callApi<SyncJob>(BackgroundRequest,
                                d->data->lastEvent(), filter, timeout);
    connect( job, &SyncJob::success, this, [this, job] {
        onSyncSuccess(job->takeData());
        d->syncJob = nullptr;
        emit syncDone();
    });
    connect( job, &SyncJob::retryScheduled, this,
        [this,job] (int retriesTaken, int nextInMilliseconds)
        {
            emit networkError(job->errorString(), job->rawDataSample(),
                              retriesTaken, nextInMilliseconds);
        });
    connect( job, &SyncJob::failure, this, [this, job] {
        d->syncJob = nullptr;
        if (job->error() == BaseJob::ContentAccessError)
        {
            qCWarning(SYNCJOB)
                << "Sync job failed with ContentAccessError - login expired?";
            emit loginError(job->errorString(), job->rawDataSample());
        }
        else
            emit syncError(job->errorString(), job->rawDataSample());
    });
}

void Connection::syncLoop(int timeout)
{
    d->syncLoopTimeout = timeout;
    connect(this, &Connection::syncDone, this, &Connection::syncLoopIteration);
    syncLoopIteration(); // initial sync to start the loop
}

QJsonObject toJson(const Connection::DirectChatsMap& directChats)
{
    QJsonObject json;
    for (auto it = directChats.begin(); it != directChats.end();)
    {
        QJsonArray roomIds;
        const auto* user = it.key();
        for (; it != directChats.end() && it.key() == user; ++it)
            roomIds.append(*it);
        json.insert(user->id(), roomIds);
    }
    return json;
}

void Connection::onSyncSuccess(SyncData &&data, bool fromCache) {
    d->data->setLastEvent(data.nextBatch());
    for (auto&& roomData: data.takeRoomData())
    {
        const auto forgetIdx = d->roomIdsToForget.indexOf(roomData.roomId);
        if (forgetIdx != -1)
        {
            d->roomIdsToForget.removeAt(forgetIdx);
            if (roomData.joinState == JoinState::Leave)
            {
                qDebug(MAIN) << "Room" << roomData.roomId
                    << "has been forgotten, ignoring /sync response for it";
                continue;
            }
            qWarning(MAIN) << "Room" << roomData.roomId
                 << "has just been forgotten but /sync returned it in"
                 << toCString(roomData.joinState)
                 << "state - suspiciously fast turnaround";
        }
        if ( auto* r = provideRoom(roomData.roomId, roomData.joinState) )
        {
            d->pendingStateRoomIds.removeOne(roomData.roomId);
            r->updateData(std::move(roomData), fromCache);
            if (d->firstTimeRooms.removeOne(r))
            {
                emit loadedRoomState(r);
                if (!d->capabilities.roomVersions.omitted())
                    r->checkVersion();
                // Otherwise, the version will be checked in reloadCapabilities()
            }
        }
        // Let UI update itself after updating each room
        QCoreApplication::processEvents();
    }
    // After running this loop, the account data events not saved in
    // d->accountData (see the end of the loop body) are auto-cleaned away
    for (auto& eventPtr : data.takeAccountData())
    {
        visit(*eventPtr,
              [this](const DirectChatEvent& dce) {
                  // See https://github.com/QMatrixClient/libqmatrixclient/wiki/Handling-direct-chat-events
                  const auto& usersToDCs = dce.usersToDirectChats();
                  DirectChatsMap remoteRemovals =
                      erase_if(d->directChats, [&usersToDCs, this](auto it) {
                          return !(usersToDCs.contains(it.key()->id(), it.value())
                                   || d->dcLocalAdditions.contains(it.key(),
                                                                   it.value()));
                      });
                  erase_if(d->directChatUsers, [&remoteRemovals](auto it) {
                      return remoteRemovals.contains(it.value(), it.key());
                  });
                  // Remove from dcLocalRemovals what the server already has.
                  erase_if(d->dcLocalRemovals, [&remoteRemovals](auto it) {
                      return remoteRemovals.contains(it.key(), it.value());
                  });
                  if (MAIN().isDebugEnabled())
                      for (auto it = remoteRemovals.begin();
                           it != remoteRemovals.end(); ++it) {
                          qCDebug(MAIN)
                              << it.value() << "is no more a direct chat with"
                              << it.key()->id();
                      }

                  DirectChatsMap remoteAdditions;
                  for (auto it = usersToDCs.begin(); it != usersToDCs.end();
                           ++it) {
                      if (auto* u = user(it.key())) {
                          if (!d->directChats.contains(u, it.value())
                              && !d->dcLocalRemovals.contains(u, it.value()))
                          {
                              Q_ASSERT(
                                  !d->directChatUsers.contains(it.value(), u));
                              remoteAdditions.insert(u, it.value());
                              d->directChats.insert(u, it.value());
                              d->directChatUsers.insert(it.value(), u);
                              qCDebug(MAIN)
                                      << "Marked room" << it.value()
                                      << "as a direct chat with" << u->id();
                          }
                      } else
                          qCWarning(MAIN) << "Couldn't get a user object for"
                                          << it.key();
                  }
                  // Remove from dcLocalAdditions what the server already has.
                  erase_if(d->dcLocalAdditions, [&remoteAdditions](auto it) {
                      return remoteAdditions.contains(it.key(), it.value());
                  });
                  if (!remoteAdditions.isEmpty() || !remoteRemovals.isEmpty())
                      emit directChatsListChanged(remoteAdditions, remoteRemovals);
              },
              // catch-all, passing eventPtr for a possible take-over
              [this, &eventPtr](const Event& accountEvent) {
                  if (is<IgnoredUsersEvent>(accountEvent))
                      qCDebug(MAIN)
                              << "Users ignored by" << d->userId << "updated:"
                              << QStringList::fromSet(ignoredUsers()).join(',');

                  auto& currentData = d->accountData[accountEvent.matrixType()];
                  // A polymorphic event-specific comparison might be a bit more
                  // efficient; maaybe do it another day
                  if (!currentData
                      || currentData->contentJson()
                              != accountEvent.contentJson()) {
                      currentData = std::move(eventPtr);
                      qCDebug(MAIN) << "Updated account data of type"
                                    << currentData->matrixType();
                      emit accountDataChanged(currentData->matrixType());
                  }
              });
    }
    if (!d->dcLocalAdditions.isEmpty() || !d->dcLocalRemovals.isEmpty()) {
        qDebug(MAIN) << "Sending updated direct chats to the server:"
                     << d->dcLocalRemovals.size() << "removal(s),"
                     << d->dcLocalAdditions.size() << "addition(s)";
        callApi<SetAccountDataJob>(d->userId, QStringLiteral("m.direct"),
                                   toJson(d->directChats));
        d->dcLocalAdditions.clear();
        d->dcLocalRemovals.clear();
    }
}

void Connection::stopSync()
{
    // If there's a sync loop, break it
    disconnect(this, &Connection::syncDone,
               this, &Connection::syncLoopIteration);
    if (d->syncJob) // If there's an ongoing sync job, stop it too
    {
        d->syncJob->abandon();
        d->syncJob = nullptr;
    }
}

QString Connection::nextBatchToken() const
{
    return d->data->lastEvent();
}

PostReceiptJob* Connection::postReceipt(Room* room, RoomEvent* event) const
{
    return callApi<PostReceiptJob>(room->id(), "m.read", event->id());
}

JoinRoomJob* Connection::joinRoom(const QString& roomAlias,
                                  const QStringList& serverNames)
{
    auto job = callApi<JoinRoomJob>(roomAlias, serverNames);
    // Upon completion, ensure a room object in Join state is created but only
    // if it's not already there due to a sync completing earlier.
    connect(job, &JoinRoomJob::success,
            this, [this, job] { provideRoom(job->roomId()); });
    return job;
}

LeaveRoomJob* Connection::leaveRoom(Room* room)
{
    const auto& roomId = room->id();
    const auto job = callApi<LeaveRoomJob>(roomId);
    if (room->joinState() == JoinState::Invite)
    {
        // Workaround matrix-org/synapse#2181 - if the room is in invite state
        // the invite may have been cancelled but Synapse didn't send it in
        // `/sync`. See also #273 for the discussion in the library context.
        d->pendingStateRoomIds.push_back(roomId);
        connect(job, &LeaveRoomJob::success, this, [this,roomId] {
            if (d->pendingStateRoomIds.removeOne(roomId))
            {
                qCDebug(MAIN) << "Forcing the room to Leave status";
                provideRoom(roomId, JoinState::Leave);
            }
        });
    }
    return job;
}

inline auto splitMediaId(const QString& mediaId)
{
    auto idParts = mediaId.split('/');
    Q_ASSERT_X(idParts.size() == 2, __FUNCTION__,
               ("'" + mediaId +
                "' doesn't look like 'serverName/localMediaId'").toLatin1());
    return idParts;
}

MediaThumbnailJob* Connection::getThumbnail(const QString& mediaId,
    QSize requestedSize, RunningPolicy policy) const
{
    auto idParts = splitMediaId(mediaId);
    return callApi<MediaThumbnailJob>(policy,
        idParts.front(), idParts.back(), requestedSize);
}

MediaThumbnailJob* Connection::getThumbnail(const QUrl& url,
    QSize requestedSize, RunningPolicy policy) const
{
    return getThumbnail(url.authority() + url.path(), requestedSize, policy);
}

MediaThumbnailJob* Connection::getThumbnail(const QUrl& url,
    int requestedWidth, int requestedHeight, RunningPolicy policy) const
{
    return getThumbnail(url, QSize(requestedWidth, requestedHeight), policy);
}

UploadContentJob* Connection::uploadContent(QIODevice* contentSource,
        const QString& filename, const QString& overrideContentType) const
{
    auto contentType = overrideContentType;
    if (contentType.isEmpty())
    {
        contentType =
            QMimeDatabase().mimeTypeForFileNameAndData(filename, contentSource)
            .name();
        contentSource->open(QIODevice::ReadOnly);
    }
    return callApi<UploadContentJob>(contentSource, filename, contentType);
}

UploadContentJob* Connection::uploadFile(const QString& fileName,
                                         const QString& overrideContentType)
{
    auto sourceFile = new QFile(fileName);
    if (!sourceFile->open(QIODevice::ReadOnly))
    {
        qCWarning(MAIN) << "Couldn't open" << sourceFile->fileName()
                        << "for reading";
        return nullptr;
    }
    return uploadContent(sourceFile, QFileInfo(*sourceFile).fileName(),
                         overrideContentType);
}

GetContentJob* Connection::getContent(const QString& mediaId) const
{
    auto idParts = splitMediaId(mediaId);
    return callApi<GetContentJob>(idParts.front(), idParts.back());
}

GetContentJob* Connection::getContent(const QUrl& url) const
{
    return getContent(url.authority() + url.path());
}

DownloadFileJob* Connection::downloadFile(const QUrl& url,
                                          const QString& localFilename) const
{
    auto mediaId = url.authority() + url.path();
    auto idParts = splitMediaId(mediaId);
    auto* job = callApi<DownloadFileJob>(idParts.front(), idParts.back(),
                                         localFilename);
    return job;
}

CreateRoomJob* Connection::createRoom(RoomVisibility visibility,
    const QString& alias, const QString& name, const QString& topic,
    QStringList invites, const QString& presetName,
    const QString& roomVersion, bool isDirect,
    const QVector<CreateRoomJob::StateEvent>& initialState,
    const QVector<CreateRoomJob::Invite3pid>& invite3pids,
    const QJsonObject& creationContent)
{
    invites.removeOne(d->userId); // The creator is by definition in the room
    auto job = callApi<CreateRoomJob>(
            visibility == PublishRoom ? QStringLiteral("public")
                                      : QStringLiteral("private"),
            alias, name, topic, invites, invite3pids, roomVersion,
            creationContent, initialState, presetName, isDirect);
    connect(job, &BaseJob::success, this, [this,job,invites,isDirect] {
        auto* room = provideRoom(job->roomId(), JoinState::Join);
        if (!room)
        {
            Q_ASSERT_X(room, "Connection::createRoom", "Failed to create a room");
            return;
        }
        emit createdRoom(room);
        if (isDirect)
            for (const auto& i: invites)
                addToDirectChats(room, user(i));
    });
    return job;
}

void Connection::requestDirectChat(const QString& userId)
{
    if (auto* u = user(userId))
        requestDirectChat(u);
    else
        qCCritical(MAIN)
            << "Connection::requestDirectChat: Couldn't get a user object for"
            << userId;
}

void Connection::requestDirectChat(User* u)
{
    doInDirectChat(u, [this] (Room* r) { emit directChatAvailable(r); });
}

void Connection::doInDirectChat(const QString& userId,
                                const std::function<void(Room*)>& operation)
{
    if (auto* u = user(userId))
        doInDirectChat(u, operation);
    else
        qCCritical(MAIN)
            << "Connection::doInDirectChat: Couldn't get a user object for"
            << userId;
}

void Connection::doInDirectChat(User* u,
                                const std::function<void(Room*)>& operation)
{
    Q_ASSERT(u);
    const auto& userId = u->id();
    // There can be more than one DC; find the first valid (existing and
    // not left), and delete inexistent (forgotten?) ones along the way.
    DirectChatsMap removals;
    for (auto it = d->directChats.find(u);
         it != d->directChats.end() && it.key() == u; ++it)
    {
        const auto& roomId = *it;
        if (auto r = room(roomId, JoinState::Join))
        {
            Q_ASSERT(r->id() == roomId);
            // A direct chat with yourself should only involve yourself :)
            if (userId == d->userId && r->totalMemberCount() > 1)
                continue;
            qCDebug(MAIN) << "Requested direct chat with" << userId
                          << "is already available as" << r->id();
            operation(r);
            return;
        }
        if (auto ir = invitation(roomId))
        {
            Q_ASSERT(ir->id() == roomId);
            auto j = joinRoom(ir->id());
            connect(j, &BaseJob::success, this, [this,roomId,userId,operation] {
                qCDebug(MAIN) << "Joined the already invited direct chat with"
                              << userId << "as" << roomId;
                operation(room(roomId, JoinState::Join));
            });
            return;
        }
        // Avoid reusing previously left chats but don't remove them
        // from direct chat maps, either.
        if (room(roomId, JoinState::Leave))
            continue;

        qCWarning(MAIN) << "Direct chat with" << userId << "known as room"
                        << roomId << "is not valid and will be discarded";
        // Postpone actual deletion until we finish iterating d->directChats.
        removals.insert(it.key(), it.value());
        // Add to the list of updates to send to the server upon the next sync.
        d->dcLocalRemovals.insert(it.key(), it.value());
    }
    if (!removals.isEmpty())
    {
        for (auto it = removals.cbegin(); it != removals.cend(); ++it)
        {
            d->directChats.remove(it.key(), it.value());
            d->directChatUsers.remove(it.value(),
                                      const_cast<User*>(it.key())); // FIXME
        }
        emit directChatsListChanged({}, removals);
    }

    auto j = createDirectChat(userId);
    connect(j, &BaseJob::success, this, [this,j,userId,operation] {
        qCDebug(MAIN) << "Direct chat with" << userId
                      << "has been created as" << j->roomId();
        operation(room(j->roomId(), JoinState::Join));
    });

}

CreateRoomJob* Connection::createDirectChat(const QString& userId,
    const QString& topic, const QString& name)
{
    return createRoom(UnpublishRoom, {}, name, topic, {userId},
                      QStringLiteral("trusted_private_chat"), {}, true);
}

ForgetRoomJob* Connection::forgetRoom(const QString& id)
{
    // To forget is hard :) First we should ensure the local user is not
    // in the room (by leaving it, if necessary); once it's done, the /forget
    // endpoint can be called; and once this is through, the local Room object
    // (if any existed) is deleted. At the same time, we still have to
    // (basically immediately) return a pointer to ForgetRoomJob. Therefore
    // a ForgetRoomJob is created in advance and can be returned in a probably
    // not-yet-started state (it will start once /leave completes).
    auto forgetJob = new ForgetRoomJob(id);
    auto room = d->roomMap.value({id, false});
    if (!room)
        room = d->roomMap.value({id, true});
    if (room && room->joinState() != JoinState::Leave)
    {
        auto leaveJob = room->leaveRoom();
        connect(leaveJob, &BaseJob::success, this, [this, forgetJob, room] {
            forgetJob->start(connectionData());
            // If the matching /sync response hasn't arrived yet, mark the room
            // for explicit deletion
            if (room->joinState() != JoinState::Leave)
                d->roomIdsToForget.push_back(room->id());
        });
        connect(leaveJob, &BaseJob::failure, forgetJob, &BaseJob::abandon);
    }
    else
        forgetJob->start(connectionData());
    connect(forgetJob, &BaseJob::success, this, [this, id]
    {
        // Delete whatever instances of the room are still in the map.
        for (auto f: {false, true})
            if (auto r = d->roomMap.take({ id, f }))
            {
                qCDebug(MAIN) << "Room" << r->objectName()
                              << "in state" << toCString(r->joinState())
                              << "will be deleted";
                emit r->beforeDestruction(r);
                r->deleteLater();
            }
    });
    return forgetJob;
}

SendToDeviceJob* Connection::sendToDevices(const QString& eventType,
        const UsersToDevicesToEvents& eventsMap) const
{
    QHash<QString, QHash<QString, QJsonObject>> json;
    json.reserve(int(eventsMap.size()));
    std::for_each(eventsMap.begin(), eventsMap.end(),
        [&json] (const auto& userTodevicesToEvents) {
            auto& jsonUser = json[userTodevicesToEvents.first];
            const auto& devicesToEvents = userTodevicesToEvents.second;
            std::for_each(devicesToEvents.begin(), devicesToEvents.end(),
                [&jsonUser] (const auto& deviceToEvents) {
                    jsonUser.insert(deviceToEvents.first,
                                    deviceToEvents.second.contentJson());
                });
        });
    return callApi<SendToDeviceJob>(BackgroundRequest,
                eventType, generateTxnId(), json);
}

SendMessageJob* Connection::sendMessage(const QString& roomId,
                                        const RoomEvent& event) const
{
    const auto txnId = event.transactionId().isEmpty()
                            ? generateTxnId() : event.transactionId();
    return callApi<SendMessageJob>(roomId, event.matrixType(),
                                   txnId, event.contentJson());
}

QUrl Connection::homeserver() const
{
    return d->data->baseUrl();
}

QString Connection::domain() const
{
    return d->userId.section(':', 1);
}

QVector<GetLoginFlowsJob::LoginFlow> Connection::loginFlows() const
{
    return d->loginFlows;
}

bool Connection::supportsPasswordAuth() const
{
    return d->loginFlows.contains(LoginFlows::Password);
}

bool Connection::supportsSso() const
{
    return d->loginFlows.contains(LoginFlows::SSO);
}

Room* Connection::room(const QString& roomId, JoinStates states) const
{
    Room* room = d->roomMap.value({roomId, false}, nullptr);
    if (states.testFlag(JoinState::Join) &&
            room && room->joinState() == JoinState::Join)
        return room;

    if (states.testFlag(JoinState::Invite))
        if (Room* invRoom = invitation(roomId))
            return invRoom;

    if (states.testFlag(JoinState::Leave) &&
            room && room->joinState() == JoinState::Leave)
        return room;

    return nullptr;
}

Room* Connection::roomByAlias(const QString& roomAlias, JoinStates states) const
{
    const auto id = d->roomAliasMap.value(roomAlias);
    if (!id.isEmpty())
        return room(id, states);
    qCWarning(MAIN) << "Room for alias" << roomAlias
                    << "is not found under account" << userId();
    return nullptr;
}

void Connection::updateRoomAliases(const QString& roomId,
                                   const QStringList& previousRoomAliases,
                                   const QStringList& roomAliases)
{
    for (const auto& a: previousRoomAliases)
        if (d->roomAliasMap.remove(a) == 0)
            qCWarning(MAIN) << "Alias" << a << "is not found (already deleted?)";

    for (const auto& a: roomAliases)
    {
        auto& mappedId = d->roomAliasMap[a];
        if (!mappedId.isEmpty())
        {
            if (mappedId == roomId)
                qCDebug(MAIN) << "Alias" << a << "is already mapped to room"
                              << roomId;
            else
                qCWarning(MAIN) << "Alias" << a
                                << "will be force-remapped from room"
                                << mappedId << "to" << roomId;
        }
        mappedId = roomId;
    }
}

Room* Connection::invitation(const QString& roomId) const
{
    return d->roomMap.value({roomId, true}, nullptr);
}

User* Connection::user(const QString& userId)
{
    if (userId.isEmpty())
        return nullptr;
    if (!userId.startsWith('@') || !userId.contains(':'))
    {
        qCCritical(MAIN) << "Malformed userId:" << userId;
        return nullptr;
    }
    if( d->userMap.contains(userId) )
        return d->userMap.value(userId);
    auto* user = userFactory()(this, userId);
    d->userMap.insert(userId, user);
    emit newUser(user);
    return user;
}

const User* Connection::user() const
{
    return d->userMap.value(d->userId, nullptr);
}

User* Connection::user()
{
    return user(d->userId);
}

QString Connection::userId() const
{
    return d->userId;
}

QString Connection::deviceId() const
{
    return d->data->deviceId();
}

QString Connection::token() const
{
    return accessToken();
}

QByteArray Connection::accessToken() const
{
    return d->data->accessToken();
}

SyncJob* Connection::syncJob() const
{
    return d->syncJob;
}

int Connection::millisToReconnect() const
{
    return d->syncJob ? d->syncJob->millisToRetry() : 0;
}

QHash< QPair<QString, bool>, Room* > Connection::roomMap() const
{
    // Copy-on-write-and-remove-elements is faster than copying elements one by one.
    QHash< QPair<QString, bool>, Room* > roomMap = d->roomMap;
    for (auto it = roomMap.begin(); it != roomMap.end(); )
    {
        if (it.value()->joinState() == JoinState::Leave)
            it = roomMap.erase(it);
        else
            ++it;
    }
    return roomMap;
}

QVector<Room*> Connection::allRooms() const
{
    QVector<Room*> result;
    result.resize(d->roomMap.size());
    std::copy(d->roomMap.cbegin(), d->roomMap.cend(), result.begin());
    return result;
}

QVector<Room*> Connection::rooms(JoinStates joinStates) const
{
    QVector<Room*> result;
    for (auto* r: qAsConst(d->roomMap))
        if (joinStates.testFlag(r->joinState()))
            result.push_back(r);
    return result;
}

int Connection::roomsCount(JoinStates joinStates) const
{
    // Using int to maintain compatibility with QML
    // (consider also that QHash<>::size() returns int anyway).
    return int(std::count_if(d->roomMap.begin(), d->roomMap.end(),
                             [joinStates](Room* r) {
                                 return joinStates.testFlag(r->joinState());
                             }));
}

bool Connection::hasAccountData(const QString& type) const
{
    return d->accountData.find(type) != d->accountData.cend();
}

const EventPtr& Connection::accountData(const QString& type) const
{
    static EventPtr NoEventPtr {};
    auto it = d->accountData.find(type);
    return it == d->accountData.end() ? NoEventPtr : it->second;
}

QJsonObject Connection::accountDataJson(const QString& type) const
{
    const auto& eventPtr = accountData(type);
    return eventPtr ? eventPtr->contentJson() : QJsonObject();
}

void Connection::setAccountData(EventPtr&& event)
{
    d->packAndSendAccountData(std::move(event));
}

void Connection::setAccountData(const QString& type, const QJsonObject& content)
{
    d->packAndSendAccountData(loadEvent<Event>(type, content));
}

QHash<QString, QVector<Room*>> Connection::tagsToRooms() const
{
    QHash<QString, QVector<Room*>> result;
    for (auto* r: qAsConst(d->roomMap))
    {
        const auto& tagNames = r->tagNames();
        for (const auto& tagName: tagNames)
            result[tagName].push_back(r);
    }
    for (auto it = result.begin(); it != result.end(); ++it)
        std::sort(it->begin(), it->end(),
            [t=it.key()] (Room* r1, Room* r2) {
                return r1->tags().value(t) < r2->tags().value(t);
            });
    return result;
}

QStringList Connection::tagNames() const
{
    QStringList tags ({FavouriteTag});
    for (auto* r: qAsConst(d->roomMap))
    {
        const auto& tagNames = r->tagNames();
        for (const auto& tag: tagNames)
            if (tag != LowPriorityTag && !tags.contains(tag))
                tags.push_back(tag);
    }
    tags.push_back(LowPriorityTag);
    return tags;
}

QVector<Room*> Connection::roomsWithTag(const QString& tagName) const
{
    QVector<Room*> rooms;
    std::copy_if(d->roomMap.begin(), d->roomMap.end(), std::back_inserter(rooms),
                 [&tagName] (Room* r) { return r->tags().contains(tagName); });
    return rooms;
}

Connection::DirectChatsMap Connection::directChats() const
{
    return d->directChats;
}

void Connection::addToDirectChats(const Room* room, User* user)
{
    Q_ASSERT(room != nullptr && user != nullptr);
    if (d->directChats.contains(user, room->id()))
        return;
    Q_ASSERT(!d->directChatUsers.contains(room->id(), user));
    d->directChats.insert(user, room->id());
    d->directChatUsers.insert(room->id(), user);
    d->dcLocalAdditions.insert(user, room->id());
    emit directChatsListChanged({ { user, room->id() } }, {});
}

void Connection::removeFromDirectChats(const QString& roomId, User* user)
{
    Q_ASSERT(!roomId.isEmpty());
    if ((user != nullptr && !d->directChats.contains(user, roomId)) ||
            d->directChats.key(roomId) == nullptr)
        return;

    DirectChatsMap removals;
    if (user != nullptr)
    {
        d->directChats.remove(user, roomId);
        d->directChatUsers.remove(roomId, user);
        removals.insert(user, roomId);
        d->dcLocalRemovals.insert(user, roomId);
    } else {
        removals = erase_if(d->directChats,
                            [&roomId] (auto it) { return it.value() == roomId; });
        d->directChatUsers.remove(roomId);
        d->dcLocalRemovals += removals;
    }
    emit directChatsListChanged({}, removals);
}

bool Connection::isDirectChat(const QString& roomId) const
{
    return d->directChatUsers.contains(roomId);
}

QList<User*> Connection::directChatUsers(const Room* room) const
{
    Q_ASSERT(room != nullptr);
    return d->directChatUsers.values(room->id());
}

bool Connection::isIgnored(const User* user) const
{
    return ignoredUsers().contains(user->id());
}

Connection::IgnoredUsersList Connection::ignoredUsers() const
{
    const auto* event = d->unpackAccountData<IgnoredUsersEvent>();
    return event ? event->ignored_users() : IgnoredUsersList();
}

void Connection::addToIgnoredUsers(const User* user)
{
    Q_ASSERT(user != nullptr);

    auto ignoreList = ignoredUsers();
    if (!ignoreList.contains(user->id()))
    {
        ignoreList.insert(user->id());
        d->packAndSendAccountData<IgnoredUsersEvent>(ignoreList);
        emit ignoredUsersListChanged({{ user->id() }}, {});
    }
}

void Connection::removeFromIgnoredUsers(const User* user)
{
    Q_ASSERT(user != nullptr);

    auto ignoreList = ignoredUsers();
    if (ignoreList.remove(user->id()) != 0)
    {
        d->packAndSendAccountData<IgnoredUsersEvent>(ignoreList);
        emit ignoredUsersListChanged({}, {{ user->id() }});
    }
}

QMap<QString, User*> Connection::users() const
{
    return d->userMap;
}

const ConnectionData* Connection::connectionData() const
{
    return d->data.get();
}

Room* Connection::provideRoom(const QString& id, Omittable<JoinState> joinState)
{
    // TODO: This whole function is a strong case for a RoomManager class.
    Q_ASSERT_X(!id.isEmpty(), __FUNCTION__, "Empty room id");

    // If joinState.omitted(), all joinState == comparisons below are false.
    const auto roomKey = qMakePair(id, joinState == JoinState::Invite);
    auto* room = d->roomMap.value(roomKey, nullptr);
    if (room)
    {
        // Leave is a special case because in transition (5a) (see the .h file)
        // joinState == room->joinState but we still have to preempt the Invite
        // and emit a signal. For Invite and Join, there's no such problem.
        if (room->joinState() == joinState && joinState != JoinState::Leave)
            return room;
    } else if (joinState.omitted())
    {
        // No Join and Leave, maybe Invite?
        room = d->roomMap.value({id, true}, nullptr);
        if (room)
            return room;
        // No Invite either, setup a new room object below
    }

    if (!room)
    {
        room = roomFactory()(this, id,
                joinState.omitted() ? JoinState::Join : joinState.value());
        if (!room)
        {
            qCCritical(MAIN) << "Failed to create a room" << id;
            return nullptr;
        }
        d->roomMap.insert(roomKey, room);
        d->firstTimeRooms.push_back(room);
        connect(room, &Room::beforeDestruction,
                this, &Connection::aboutToDeleteRoom);
        emit newRoom(room);
    }
    if (joinState.omitted())
        return room;

    if (joinState == JoinState::Invite)
    {
        // prev is either Leave or nullptr
        auto* prev = d->roomMap.value({id, false}, nullptr);
        emit invitedRoom(room, prev);
    }
    else
    {
        room->setJoinState(joinState.value());
        // Preempt the Invite room (if any) with a room in Join/Leave state.
        auto* prevInvite = d->roomMap.take({id, true});
        if (joinState == JoinState::Join)
            emit joinedRoom(room, prevInvite);
        else if (joinState == JoinState::Leave)
            emit leftRoom(room, prevInvite);
        if (prevInvite)
        {
            const auto dcUsers = prevInvite->directChatUsers();
            for (auto* u: dcUsers)
                addToDirectChats(room, u);
            qCDebug(MAIN) << "Deleting Invite state for room" << prevInvite->id();
            emit prevInvite->beforeDestruction(prevInvite);
            prevInvite->deleteLater();
        }
    }

    return room;
}

void Connection::setRoomFactory(room_factory_t f)
{
    _roomFactory = std::move(f);
}

void Connection::setUserFactory(user_factory_t f)
{
    _userFactory = std::move(f);
}

room_factory_t Connection::roomFactory()
{
    return _roomFactory;
}

user_factory_t Connection::userFactory()
{
    return _userFactory;
}

room_factory_t Connection::_roomFactory = defaultRoomFactory<>();
user_factory_t Connection::_userFactory = defaultUserFactory<>();

QByteArray Connection::generateTxnId() const
{
    return d->data->generateTxnId();
}

void Connection::setHomeserver(const QUrl& url)
{
    if (homeserver() != url) {
        d->data->setBaseUrl(url);
        d->loginFlows.clear();
        emit homeserverChanged(homeserver());
    }

    // Whenever a homeserver is updated, retrieve available login flows from it
    auto* j = callApi<GetLoginFlowsJob>(BackgroundRequest);
    connect(j, &BaseJob::finished, this, [this, j] {
        if (j->status().good())
            d->loginFlows = j->flows();
        else
            d->loginFlows.clear();
        emit loginFlowsChanged();
    });
}

void Connection::saveRoomState(Room* r) const
{
    Q_ASSERT(r);
    if (!d->cacheState)
        return;

    QFile outRoomFile { stateCachePath() % SyncData::fileNameForRoom(r->id()) };
    if (outRoomFile.open(QFile::WriteOnly))
    {
        QJsonDocument json { r->toJson() };
        auto data = d->cacheToBinary ? json.toBinaryData()
                                     : json.toJson(QJsonDocument::Compact);
        outRoomFile.write(data.data(), data.size());
        qCDebug(MAIN) << "Room state cache saved to" << outRoomFile.fileName();
    } else {
        qCWarning(MAIN) << "Error opening" << outRoomFile.fileName()
                        << ":" << outRoomFile.errorString();
    }
}

void Connection::saveState() const
{
    if (!d->cacheState)
        return;

    QElapsedTimer et; et.start();

    QFile outFile { stateCachePath() % "state.json" };
    if (!outFile.open(QFile::WriteOnly))
    {
        qCWarning(MAIN) << "Error opening" << outFile.fileName()
                        << ":" << outFile.errorString();
        qCWarning(MAIN) << "Caching the rooms state disabled";
        d->cacheState = false;
        return;
    }

    QJsonObject rootObj {
        { QStringLiteral("cache_version"), QJsonObject {
            { QStringLiteral("major"), SyncData::cacheVersion().first },
            { QStringLiteral("minor"), SyncData::cacheVersion().second }
    }}};
    {
        QJsonObject roomsJson;
        QJsonObject inviteRoomsJson;
        for (const auto* r: qAsConst(d->roomMap)) {
            if (r->joinState() == JoinState::Leave)
                continue;
            (r->joinState() == JoinState::Invite ? inviteRoomsJson : roomsJson)
                .insert(r->id(), QJsonValue::Null);
        }

        QJsonObject roomObj;
        if (!roomsJson.isEmpty())
            roomObj.insert(QStringLiteral("join"), roomsJson);
        if (!inviteRoomsJson.isEmpty())
            roomObj.insert(QStringLiteral("invite"), inviteRoomsJson);

        rootObj.insert(QStringLiteral("next_batch"), d->data->lastEvent());
        rootObj.insert(QStringLiteral("rooms"), roomObj);
    }
    {
        QJsonArray accountDataEvents {
            basicEventJson(QStringLiteral("m.direct"), toJson(d->directChats))
        };
        for (const auto &e : d->accountData)
            accountDataEvents.append(
                basicEventJson(e.first, e.second->contentJson()));

        rootObj.insert(QStringLiteral("account_data"),
            QJsonObject {{ QStringLiteral("events"), accountDataEvents }});
    }

    QJsonDocument json { rootObj };
    auto data = d->cacheToBinary ? json.toBinaryData() :
                                   json.toJson(QJsonDocument::Compact);
    qCDebug(PROFILER) << "Cache for" << userId() << "generated in" << et;

    outFile.write(data.data(), data.size());
    qCDebug(MAIN) << "State cache saved to" << outFile.fileName();
}

void Connection::loadState()
{
    if (!d->cacheState)
        return;

    QElapsedTimer et; et.start();

    SyncData sync { stateCachePath() % "state.json" };
    if (sync.nextBatch().isEmpty()) // No token means no cache by definition
        return;

    if (!sync.unresolvedRooms().isEmpty())
    {
        qCWarning(MAIN) << "State cache incomplete, discarding";
        return;
    }
    // TODO: to handle load failures, instead of the above block:
    // 1. Do initial sync on failed rooms without saving the nextBatch token
    // 2. Do the sync across all rooms as normal
    onSyncSuccess(std::move(sync), true);
    qCDebug(PROFILER) << "*** Cached state for" << userId() << "loaded in" << et;
}

QString Connection::stateCachePath() const
{
    auto safeUserId = userId();
    safeUserId.replace(':', '_');
    return cacheLocation(safeUserId);
}

bool Connection::cacheState() const
{
    return d->cacheState;
}

void Connection::setCacheState(bool newValue)
{
    if (d->cacheState != newValue)
    {
        d->cacheState = newValue;
        emit cacheStateChanged();
    }
}

bool QMatrixClient::Connection::lazyLoading() const
{
    return d->lazyLoading;
}

void QMatrixClient::Connection::setLazyLoading(bool newValue)
{
    if (d->lazyLoading != newValue)
    {
        d->lazyLoading = newValue;
        emit lazyLoadingChanged();
    }
}

void Connection::getTurnServers()
{
    auto job = callApi<GetTurnServerJob>();
    connect(job, &GetTurnServerJob::success,
            this, [=] { emit turnServersChanged(job->data()); });
}

const QString Connection::SupportedRoomVersion::StableTag =
        QStringLiteral("stable");

QString Connection::defaultRoomVersion() const
{
    Q_ASSERT(!d->capabilities.roomVersions.omitted());
    return d->capabilities.roomVersions->defaultVersion;
}

QStringList Connection::stableRoomVersions() const
{
    Q_ASSERT(!d->capabilities.roomVersions.omitted());
    QStringList l;
    const auto& allVersions = d->capabilities.roomVersions->available;
    for (auto it = allVersions.begin(); it != allVersions.end(); ++it)
        if (it.value() == SupportedRoomVersion::StableTag)
            l.push_back(it.key());
    return l;
}

inline bool roomVersionLess(const Connection::SupportedRoomVersion& v1,
                            const Connection::SupportedRoomVersion& v2)
{
    bool ok1 = false, ok2 = false;
    const auto vNum1 = v1.id.toFloat(&ok1);
    const auto vNum2 = v2.id.toFloat(&ok2);
    return ok1 && ok2 ? vNum1 < vNum2 : v1.id < v2.id;
}

QVector<Connection::SupportedRoomVersion> Connection::availableRoomVersions() const
{
    Q_ASSERT(!d->capabilities.roomVersions.omitted());
    QVector<SupportedRoomVersion> result;
    result.reserve(d->capabilities.roomVersions->available.size());
    for (auto it = d->capabilities.roomVersions->available.begin();
         it != d->capabilities.roomVersions->available.end(); ++it)
        result.push_back({ it.key(), it.value() });
    // Put stable versions over unstable; within each group,
    // sort numeric versions as numbers, the rest as strings.
    const auto mid = std::partition(result.begin(), result.end(),
                                    std::mem_fn(&SupportedRoomVersion::isStable));
    std::sort(result.begin(), mid, roomVersionLess);
    std::sort(mid, result.end(), roomVersionLess);

    return result;
}
