#include "modloaderversionservice.h"

#include "launcherpaths.h"

#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <QSaveFile>
#include <QTimer>
#include <QUrl>
#include <QXmlStreamReader>

#include <algorithm>
#include <utility>

namespace {

// Списки лоадеров меняются реже манифеста Mojang, но шесть часов держим те же:
// одна цифра на весь лаунчер понятнее двух разных.
constexpr qint64 kCacheTtlSeconds = 6 * 60 * 60;
constexpr int kTransferTimeoutMs = 20000;

const QString kFabricMeta = QStringLiteral("https://meta.fabricmc.net/v2/versions/loader/");
const QString kQuiltMeta = QStringLiteral("https://meta.quiltmc.org/v3/versions/loader/");

const QString kForgeMaven = QStringLiteral("https://maven.minecraftforge.net/net/minecraftforge/forge/");
const QString kForgePromotions =
    QStringLiteral("https://files.minecraftforge.net/maven/net/minecraftforge/forge/promotions_slim.json");

const QString kNeoForgeMaven = QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/neoforge/");
// Под 1.20.1 NeoForge выходил ещё под старым именем артефакта.
const QString kNeoForgeLegacyMaven =
    QStringLiteral("https://maven.neoforged.net/releases/net/neoforged/forge/");

bool looksUnstable(const QString &version)
{
    const QString lower = version.toLower();
    return lower.contains(QLatin1String("alpha")) || lower.contains(QLatin1String("beta"))
           || lower.contains(QLatin1String("-rc")) || lower.contains(QLatin1String("-pre"))
           || lower.contains(u'+');
}

// Сравнение версий по числовым компонентам: «47.4.23» новее «47.4.2», хотя как
// строка стоит раньше. Нечисловые хвосты (alpha, +snapshot) сравниваются как есть.
bool isNewer(const QString &a, const QString &b)
{
    const QStringList pa = a.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts);
    const QStringList pb = b.split(QRegularExpression(QStringLiteral("[.\\-+]")), Qt::SkipEmptyParts);
    for (int i = 0; i < std::max(pa.size(), pb.size()); ++i) {
        const QString sa = i < pa.size() ? pa.at(i) : QString();
        const QString sb = i < pb.size() ? pb.at(i) : QString();
        bool okA = false, okB = false;
        const int na = sa.toInt(&okA);
        const int nb = sb.toInt(&okB);
        if (okA && okB) {
            if (na != nb)
                return na > nb;
            continue;
        }
        // Число всегда «новее» суффикса: 26.1.0.0 идёт впереди 26.1.0.0-alpha.1.
        if (okA != okB)
            return okA;
        if (sa != sb)
            return sa > sb;
    }
    return false;
}

// «21.1.248» -> «1.21.1», «21.0.167» -> «1.21», «26.1.2.97» -> «26.1.2»,
// «26.2.0.68» -> «26.2». До 1.20.2 Minecraft нумеровался как 1.x.y, с 26.1 —
// как x.y, поэтому у NeoForge две схемы: три компонента и четыре.
QString neoForgeGameVersion(const QString &neoVersion)
{
    const QString head = neoVersion.section(u'-', 0, 0).section(u'+', 0, 0);
    const QStringList parts = head.split(u'.');
    if (parts.size() == 3)
        return parts.at(1) == QLatin1String("0") ? QStringLiteral("1.%1").arg(parts.at(0))
                                                 : QStringLiteral("1.%1.%2").arg(parts.at(0), parts.at(1));
    if (parts.size() >= 4)
        return parts.at(2) == QLatin1String("0")
                   ? QStringLiteral("%1.%2").arg(parts.at(0), parts.at(1))
                   : QStringLiteral("%1.%2.%3").arg(parts.at(0), parts.at(1), parts.at(2));
    return {};
}

QJsonObject entryToJson(const LoaderVersionEntry &entry)
{
    return {{QStringLiteral("loaderVersion"), entry.loaderVersion},
            {QStringLiteral("gameVersion"), entry.gameVersion},
            {QStringLiteral("versionId"), entry.versionId},
            {QStringLiteral("installerUrl"), entry.installerUrl.toString()},
            {QStringLiteral("recommended"), entry.recommended},
            {QStringLiteral("stable"), entry.stable}};
}

LoaderVersionEntry entryFromJson(const QJsonObject &o)
{
    LoaderVersionEntry entry;
    entry.loaderVersion = o.value(QStringLiteral("loaderVersion")).toString();
    entry.gameVersion = o.value(QStringLiteral("gameVersion")).toString();
    entry.versionId = o.value(QStringLiteral("versionId")).toString();
    entry.installerUrl = QUrl(o.value(QStringLiteral("installerUrl")).toString());
    entry.recommended = o.value(QStringLiteral("recommended")).toBool();
    entry.stable = o.value(QStringLiteral("stable")).toBool(true);
    return entry;
}

} // namespace

ModLoaderVersionService::ModLoaderVersionService(QObject *parent)
    : QObject(parent)
    , m_network(new QNetworkAccessManager(this))
{
}

bool ModLoaderVersionService::isPerGameVersion(ModLoader loader)
{
    return loader == ModLoader::Fabric || loader == ModLoader::Quilt;
}

ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader)
{
    return m_buckets[static_cast<int>(loader)];
}

const ModLoaderVersionService::Bucket &ModLoaderVersionService::bucket(ModLoader loader) const
{
    static const Bucket empty;
    const auto it = m_buckets.constFind(static_cast<int>(loader));
    return it == m_buckets.constEnd() ? empty : it.value();
}

QString ModLoaderVersionService::requestKey(ModLoader loader, const QString &gameVersion)
{
    // У Forge и NeoForge один запрос покрывает все версии игры — они делят
    // очередь колбэков, чтобы не качать maven-metadata по разу на версию.
    return isPerGameVersion(loader) ? loaderKey(loader) + u'/' + gameVersion : loaderKey(loader);
}

bool ModLoaderVersionService::isFresh(ModLoader loader, const QString &gameVersion) const
{
    const Bucket &b = bucket(loader);
    const QDateTime fetched = isPerGameVersion(loader)
                                  ? b.fetchedAtByGame.value(gameVersion)
                                  : b.fetchedAt;
    return fetched.isValid() && fetched.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
}

QList<LoaderVersionEntry> ModLoaderVersionService::versions(ModLoader loader,
                                                            const QString &gameVersion) const
{
    return bucket(loader).byGame.value(gameVersion);
}

bool ModLoaderVersionService::isRefreshing(ModLoader loader, const QString &gameVersion) const
{
    return m_inFlight.contains(requestKey(loader, gameVersion));
}

std::optional<LoaderVersionEntry> ModLoaderVersionService::find(ModLoader loader,
                                                                const QString &gameVersion,
                                                                const QString &loaderVersion) const
{
    const QList<LoaderVersionEntry> list = versions(loader, gameVersion);
    for (const LoaderVersionEntry &entry : list) {
        if (entry.loaderVersion == loaderVersion)
            return entry;
    }
    return std::nullopt;
}

void ModLoaderVersionService::ensureLoaded(ModLoader loader,
                                           const QString &gameVersion,
                                           Callback callback,
                                           bool forceRefresh)
{
    if (gameVersion.trimmed().isEmpty()) {
        if (callback)
            QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
        return;
    }

    Bucket &b = bucket(loader);
    if (!b.cacheRead) {
        b.cacheRead = true;
        if (readCache(loader))
            emit versionsChanged(loaderKey(loader), gameVersion);
    }

    if (!forceRefresh && isFresh(loader, gameVersion)) {
        // Колбэк, который то синхронный, то нет, ломает вызывающий код —
        // на попадании в кэш отвечаем через событийный цикл.
        if (callback)
            QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
        return;
    }

    const QString key = requestKey(loader, gameVersion);
    if (callback)
        m_pending[key] << std::move(callback);
    if (m_inFlight.contains(key))
        return;
    startRefresh(loader, gameVersion);
}

void ModLoaderVersionService::startRefresh(ModLoader loader, const QString &gameVersion)
{
    const QString key = requestKey(loader, gameVersion);
    m_inFlight.insert(key);
    emit refreshingChanged();

    const auto request = [](const QUrl &url) {
        QNetworkRequest req{url};
        req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
                         QNetworkRequest::NoLessSafeRedirectPolicy);
        req.setTransferTimeout(kTransferTimeoutMs);
        return req;
    };

    if (isPerGameVersion(loader)) {
        const QString base = loader == ModLoader::Fabric ? kFabricMeta : kQuiltMeta;
        QNetworkReply *reply = m_network->get(request(QUrl(base + gameVersion)));
        connect(reply, &QNetworkReply::finished, this, [this, reply, loader, gameVersion] {
            reply->deleteLater();
            if (reply->error() != QNetworkReply::NoError) {
                finishRefresh(loader, gameVersion, false,
                              tr("Не удалось получить список версий %1: %2")
                                  .arg(loaderTitle(loader), reply->errorString()));
                return;
            }

            const QByteArray payload = reply->readAll();
            // Пустой список — законный ответ: этот лоадер не поддерживает такую
            // версию игры. Отличаем его от испорченного ответа по самому JSON.
            const QJsonDocument document = QJsonDocument::fromJson(payload);
            if (!document.isArray()) {
                finishRefresh(loader, gameVersion, false,
                              tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader)));
                return;
            }

            Bucket &b = bucket(loader);
            b.byGame.insert(gameVersion, parseFabricLike(payload, loader, gameVersion));
            b.fetchedAtByGame.insert(gameVersion, QDateTime::currentDateTimeUtc());
            writeCache(loader);
            emit versionsChanged(loaderKey(loader), gameVersion);
            finishRefresh(loader, gameVersion, true, {});
        });
        return;
    }

    // Forge и NeoForge: основной maven-metadata плюс вспомогательный запрос
    // (рекомендованные сборки у Forge, старая схема 1.20.1 у NeoForge).
    // Считаем оба и сводим результат, когда придёт последний.
    struct Pending
    {
        int outstanding = 2;
        QHash<QString, QList<LoaderVersionEntry>> merged;
        QHash<QString, QString> recommended;   // версия игры -> версия лоадера
        bool primaryOk = false;
        QString warning;
    };
    auto state = std::make_shared<Pending>();

    const auto settle = [this, state, loader, gameVersion] {
        if (--state->outstanding > 0)
            return;
        if (!state->primaryOk) {
            finishRefresh(loader, gameVersion, false, state->warning);
            return;
        }

        for (auto it = state->merged.begin(); it != state->merged.end(); ++it) {
            const QString promoted = state->recommended.value(it.key());
            if (promoted.isEmpty())
                continue;
            for (LoaderVersionEntry &entry : it.value())
                entry.recommended = entry.loaderVersion == promoted;
        }

        Bucket &b = bucket(loader);
        b.byGame = state->merged;
        b.fetchedAt = QDateTime::currentDateTimeUtc();
        writeCache(loader);
        emit versionsChanged(loaderKey(loader), gameVersion);
        finishRefresh(loader, gameVersion, true, {});
    };

    const QString primaryUrl = loader == ModLoader::Forge ? kForgeMaven : kNeoForgeMaven;
    QNetworkReply *primary = m_network->get(request(QUrl(primaryUrl + QStringLiteral("maven-metadata.xml"))));
    connect(primary, &QNetworkReply::finished, this, [this, primary, state, loader, settle] {
        primary->deleteLater();
        if (primary->error() != QNetworkReply::NoError) {
            state->warning = tr("Не удалось получить список версий %1: %2")
                                 .arg(loaderTitle(loader), primary->errorString());
            settle();
            return;
        }
        auto parsed = parseMavenMetadata(primary->readAll(), loader, /*legacyNeoForge=*/false);
        if (parsed.isEmpty()) {
            state->warning = tr("Список версий %1 не удалось разобрать").arg(loaderTitle(loader));
            settle();
            return;
        }
        state->primaryOk = true;
        state->merged = std::move(parsed);
        settle();
    });

    if (loader == ModLoader::Forge) {
        QNetworkReply *promos = m_network->get(request(QUrl(kForgePromotions)));
        connect(promos, &QNetworkReply::finished, this, [promos, state, settle] {
            promos->deleteLater();
            if (promos->error() == QNetworkReply::NoError) {
                const QJsonObject root = QJsonDocument::fromJson(promos->readAll()).object();
                const QJsonObject promoted = root.value(QStringLiteral("promos")).toObject();
                for (auto it = promoted.constBegin(); it != promoted.constEnd(); ++it) {
                    if (!it.key().endsWith(QLatin1String("-recommended")))
                        continue;
                    state->recommended.insert(it.key().chopped(int(sizeof("-recommended") - 1)),
                                              it.value().toString());
                }
            }
            // Метка «рекомендовано» необязательна: без неё список всё равно рабочий.
            settle();
        });
    } else {
        QNetworkReply *legacy =
            m_network->get(request(QUrl(kNeoForgeLegacyMaven + QStringLiteral("maven-metadata.xml"))));
        connect(legacy, &QNetworkReply::finished, this, [legacy, state, settle] {
            legacy->deleteLater();
            if (legacy->error() == QNetworkReply::NoError) {
                const auto extra = parseMavenMetadata(legacy->readAll(), ModLoader::NeoForge,
                                                      /*legacyNeoForge=*/true);
                for (auto it = extra.constBegin(); it != extra.constEnd(); ++it)
                    state->merged[it.key()] += it.value();
            }
            settle();
        });
    }
}

void ModLoaderVersionService::finishRefresh(ModLoader loader,
                                            const QString &gameVersion,
                                            bool ok,
                                            const QString &warning)
{
    const QString key = requestKey(loader, gameVersion);
    m_inFlight.remove(key);
    emit refreshingChanged();

    // Колбэк может снова позвать ensureLoaded — список забираем себе заранее.
    const QList<Callback> pending = m_pending.take(key);
    const bool haveData = !bucket(loader).byGame.isEmpty();
    for (const Callback &callback : pending) {
        // Сеть недоступна, но старые данные есть — работаем на них.
        if (!ok && haveData)
            callback(true, warning);
        else
            callback(ok, warning);
    }
}

QList<LoaderVersionEntry> ModLoaderVersionService::parseFabricLike(const QByteArray &payload,
                                                                   ModLoader loader,
                                                                   const QString &gameVersion)
{
    const QString prefix = loader == ModLoader::Fabric ? QStringLiteral("fabric-loader-")
                                                       : QStringLiteral("quilt-loader-");
    QList<LoaderVersionEntry> list;
    const QJsonArray array = QJsonDocument::fromJson(payload).array();
    list.reserve(array.size());

    for (const QJsonValue &value : array) {
        const QJsonObject object = value.toObject().value(QStringLiteral("loader")).toObject();
        LoaderVersionEntry entry;
        entry.loaderVersion = object.value(QStringLiteral("version")).toString();
        if (entry.loaderVersion.isEmpty())
            continue;
        entry.gameVersion = gameVersion;
        entry.versionId = prefix + entry.loaderVersion + u'-' + gameVersion;
        entry.stable = object.value(QStringLiteral("stable")).toBool(!looksUnstable(entry.loaderVersion));
        list << entry;
    }

    // Fabric и Quilt отдают новые сверху, но зависеть от этого не стоит.
    std::stable_sort(list.begin(), list.end(),
                     [](const LoaderVersionEntry &a, const LoaderVersionEntry &b) {
                         return isNewer(a.loaderVersion, b.loaderVersion);
                     });
    return list;
}

QHash<QString, QList<LoaderVersionEntry>> ModLoaderVersionService::parseMavenMetadata(
    const QByteArray &xml, ModLoader loader, bool legacyNeoForge)
{
    // Версия игры зашита в саму строку версии лоадера, поэтому раскладка по
    // версиям игры — это и есть проверка совместимости: несовместимой пары в
    // получившемся отображении просто не существует.
    const bool splitOnDash = loader == ModLoader::Forge || legacyNeoForge;
    const QString base = loader == ModLoader::Forge ? kForgeMaven
                       : legacyNeoForge             ? kNeoForgeLegacyMaven
                                                    : kNeoForgeMaven;
    const QString artifact = loader == ModLoader::Forge ? QStringLiteral("forge")
                           : legacyNeoForge             ? QStringLiteral("forge")
                                                        : QStringLiteral("neoforge");

    QHash<QString, QList<LoaderVersionEntry>> byGame;
    QXmlStreamReader reader(xml);
    while (!reader.atEnd()) {
        if (reader.readNext() != QXmlStreamReader::StartElement)
            continue;
        if (reader.name() != QLatin1String("version"))
            continue;

        const QString full = reader.readElementText().trimmed();
        if (full.isEmpty())
            continue;

        LoaderVersionEntry entry;
        if (splitOnDash) {
            const int dash = full.indexOf(u'-');
            if (dash <= 0 || dash + 1 >= full.size())
                continue;
            entry.gameVersion = full.left(dash);
            entry.loaderVersion = full.mid(dash + 1);
        } else {
            entry.gameVersion = neoForgeGameVersion(full);
            entry.loaderVersion = full;
        }
        if (entry.gameVersion.isEmpty())
            continue;

        entry.stable = !looksUnstable(full);
        entry.installerUrl = QUrl(base + full + u'/' + artifact + u'-' + full
                                  + QStringLiteral("-installer.jar"));
        byGame[entry.gameVersion] << entry;
    }
    if (reader.hasError())
        return {};

    // maven-metadata приходит неотсортированным: 1.20.1-47.4.23 стоит между
    // версиями 26.x. Без сортировки «самой новой» оказалась бы случайная сборка.
    for (auto it = byGame.begin(); it != byGame.end(); ++it) {
        std::stable_sort(it.value().begin(), it.value().end(),
                         [](const LoaderVersionEntry &a, const LoaderVersionEntry &b) {
                             return isNewer(a.loaderVersion, b.loaderVersion);
                         });
    }
    return byGame;
}

bool ModLoaderVersionService::readCache(ModLoader loader)
{
    QFile file(LauncherPaths::loaderCacheFile(loaderKey(loader)));
    if (!file.open(QIODevice::ReadOnly))
        return false;

    const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
    const QJsonObject games = root.value(QStringLiteral("games")).toObject();
    if (games.isEmpty())
        return false;

    Bucket &b = bucket(loader);
    for (auto it = games.constBegin(); it != games.constEnd(); ++it) {
        const QJsonObject game = it.value().toObject();
        QList<LoaderVersionEntry> entries;
        const QJsonArray array = game.value(QStringLiteral("entries")).toArray();
        entries.reserve(array.size());
        for (const QJsonValue &value : array)
            entries << entryFromJson(value.toObject());
        b.byGame.insert(it.key(), entries);
        const QDateTime fetched =
            QDateTime::fromString(game.value(QStringLiteral("fetchedAt")).toString(), Qt::ISODate);
        if (fetched.isValid())
            b.fetchedAtByGame.insert(it.key(), fetched);
    }
    b.fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
                                        Qt::ISODate);
    return true;
}

void ModLoaderVersionService::writeCache(ModLoader loader)
{
    // Папку кэша могли удалить уже после запуска лаунчера.
    if (!LauncherPaths::ensureRootExists())
        return;

    const Bucket &b = bucket(loader);
    QJsonObject games;
    for (auto it = b.byGame.constBegin(); it != b.byGame.constEnd(); ++it) {
        QJsonArray entries;
        for (const LoaderVersionEntry &entry : it.value())
            entries << entryToJson(entry);
        games.insert(it.key(),
                     QJsonObject{{QStringLiteral("fetchedAt"),
                                  b.fetchedAtByGame.value(it.key()).toString(Qt::ISODate)},
                                 {QStringLiteral("entries"), entries}});
    }

    const QJsonObject root{{QStringLiteral("fetchedAt"), b.fetchedAt.toString(Qt::ISODate)},
                           {QStringLiteral("games"), games}};

    // QSaveFile: оборванная запись не оставит обрезанный кэш, который потом
    // пришлось бы отличать от настоящего.
    QSaveFile file(LauncherPaths::loaderCacheFile(loaderKey(loader)));
    if (!file.open(QIODevice::WriteOnly))
        return;
    const QByteArray payload = QJsonDocument(root).toJson(QJsonDocument::Compact);
    if (file.write(payload) != payload.size()) {
        file.cancelWriting();
        return;
    }
    file.commit();
}
