#include "seasonalbuildservice.h"

#include "launcherpaths.h"
#include "modloader.h"

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

#include <algorithm>
#include <utility>

namespace {

// Формат каталога, который понимает этот лаунчер. Сервер, отдающий версию
// новее, обслуживает лаунчер новее — молча показывать половину полей нельзя.
constexpr int kSupportedFormatVersion = 1;

// Каталог весит килобайты, а новая ревизия сборки должна доезжать до игроков
// быстро. Четверть часа — это и не поток запросов при каждом открытии окна, и
// не полдня ожидания после публикации; кнопка «Обновить список» ходит с force.
constexpr qint64 kCacheTtlSeconds = 15 * 60;

constexpr int kTransferTimeoutMs = 20000;

// id попадает в имена файлов и в сравнение с локальными записями, поэтому
// набор символов узкий и проверяется до всего остального.
bool isSafeCatalogId(const QString &id)
{
    static const QRegularExpression re(QStringLiteral("^[a-z0-9][a-z0-9._-]{0,63}$"));
    return re.match(id).hasMatch();
}

// Тот же запрет, что у идентификаторов версий в minecraftversion.cpp: строка
// становится именем папки в versions.
bool isSafeVersionId(const QString &id)
{
    if (id.isEmpty() || id.size() > 128)
        return false;
    if (id.contains(u'/') || id.contains(u'\\') || id.contains(u':'))
        return false;
    return id != QLatin1String(".") && id != QLatin1String("..");
}

bool isSha256Hex(const QString &value)
{
    if (value.size() != 64)
        return false;
    for (const QChar c : value) {
        if (!((c >= u'0' && c <= u'9') || (c >= u'a' && c <= u'f') || (c >= u'A' && c <= u'F')))
            return false;
    }
    return true;
}

// null или пустая строка — даты нет; в таблице на её месте прочерк.
QDate readDate(const QJsonValue &value)
{
    const QString text = value.toString();
    return text.isEmpty() ? QDate() : QDate::fromString(text, Qt::ISODate);
}

QString dateToJson(const QDate &date)
{
    return date.isValid() ? date.toString(Qt::ISODate) : QString();
}

} // namespace

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

std::optional<SeasonalBuildEntry> SeasonalBuildService::find(const QString &id) const
{
    const auto it = m_indexById.constFind(id);
    if (it == m_indexById.constEnd())
        return std::nullopt;
    return m_builds.at(it.value());
}

void SeasonalBuildService::setBaseUrl(const QUrl &baseUrl)
{
    if (m_baseUrl == baseUrl)
        return;

    m_baseUrl = baseUrl;
    // Ссылки на архивы привязаны к прежнему хосту, а проверка их происхождения
    // сверяется с базовым адресом — держать такие данные после смены сервера
    // нельзя.
    m_builds.clear();
    m_indexById.clear();
    m_fetchedAt = QDateTime();
    m_cacheRead = false;
    m_lastError.clear();
    emit buildsChanged();
}

void SeasonalBuildService::applyBuilds(QList<SeasonalBuildEntry> list)
{
    m_builds = std::move(list);
    m_indexById.clear();
    m_indexById.reserve(m_builds.size());
    for (int i = 0; i < m_builds.size(); ++i)
        m_indexById.insert(m_builds.at(i).id, i);
}

QList<SeasonalBuildEntry> SeasonalBuildService::parseCatalog(const QJsonObject &root,
                                                             QString *error) const
{
    const int format = root.value(QStringLiteral("formatVersion")).toInt();
    if (format != kSupportedFormatVersion) {
        if (error) {
            *error = format > kSupportedFormatVersion
                         ? tr("Сервер сборок отдаёт формат %1 — обновите лаунчер").arg(format)
                         : tr("Неизвестный формат каталога сборок (%1)").arg(format);
        }
        return {};
    }

    QList<SeasonalBuildEntry> list;
    const QJsonArray builds = root.value(QStringLiteral("builds")).toArray();
    list.reserve(builds.size());

    for (const QJsonValue &value : builds) {
        const QJsonObject object = value.toObject();

        SeasonalBuildEntry entry;
        entry.id = object.value(QStringLiteral("id")).toString();
        entry.name = object.value(QStringLiteral("name")).toString().trimmed();
        entry.revision = object.value(QStringLiteral("revision")).toInt();
        entry.minecraftVersion = object.value(QStringLiteral("minecraftVersion")).toString();
        entry.loader = object.value(QStringLiteral("loader")).toString();
        entry.loaderVersion = object.value(QStringLiteral("loaderVersion")).toString().trimmed();
        entry.modCount = object.value(QStringLiteral("modCount")).toInt();
        entry.seasonStart = readDate(object.value(QStringLiteral("seasonStart")));
        entry.seasonEnd = readDate(object.value(QStringLiteral("seasonEnd")));
        entry.serverUrl = object.value(QStringLiteral("serverUrl")).toString().trimmed();
        entry.javaMajor = object.value(QStringLiteral("javaMajor")).toInt();
        entry.description = object.value(QStringLiteral("description")).toString().trimmed();

        const QJsonObject archive = object.value(QStringLiteral("archive")).toObject();
        entry.archiveUrl = QUrl(archive.value(QStringLiteral("url")).toString());
        entry.archiveSize = qint64(archive.value(QStringLiteral("size")).toDouble());
        entry.archiveSha256 = archive.value(QStringLiteral("sha256")).toString().toLower();

        // Данные пришли из сети и превратятся в пути на диске и в запуск игры,
        // поэтому строка отбрасывается целиком при любой странности.
        if (!isSafeCatalogId(entry.id) || entry.name.isEmpty() || entry.revision <= 0)
            continue;
        if (!isSafeVersionId(entry.minecraftVersion))
            continue;
        if (!entry.loader.isEmpty() && !loaderFromKey(entry.loader))
            continue;
        // Загрузчик без версии установить нечем.
        if (!entry.loader.isEmpty() && entry.loaderVersion.isEmpty())
            continue;
        if (!isSha256Hex(entry.archiveSha256) || entry.archiveSize < 0)
            continue;
        // Ссылка обязана вести на тот же сервер, с которого пришёл каталог:
        // подменённый index.json иначе увёл бы загрузку куда угодно.
        if (entry.archiveUrl.scheme() != m_baseUrl.scheme()
            || entry.archiveUrl.host() != m_baseUrl.host()
            || entry.archiveUrl.port(-1) != m_baseUrl.port(-1)) {
            continue;
        }
        // http допустим только если сам адрес сервера задан по http — так
        // работает отладка против локального python3 -m http.server.
        if (entry.archiveUrl.scheme() != QLatin1String("https")
            && entry.archiveUrl.scheme() != QLatin1String("http")) {
            continue;
        }

        list << entry;
    }

    // Свежие сезоны сверху: сначала те, что ещё идут, потом по дате старта.
    std::stable_sort(list.begin(), list.end(),
                     [](const SeasonalBuildEntry &a, const SeasonalBuildEntry &b) {
                         const bool aOpen = !a.seasonEnd.isValid();
                         const bool bOpen = !b.seasonEnd.isValid();
                         if (aOpen != bOpen)
                             return aOpen;
                         if (a.seasonStart != b.seasonStart)
                             return a.seasonStart > b.seasonStart;
                         return a.name.localeAwareCompare(b.name) < 0;
                     });
    return list;
}

void SeasonalBuildService::ensureLoaded(Callback callback, bool forceRefresh)
{
    if (!m_baseUrl.isValid() || m_baseUrl.host().isEmpty()) {
        m_lastError = tr("Адрес сервера сборок не задан");
        if (callback) {
            const QString error = m_lastError;
            QTimer::singleShot(0, this, [callback = std::move(callback), error] {
                callback(false, error);
            });
        }
        return;
    }

    if (!m_cacheRead) {
        m_cacheRead = true;
        if (readCache())
            emit buildsChanged();
    }

    const bool fresh = hasData() && m_fetchedAt.isValid()
                       && m_fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) < kCacheTtlSeconds;
    if (!forceRefresh && fresh) {
        // Колбэк, который то синхронный, то нет, ломает вызывающий код —
        // на попадании в кэш отвечаем через событийный цикл.
        if (callback)
            QTimer::singleShot(0, this, [callback = std::move(callback)] { callback(true, {}); });
        return;
    }

    if (callback)
        m_pending << std::move(callback);
    if (m_refreshing)
        return;
    startRefresh();
}

void SeasonalBuildService::startRefresh()
{
    m_refreshing = true;
    emit refreshingChanged();

    QUrl url = m_baseUrl;
    url.setPath(m_baseUrl.path() + QStringLiteral("/index.json"));

    QNetworkRequest request{url};
    request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
                         QNetworkRequest::NoLessSafeRedirectPolicy);
    request.setTransferTimeout(kTransferTimeoutMs);

    QNetworkReply *reply = m_network->get(request);
    connect(reply, &QNetworkReply::finished, this, [this, reply] {
        reply->deleteLater();

        if (reply->error() != QNetworkReply::NoError) {
            finishRefresh(false,
                          tr("Не удалось получить список сборок: %1").arg(reply->errorString()));
            return;
        }

        QString parseError;
        QList<SeasonalBuildEntry> parsed =
            parseCatalog(QJsonDocument::fromJson(reply->readAll()).object(), &parseError);
        // Пустой разбор — испорченный ответ; хороший кэш им не затираем.
        // Пустой каталог на сервере от испорченного не отличить, поэтому
        // «сборок нет» и «ответ битый» — одно и то же состояние: список
        // остаётся прежним, а причина уходит в lastError.
        if (parsed.isEmpty()) {
            finishRefresh(false,
                          parseError.isEmpty() ? tr("Список сборок пуст или не разобран")
                                               : parseError);
            return;
        }

        applyBuilds(std::move(parsed));
        m_fetchedAt = QDateTime::currentDateTimeUtc();
        writeCache();
        emit buildsChanged();
        finishRefresh(true, {});
    });
}

void SeasonalBuildService::finishRefresh(bool ok, const QString &warning)
{
    m_lastError = ok ? QString() : warning;
    m_refreshing = false;
    emit refreshingChanged();
    // Строка ошибки — часть того, что показывает окно, поэтому список
    // перечитывается и на неудаче.
    emit buildsChanged();

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

bool SeasonalBuildService::readCache()
{
    QFile file(LauncherPaths::seasonalCatalogFile());
    if (!file.open(QIODevice::ReadOnly))
        return false;

    const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
    // Кэш от другого сервера бесполезен: ссылки в нём ведут не туда.
    if (root.value(QStringLiteral("baseUrl")).toString() != m_baseUrl.toString())
        return false;

    QList<SeasonalBuildEntry> parsed = parseCatalog(root, nullptr);
    if (parsed.isEmpty())
        return false;

    applyBuilds(std::move(parsed));
    m_fetchedAt = QDateTime::fromString(root.value(QStringLiteral("fetchedAt")).toString(),
                                        Qt::ISODate);
    return true;
}

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

    QJsonArray builds;
    for (const SeasonalBuildEntry &entry : std::as_const(m_builds)) {
        builds << QJsonObject{
            {QStringLiteral("id"), entry.id},
            {QStringLiteral("name"), entry.name},
            {QStringLiteral("revision"), entry.revision},
            {QStringLiteral("minecraftVersion"), entry.minecraftVersion},
            {QStringLiteral("loader"), entry.loader},
            {QStringLiteral("loaderVersion"), entry.loaderVersion},
            {QStringLiteral("modCount"), entry.modCount},
            {QStringLiteral("seasonStart"), dateToJson(entry.seasonStart)},
            {QStringLiteral("seasonEnd"), dateToJson(entry.seasonEnd)},
            {QStringLiteral("serverUrl"), entry.serverUrl},
            {QStringLiteral("javaMajor"), entry.javaMajor},
            {QStringLiteral("description"), entry.description},
            {QStringLiteral("archive"),
             QJsonObject{{QStringLiteral("url"), entry.archiveUrl.toString()},
                         {QStringLiteral("size"), double(entry.archiveSize)},
                         {QStringLiteral("sha256"), entry.archiveSha256}}}};
    }

    // Своя обёртка: в каталоге нет ни отметки свежести кэша, ни адреса, с
    // которого он приехал, — а без них кэш нечем проверить.
    const QJsonObject root{{QStringLiteral("formatVersion"), kSupportedFormatVersion},
                           {QStringLiteral("fetchedAt"), m_fetchedAt.toString(Qt::ISODate)},
                           {QStringLiteral("baseUrl"), m_baseUrl.toString()},
                           {QStringLiteral("builds"), builds}};

    QSaveFile file(LauncherPaths::seasonalCatalogFile());
    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();
}
