#include "launcherbackend.h"

#include "authservice.h"
#include "gamelauncher.h"
#include "javainstaller.h"
#include "javalocator.h"
#include "javaruntime.h"
#include "javaruntimeservice.h"
#include "launcherpaths.h"
#include "versioninstaller.h"
#include "versionmanifestservice.h"
#include "modloaderinstaller.h"
#include "msaauthservice.h"
#include "modloaderversionservice.h"
#include "buildswitcher.h"
#include "seasonalbuildservice.h"
#include "seasonalpackdownloader.h"

#include <QDate>
#include <QDateTime>
#include <QDesktopServices>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QHash>
#include <QRegularExpression>
#include <QSaveFile>
#include <QSet>
#include <QTimer>
#include <QUrl>
#include <algorithm>

namespace {

const QString kOffline = QStringLiteral("offline");
const QString kElyBy = QStringLiteral("elyby");
const QString kMicrosoft = QStringLiteral("microsoft");

// Токен Minecraft живёт около суток, но обновляем его чуть раньше срока: игра
// проверяет сессию не в первую секунду после старта.
const int kTokenRenewMarginSecs = 120;

// Сервер готовых сборок по умолчанию. Переопределяется ключом seasonalBaseUrl
// в settings.json — это нужно, чтобы прогонять установку против локального
// http-сервера, не трогая боевой.
const QString kSeasonalBaseUrl = QStringLiteral("https://launcher-builds.galeonworx.com");

// Тип авторизации профиля: всё, что лаунчер не знает, считается офлайном.
QString normalizedAuthType(const QString &value)
{
    if (value == kElyBy)
        return kElyBy;
    if (value == kMicrosoft)
        return kMicrosoft;
    return kOffline;
}

// Дата сезона в таблице: год нужен, время — нет.
QString formatSeasonDate(const QDate &date)
{
    return date.isValid() ? date.toString(QStringLiteral("dd.MM.yyyy")) : QStringLiteral("—");
}

int settingInt(const QVariantMap &settings, const QString &key, int fallback)
{
    const QVariant value = settings.value(key);
    bool ok = false;
    const int parsed = value.toInt(&ok);
    return ok ? parsed : fallback;
}

} // namespace

LauncherBackend::LauncherBackend(QObject *parent)
    : QObject(parent)
    , m_auth(new AuthService(this))
    , m_msa(new MsaAuthService(this))
    , m_launcher(new GameLauncher(this))
    , m_manifest(new VersionManifestService(this))
    , m_installer(new VersionInstaller(m_manifest, this))
    , m_loaderMeta(new ModLoaderVersionService(this))
    , m_loaderInstaller(new ModLoaderInstaller(m_loaderMeta, m_installer, this))
    , m_switcher(new BuildSwitcher(this))
    , m_javaMeta(new JavaRuntimeService(this))
    , m_javaInstaller(new JavaInstaller(this))
    , m_seasonalMeta(new SeasonalBuildService(this))
    , m_packDownloader(new SeasonalPackDownloader(this))
{
    // Папка лаунчера создаётся при первом запуске и проверяется при каждом
    // следующем — если её удалили, она появится снова.
    QString storageError;
    if (!LauncherPaths::ensureRootExists(&storageError))
        m_storageIssues << tr("Не удалось создать папку лаунчера: %1").arg(storageError);

    loadSettings();
    // Порядок важен: миграция должна успеть до того, как ensureDataFiles()
    // создаст пустой customBuilds.json поверх старых данных.
    migrateLegacyBuildsFile();
    loadData();
    ensureDataFiles();

    // Прошлое переключение оборвалось на середине. Доигрываем его сами, а не
    // предлагаем пользователю переключиться заново: архив прежней сборки уже
    // записан целиком, а вот повторное переключение сначала заархивировало бы
    // поверх него нынешнее — наполовину разобранное — содержимое .minecraft.
    const QString interrupted = m_switcher->interruptedSwitchWarning();
    if (!interrupted.isEmpty()) {
        m_storageIssues << interrupted;
        QTimer::singleShot(0, this, [this] { m_switcher->resumeInterrupted(gameDir()); });
    }

    connect(m_auth, &AuthService::progress, this, &LauncherBackend::launchProgress);
    connect(m_msa, &MsaAuthService::progress, this, &LauncherBackend::launchProgress);
    connect(m_launcher, &GameLauncher::progress, this, &LauncherBackend::launchProgress);
    connect(m_launcher, &GameLauncher::output, this, &LauncherBackend::gameOutput);
    connect(m_launcher, &GameLauncher::gameStarted, this, [this](const QString &commandLine) {
        emit gameOutput(commandLine);
        emit gameRunningChanged();
    });
    connect(m_launcher, &GameLauncher::gameFinished, this, [this](int exitCode, bool crashed) {
        emit gameRunningChanged();
        emit gameFinished(exitCode, crashed);
    });

    connect(m_manifest, &VersionManifestService::versionsChanged,
            this, &LauncherBackend::invalidateCatalog);
    connect(m_manifest, &VersionManifestService::refreshingChanged,
            this, &LauncherBackend::catalogLoadingChanged);

    connect(m_javaMeta, &JavaRuntimeService::catalogChanged,
            this, &LauncherBackend::invalidateJavaCatalog);
    connect(m_javaMeta, &JavaRuntimeService::refreshingChanged,
            this, &LauncherBackend::javaCatalogLoadingChanged);

    connect(m_javaInstaller, &JavaInstaller::started, this, [this](const QString &label) {
        emit downloadChanged();
        emit busyChanged();
        emit launchProgress(tr("Загрузка Java «%1»…").arg(label));
    });
    connect(m_javaInstaller, &JavaInstaller::progressChanged,
            this, &LauncherBackend::downloadChanged);
    connect(m_javaInstaller, &JavaInstaller::finished, this,
            [this](const QString &runtimeId, const QString &javaPath) {
                invalidateJavaCatalog();
                emit downloadChanged();
                emit busyChanged();
                emit javaRuntimeInstalled(runtimeId);
                emit launchProgress(tr("Java установлена: %1")
                                        .arg(QDir::toNativeSeparators(javaPath)));
                if (m_season.stage == SeasonStage::Java)
                    seasonAdvance();
            });
    connect(m_javaInstaller, &JavaInstaller::failed, this,
            [this](const QString &label, const QString &message) {
                invalidateJavaCatalog();
                emit downloadChanged();
                emit busyChanged();
                emit launchError(tr("Не удалось установить Java «%1»: %2").arg(label, message));
                if (m_season.stage == SeasonStage::Java)
                    seasonFail({});
            });
    connect(m_javaInstaller, &JavaInstaller::canceled, this, [this](const QString &label) {
        invalidateJavaCatalog();
        emit downloadChanged();
        emit busyChanged();
        emit launchProgress(tr("Загрузка Java «%1» отменена").arg(label));
        if (m_season.stage == SeasonStage::Java)
            seasonFail({});
    });

    connect(m_installer, &VersionInstaller::started, this, [this](const QString &id) {
        emit downloadChanged();
        emit busyChanged();
        emit launchProgress(tr("Загрузка версии «%1»…").arg(id));
    });
    connect(m_installer, &VersionInstaller::progressChanged,
            this, &LauncherBackend::downloadChanged);
    // Порядок важен: на installedVersionsChanged интерфейс перечитывает каталог,
    // и устаревший кэш вернулся бы наружу.
    connect(m_installer, &VersionInstaller::finished, this, [this](const QString &id) {
        invalidateCatalog();
        emit installedVersionsChanged();
        emit downloadChanged();
        emit busyChanged();
        emit launchProgress(tr("Версия «%1» загружена").arg(id));
        // У ванильной сезонной сборки этот установщик и есть весь шаг Loader.
        // Внутри установки модлоадера он тоже отчитывается — тогда ход
        // передаст ModLoaderInstaller, а не он.
        if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
            seasonAdvance();
    });
    connect(m_installer, &VersionInstaller::failed, this, [this](const QString &id,
                                                                const QString &message) {
        // Часть файлов всё же появилась — список установленного мог измениться.
        invalidateCatalog();
        emit installedVersionsChanged();
        emit downloadChanged();
        emit busyChanged();
        emit launchError(tr("Не удалось загрузить версию «%1»: %2").arg(id, message));
        if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
            seasonFail({});
    });
    connect(m_switcher, &BuildSwitcher::progressChanged, this, [this] {
        emit switchChanged();
        emit busyChanged();
    });
    connect(m_switcher, &BuildSwitcher::finished, this, [this](int toBuildId) {
        // Активной сборка становится только здесь: провалившееся переключение
        // оставляет активной прежнюю.
        m_settings.insert(QStringLiteral("activeBuildId"), toBuildId);
        saveSettings();
        invalidateCatalog();
        emit activeBuildChanged();
        emit installedVersionsChanged();
        emit switchChanged();
        emit busyChanged();
        const int index = indexOfBuildId(toBuildId);
        emit launchProgress(tr("Активна сборка «%1»")
                                .arg(index >= 0 ? m_builds[index].name : QString()));

        // Раскатка пака могла быть и той, что доигралась после перезапуска —
        // тогда цепочки установки уже нет, а записать ревизию всё равно надо.
        const QJsonObject note = m_switcher->lastPackNote();
        if (!note.isEmpty())
            recordSeasonalPack(toBuildId, note, m_switcher->lastPackEntries());

        if (m_season.stage == SeasonStage::Applying)
            seasonFinish();
        else if (m_season.stage == SeasonStage::Switching)
            seasonAdvance();
    });
    connect(m_switcher, &BuildSwitcher::failed, this,
            [this](int, const QString &message, bool gameDirIntact) {
                invalidateCatalog();
                emit installedVersionsChanged();
                emit switchChanged();
                emit busyChanged();
                // Раскатка пака и смена сборки приходят одним сигналом, а
                // сказать пользователю надо разное: после сорванного
                // переключения .minecraft лежит разобранной, а сорванная
                // раскатка означает лишь наполовину обновлённую сборку.
                if (m_season.stage == SeasonStage::Applying) {
                    emit launchError(gameDirIntact
                                         ? tr("Файлы сборки не установлены: %1").arg(message)
                                         : tr("Файлы сборки установлены не полностью: %1\n"
                                              "Повторите установку сборки.")
                                               .arg(message));
                } else {
                    emit launchError(gameDirIntact
                                         ? tr("Сборка не переключена: %1").arg(message)
                                         : tr("Сборка не переключена: %1\nСодержимое .minecraft "
                                              "осталось разобранным — повторите переключение.")
                                               .arg(message));
                }
                if (m_season.stage == SeasonStage::Switching
                    || m_season.stage == SeasonStage::Applying) {
                    seasonFail({});
                }
            });

    connect(m_loaderMeta, &ModLoaderVersionService::versionsChanged,
            this, &LauncherBackend::loaderVersionsChanged);
    connect(m_loaderMeta, &ModLoaderVersionService::refreshingChanged,
            this, &LauncherBackend::downloadChanged);

    // Установщик лоадера делит панель прогресса и флаг занятости с обычным
    // установщиком версий — сигналы у них одни и те же.
    connect(m_loaderInstaller, &ModLoaderInstaller::started, this, [this](const QString &label) {
        emit busyChanged();
        emit downloadChanged();
        emit launchProgress(tr("Установка %1…").arg(label));
    });
    connect(m_loaderInstaller, &ModLoaderInstaller::progressChanged,
            this, &LauncherBackend::downloadChanged);
    connect(m_loaderInstaller, &ModLoaderInstaller::log, this, &LauncherBackend::gameOutput);
    connect(m_loaderInstaller, &ModLoaderInstaller::finished, this,
            [this](const QString &loader, const QString &gameVersion,
                   const QString &loaderVersion, const QString &producedVersionId) {
                const int index = indexOfBuildId(m_loaderInstallBuildId);
                m_loaderInstallBuildId = -1;
                if (index >= 0) {
                    updateCustomBuild(index,
                                      {{QStringLiteral("loader"), loader},
                                       {QStringLiteral("loaderVersion"), loaderVersion},
                                       {QStringLiteral("resolvedVersionId"), producedVersionId}});
                }
                invalidateCatalog();
                emit installedVersionsChanged();
                emit busyChanged();
                emit downloadChanged();
                emit launchProgress(tr("Готово: %1 для Minecraft %2")
                                        .arg(producedVersionId, gameVersion));
                if (m_season.stage == SeasonStage::Loader)
                    seasonAdvance();
            });
    connect(m_loaderInstaller, &ModLoaderInstaller::failed, this,
            [this](const QString &label, const QString &message) {
                m_loaderInstallBuildId = -1;
                invalidateCatalog();
                emit installedVersionsChanged();
                emit busyChanged();
                emit downloadChanged();
                emit launchError(tr("%1: %2").arg(label, message));
                if (m_season.stage == SeasonStage::Loader)
                    seasonFail({});
            });
    connect(m_loaderInstaller, &ModLoaderInstaller::canceled, this, [this](const QString &label) {
        m_loaderInstallBuildId = -1;
        invalidateCatalog();
        emit installedVersionsChanged();
        emit busyChanged();
        emit downloadChanged();
        emit launchProgress(tr("Установка %1 отменена").arg(label));
        if (m_season.stage == SeasonStage::Loader)
            seasonFail({});
    });

    connect(m_installer, &VersionInstaller::canceled, this, [this](const QString &id) {
        invalidateCatalog();
        emit installedVersionsChanged();
        emit downloadChanged();
        emit busyChanged();
        emit launchProgress(tr("Загрузка версии «%1» отменена").arg(id));
        if (m_season.stage == SeasonStage::Loader && !m_loaderInstaller->isRunning())
            seasonFail({});
    });

    m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
    connect(m_seasonalMeta, &SeasonalBuildService::buildsChanged,
            this, &LauncherBackend::invalidateSeasonalCatalog);
    connect(m_seasonalMeta, &SeasonalBuildService::refreshingChanged,
            this, &LauncherBackend::seasonalCatalogLoadingChanged);
    // Статус строки («установлена», «доступно обновление») считается по
    // локальным записям — их изменение обновляет и таблицу.
    connect(this, &LauncherBackend::customBuildsChanged,
            this, &LauncherBackend::invalidateSeasonalCatalog);

    connect(m_packDownloader, &SeasonalPackDownloader::started, this, [this](const QString &label) {
        emit downloadChanged();
        emit busyChanged();
        emit launchProgress(tr("Загрузка файлов сборки «%1»…").arg(label));
    });
    connect(m_packDownloader, &SeasonalPackDownloader::progressChanged,
            this, &LauncherBackend::downloadChanged);
    connect(m_packDownloader, &SeasonalPackDownloader::finished, this, [this](const QString &path) {
        emit downloadChanged();
        emit busyChanged();
        if (m_season.stage != SeasonStage::Downloading)
            return;
        m_season.packPath = path;
        seasonAdvance();
    });
    connect(m_packDownloader, &SeasonalPackDownloader::failed, this,
            [this](const QString &label, const QString &message) {
                emit downloadChanged();
                emit busyChanged();
                emit launchError(tr("Сборка «%1»: %2").arg(label, message));
                if (m_season.stage == SeasonStage::Downloading)
                    seasonFail({});
            });
    connect(m_packDownloader, &SeasonalPackDownloader::canceled, this, [this](const QString &label) {
        emit downloadChanged();
        emit busyChanged();
        emit launchProgress(tr("Загрузка сборки «%1» отменена").arg(label));
        if (m_season.stage == SeasonStage::Downloading)
            seasonFail({});
    });

    // Интерфейс подключается к сигналам уже после конструктора — сообщаем отложенно.
    if (!m_storageIssues.isEmpty()) {
        QTimer::singleShot(0, this, [this] {
            emit launchError(m_storageIssues.join(u'\n'));
        });
    }
}

LauncherBackend::~LauncherBackend() = default;

QStringList LauncherBackend::profileNames() const
{
    QStringList names;
    for (const auto &p : m_profiles)
        names << p.name;
    return names;
}

QStringList LauncherBackend::customBuildNames() const
{
    QStringList names;
    for (const auto &b : m_builds)
        names << b.name;
    return names;
}

QStringList LauncherBackend::installedVersions() const
{
    return VersionLoader::installedVersions(gameDir());
}

bool LauncherBackend::gameRunning() const
{
    return m_launcher->isRunning();
}

void LauncherBackend::addProfile(const QString &name,
                                 const QString &login,
                                 const QString &password,
                                 const QString &authType)
{
    Profile profile;
    profile.id = m_nextProfileId++;
    profile.name = name;
    profile.login = login;
    profile.password = password;
    profile.authType = normalizedAuthType(authType);
    profile.clientToken = AuthService::generateClientToken();
    m_profiles.append(profile);
    saveProfiles();
    emit profilesChanged();
}

void LauncherBackend::addCustomBuild(const QString &name,
                                     const QString &serverUrl,
                                     const QString &minecraftVersion)
{
    CustomBuild build;
    build.id = m_nextBuildId++;
    build.name = name;
    build.serverUrl = serverUrl;
    build.minecraftVersion = minecraftVersion;
    m_builds.append(build);
    saveCustomBuilds();
    emit customBuildsChanged();
    maybeInstall(launchVersionIdOf(m_builds.last()));
}

void LauncherBackend::updateProfile(int index,
                                    const QString &name,
                                    const QString &login,
                                    const QString &password,
                                    const QString &authType)
{
    if (index < 0 || index >= m_profiles.size())
        return;

    Profile &profile = m_profiles[index];
    const QString type = normalizedAuthType(authType);

    // Смена логина или пароля обесценивает сохранённый токен Ely.by. У аккаунта
    // Microsoft ни того ни другого нет, и переименование профиля не должно
    // выкидывать из аккаунта.
    if (profile.authType == kElyBy && (profile.login != login || profile.password != password)) {
        profile.accessToken.clear();
        profile.uuid.clear();
        profile.resolvedName.clear();
    }
    // Смена типа авторизации: чужие учётные данные к новому типу не подходят.
    if (profile.authType != type) {
        profile.accessToken.clear();
        profile.refreshToken.clear();
        profile.uuid.clear();
        profile.resolvedName.clear();
        profile.xuid.clear();
        profile.tokenExpiry.clear();
        profile.userType.clear();
    }
    profile.name = name;
    profile.login = login;
    profile.password = password;
    profile.authType = type;
    if (profile.clientToken.isEmpty())
        profile.clientToken = AuthService::generateClientToken();

    saveProfiles();
    emit profilesChanged();
}

void LauncherBackend::updateCustomBuild(int index, const QVariantMap &fields)
{
    if (index < 0 || index >= m_builds.size())
        return;

    CustomBuild &build = m_builds[index];
    const auto take = [&fields](const char *key, QString &target) {
        const auto it = fields.constFind(QLatin1String(key));
        if (it == fields.constEnd())
            return false;
        target = it->toString();
        return true;
    };

    // Сменилась версия игры — выбранная сборка модлоадера к ней уже не подходит.
    // Сам лоадер оставляем: чекбокс не должен слетать, он просто перевыберет версию.
    const QString previousMinecraft = build.minecraftVersion;

    take("name", build.name);
    take("serverUrl", build.serverUrl);
    take("minecraftVersion", build.minecraftVersion);
    take("loader", build.loader);
    take("loaderVersion", build.loaderVersion);
    take("resolvedVersionId", build.resolvedVersionId);
    take("seasonalId", build.seasonalId);
    if (fields.contains(QStringLiteral("seasonalRevision")))
        build.seasonalRevision = fields.value(QStringLiteral("seasonalRevision")).toInt();

    if (build.minecraftVersion != previousMinecraft
        && !fields.contains(QStringLiteral("loaderVersion"))) {
        build.loaderVersion.clear();
        build.resolvedVersionId.clear();
    }

    saveCustomBuilds();
    emit customBuildsChanged();
    emit activeBuildChanged();   // имя активной сборки могло измениться
    // maybeInstall() здесь намеренно нет: карточка сборки сохраняется по ходу
    // правки, и загрузка стартовала бы почти на каждое действие. Для этого есть
    // installCustomBuild().
}

QVariantMap LauncherBackend::profileAt(int index) const
{
    if (index < 0 || index >= m_profiles.size())
        return {};
    const auto &p = m_profiles[index];
    return {{"name", p.name},
            {"login", p.login},
            {"password", p.password},
            {"authType", p.authType},
            {"resolvedName", p.resolvedName},
            {"hasMicrosoftSession", !p.refreshToken.isEmpty()}};
}

QVariantMap LauncherBackend::customBuildAt(int index) const
{
    if (index < 0 || index >= m_builds.size())
        return {};
    const auto &b = m_builds[index];
    return {{"name", b.name},
            {"serverUrl", b.serverUrl},
            {"minecraftVersion", b.minecraftVersion},
            {"loader", b.loader},
            {"loaderVersion", b.loaderVersion},
            {"resolvedVersionId", b.resolvedVersionId},
            {"seasonalId", b.seasonalId},
            {"seasonalRevision", b.seasonalRevision},
            {"launchVersionId", launchVersionIdOf(b)}};
}

void LauncherBackend::removeProfile(int index)
{
    if (index < 0 || index >= m_profiles.size())
        return;
    m_profiles.removeAt(index);
    saveProfiles();
    emit profilesChanged();
}

QVariantMap LauncherBackend::customBuildRemovalInfo(int index) const
{
    if (index < 0 || index >= m_builds.size())
        return {};
    const CustomBuild &build = m_builds[index];
    const bool active = m_settings.value(QStringLiteral("activeBuildId")).toInt() == build.id
                        || m_builds.size() == 1;
    return {{QStringLiteral("name"), build.name},
            {QStringLiteral("hasArchive"), m_switcher->hasArchive(build.id)},
            {QStringLiteral("active"), active},
            {QStringLiteral("lastOne"), m_builds.size() == 1}};
}

void LauncherBackend::removeCustomBuild(int index)
{
    if (index < 0 || index >= m_builds.size())
        return;

    if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
        || m_loaderInstaller->isRunning()) {
        emit launchError(tr("Дождитесь окончания текущей операции"));
        return;
    }
    if (m_launcher->isRunning()) {
        emit launchError(tr("Сначала закройте игру"));
        return;
    }

    const CustomBuild removed = m_builds.takeAt(index);
    const bool wasActive = m_settings.value(QStringLiteral("activeBuildId")).toInt() == removed.id
                           || m_builds.isEmpty();
    saveCustomBuilds();

    // Раньше удалялась только запись, а архив сборки оставался лежать в
    // galeonLauncher/builds вместе со ссылкой на него в index.json.
    if (!m_switcher->forgetBuild(removed.id)) {
        emit launchError(tr("Сборка «%1» удалена, но её архив в %2 стереть не удалось")
                             .arg(removed.name,
                                  QDir::toNativeSeparators(LauncherPaths::buildStorageDir())));
    }

    emit customBuildsChanged();
    emit activeBuildChanged();

    if (!wasActive)
        return;

    // Удалили активную сборку: в .minecraft лежит её содержимое, а активной
    // становится другая. Оставить как есть нельзя — при следующем переключении
    // чужое содержимое уехало бы в архив новой сборки поверх её собственного.
    m_settings.remove(QStringLiteral("activeBuildId"));
    saveSettings();

    if (m_builds.isEmpty()) {
        emit activeBuildChanged();
        return;
    }

    const CustomBuild &next = m_builds.first();
    emit launchProgress(tr("Активна сборка «%1»…").arg(next.name));
    // from = -1: архивировать нечего, содержимое удалённой сборки уходит вместе
    // с ней. Дальше обычная очистка и распаковка новой активной сборки.
    m_switcher->switchTo(-1, QString(), next.id, next.name, gameDir());
    emit switchChanged();
    emit busyChanged();
}

int LauncherBackend::indexOfBuildId(int buildId) const
{
    for (int i = 0; i < m_builds.size(); ++i) {
        if (m_builds[i].id == buildId)
            return i;
    }
    return -1;
}

int LauncherBackend::activeBuildIndex() const
{
    if (m_builds.isEmpty())
        return -1;
    const int index = indexOfBuildId(m_settings.value(QStringLiteral("activeBuildId")).toInt());
    // Настройка могла указывать на удалённую сборку — тогда активна первая.
    return index >= 0 ? index : 0;
}

void LauncherBackend::setActiveBuildIndex(int index)
{
    const int current = activeBuildIndex();
    if (index < 0 || index >= m_builds.size() || index == current)
        return;

    if (m_switcher->isRunning()) {
        emit launchError(tr("Смена сборки уже выполняется"));
        return;
    }
    if (m_launcher->isRunning()) {
        emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
        return;
    }
    if (m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()) {
        emit launchError(tr("Дождитесь окончания текущей операции"));
        return;
    }

    // Содержимое .minecraft принадлежит прежней сборке: сначала оно уезжает в
    // её архив, и только потом на его место разворачивается выбранная.
    const CustomBuild &target = m_builds[index];
    emit launchProgress(tr("Смена сборки на «%1»…").arg(target.name));
    m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
                         current >= 0 ? m_builds[current].name : QString(),
                         target.id,
                         target.name,
                         gameDir());
    emit switchChanged();
    emit busyChanged();
}

QString LauncherBackend::activeBuildName() const
{
    const int index = activeBuildIndex();
    return index < 0 ? QString() : m_builds[index].name;
}

void LauncherBackend::installCustomBuild(int index)
{
    // Установка сборки — это установка её версии игры и, если выбран,
    // модлоадера: они ставятся одной цепочкой.
    installLoaderForBuild(index);
}

QString LauncherBackend::launchVersionIdOf(const CustomBuild &build) const
{
    // resolvedVersionId появляется после установки модлоадера. Пользователь мог
    // назвать сборку как угодно — тогда версией считаем само имя: так работали
    // записи, созданные до появления каталога версий.
    if (!build.resolvedVersionId.isEmpty())
        return build.resolvedVersionId;
    return build.minecraftVersion.isEmpty() ? build.name : build.minecraftVersion;
}

QStringList LauncherBackend::checkInstallation(int buildIndex) const
{
    if (buildIndex < 0 || buildIndex >= m_builds.size())
        return {tr("Сборка не выбрана")};

    const CustomBuild &entry = m_builds[buildIndex];
    const QString id = launchVersionIdOf(entry);
    if (id.trimmed().isEmpty())
        return {tr("У сборки не выбрана версия Minecraft. Проверьте %1")
                    .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))};

    QString error;
    const MinecraftVersion version = VersionLoader::load(gameDir(), id, {}, &error);
    if (!version.isValid()) {
        return {error.isEmpty()
                    ? tr("Версия «%1» из сборки «%2» не установлена в %3")
                          .arg(id,
                               entry.name,
                               QDir::toNativeSeparators(gameDir() + QStringLiteral("/versions")))
                    : error};
    }

    LaunchOptions options;
    options.gameDir = gameDir();
    options.versionId = id;
    return GameLauncher::missingFiles(options, version);
}

// ── Каталог версий ─────────────────────────────────────────────────────────

void LauncherBackend::invalidateCatalog()
{
    m_catalogDirty = true;
    emit versionCatalogChanged();
}

bool LauncherBackend::catalogLoading() const
{
    return m_manifest->isRefreshing();
}

void LauncherBackend::refreshVersionCatalog(bool force)
{
    // Список установленного мог поменяться и без нас (папку добавили руками).
    invalidateCatalog();
    m_manifest->ensureLoaded([this](bool ok, const QString &warning) {
        if (!ok && !warning.isEmpty())
            emit launchError(warning);
    }, force);
}

bool LauncherBackend::isVersionInstalled(const QString &versionId) const
{
    if (versionId.trimmed().isEmpty())
        return false;
    return VersionLoader::installedVersions(gameDir()).contains(versionId);
}

QVariantMap LauncherBackend::versionRemovalInfo(const QString &versionId) const
{
    const QString id = versionId.trimmed();
    if (id.isEmpty())
        return {};

    const qint64 bytes = VersionLoader::installedSize(gameDir(), id);
    // Сборки не правим при удалении — ссылка на неустановленную версию это
    // рабочее состояние. Но перечислить, кого заденет, надо.
    QStringList builds;
    for (const CustomBuild &build : m_builds) {
        if (build.minecraftVersion == id || build.resolvedVersionId == id
            || launchVersionIdOf(build) == id) {
            builds << build.name;
        }
    }

    return {{QStringLiteral("id"), id},
            {QStringLiteral("installed"), isVersionInstalled(id)},
            {QStringLiteral("sizeMb"), static_cast<int>((bytes + 1024 * 1024 - 1) / (1024 * 1024))},
            {QStringLiteral("dependents"), VersionLoader::dependentsOf(gameDir(), id)},
            {QStringLiteral("builds"), builds}};
}

void LauncherBackend::removeVersion(const QString &versionId)
{
    const QString id = versionId.trimmed();
    if (id.isEmpty())
        return;

    if (m_installer->versionId() == id || m_installer->isQueued(id)) {
        emit launchError(tr("Эта версия сейчас скачивается"));
        return;
    }
    if (m_switcher->isRunning() || m_busy || m_installer->isRunning()
        || m_loaderInstaller->isRunning()) {
        emit launchError(tr("Дождитесь окончания текущей операции"));
        return;
    }
    // Какой версией сейчас играют, GameLauncher наружу не отдаёт, поэтому на
    // время игры удаление запрещено целиком.
    if (m_launcher->isRunning()) {
        emit launchError(tr("Сначала закройте игру"));
        return;
    }

    QString error;
    if (!VersionLoader::remove(gameDir(), id, &error)) {
        emit launchError(tr("Не удалось удалить версию: %1").arg(error));
        return;
    }

    invalidateCatalog();
    emit installedVersionsChanged();
    emit launchProgress(tr("Версия «%1» удалена").arg(id));
}

QVariantList LauncherBackend::versionCatalog() const
{
    if (m_catalogDirty) {
        rebuildCatalog();
        m_catalogDirty = false;
    }
    return m_catalog;
}

void LauncherBackend::rebuildCatalog() const
{
    m_catalog.clear();

    // Плоский список в порядке манифеста — новые версии сверху. Раскладку по
    // категориям и отбор делает окно выбора версии: ему нужен один список,
    // который можно фильтровать, а не готовая структура с разделителями.
    const QList<RemoteVersionEntry> remote = m_manifest->versions();
    const QStringList installed = VersionLoader::installedVersions(gameDir());
    const QSet<QString> installedSet(installed.cbegin(), installed.cend());

    const auto category = [](const QString &type) {
        if (type == QLatin1String("release") || type == QLatin1String("snapshot")
            || type == QLatin1String("old_beta") || type == QLatin1String("old_alpha"))
            return type;
        return QStringLiteral("other");
    };

    const auto makeVersionRow = [&category](const QString &id, const QString &type, bool isInstalled) {
        return QVariantMap{{QStringLiteral("id"), id},
                           {QStringLiteral("type"), type},
                           {QStringLiteral("category"), category(type)},
                           {QStringLiteral("label"), id},
                           {QStringLiteral("installed"), isInstalled},
                           {QStringLiteral("search"), (id + u' ' + type).toLower()}};
    };

    m_catalog.reserve(remote.size() + installed.size());
    QSet<QString> known;
    known.reserve(remote.size());
    for (const RemoteVersionEntry &entry : remote) {
        known.insert(entry.id);
        m_catalog.append(makeVersionRow(entry.id, entry.type, installedSet.contains(entry.id)));
    }

    // Сборки модлоадеров манифест не знает — они попадают в «Прочие».
    QStringList unknown;
    for (const QString &id : installed) {
        if (!known.contains(id))
            unknown << id;
    }
    std::sort(unknown.begin(), unknown.end());
    for (const QString &id : std::as_const(unknown))
        m_catalog.append(makeVersionRow(id, QString(), true));
}

// ── Загрузка версий ────────────────────────────────────────────────────────

void LauncherBackend::maybeInstall(const QString &versionId)
{
    const QString id = versionId.trimmed();
    if (id.isEmpty() || isVersionInstalled(id))
        return;
    if (m_installer->versionId() == id || m_installer->isQueued(id))
        return;
    // Манифест здесь не спрашиваем: установщик сам скажет, что версии в нём нет,
    // и это правильный ответ на вручную вписанный id сборки.
    m_installer->install(gameDir(), id);
}

void LauncherBackend::installVersion(const QString &versionId)
{
    const QString id = versionId.trimmed();
    if (id.isEmpty())
        return;
    if (m_installer->versionId() == id || m_installer->isQueued(id))
        return;
    m_installer->install(gameDir(), id);
}

void LauncherBackend::cancelDownload()
{
    // Установщик лоадера сам остановит вложенную установку версии.
    if (m_loaderInstaller->isRunning())
        m_loaderInstaller->cancel();
    else if (m_installer->isRunning())
        m_installer->cancel();
    else if (m_javaInstaller->isRunning())
        m_javaInstaller->cancel();
    else
        m_packDownloader->cancel();
}

// Панель загрузки одна на три установщика: пока идёт установка модлоадера, она
// показывает его стадии, включая вложенную загрузку файлов версии. Установка
// Java начинается только когда остальные не заняты, поэтому за панель они не спорят.
bool LauncherBackend::downloading() const
{
    return m_loaderInstaller->isRunning() || m_installer->isRunning()
           || m_javaInstaller->isRunning() || m_packDownloader->isRunning();
}

// Java качается сама по себе, а версия и лоадер — вместе; отсюда и порядок проверок.
bool LauncherBackend::javaDownloadShown() const
{
    return m_javaInstaller->isRunning() && !m_installer->isRunning()
           && !m_loaderInstaller->isRunning();
}

// Пак качается отдельным шагом цепочки, когда остальные установщики уже
// отработали, — за панель они не спорят.
bool LauncherBackend::packDownloadShown() const
{
    return m_packDownloader->isRunning() && !m_installer->isRunning()
           && !m_loaderInstaller->isRunning() && !m_javaInstaller->isRunning();
}

double LauncherBackend::downloadProgress() const
{
    if (packDownloadShown())
        return m_packDownloader->fraction();
    if (javaDownloadShown())
        return m_javaInstaller->fraction();
    if (!m_loaderInstaller->isRunning())
        return m_installer->fraction();
    // Внутри установки лоадера файлы качает VersionInstaller — его доля точнее.
    return m_installer->isRunning() ? m_installer->fraction() : m_loaderInstaller->fraction();
}

QString LauncherBackend::downloadVersion() const
{
    if (packDownloadShown())
        return m_packDownloader->label();
    if (javaDownloadShown())
        return m_javaInstaller->label();
    if (m_loaderInstaller->isRunning())
        return m_loaderInstaller->label();
    return m_installer->versionId();
}

QString LauncherBackend::downloadStatus() const
{
    QString stage = m_installer->stage();
    QString file = m_installer->currentFile();
    if (packDownloadShown()) {
        stage = m_packDownloader->stage();
        file = m_packDownloader->currentFile();
    } else if (javaDownloadShown()) {
        stage = m_javaInstaller->stage();
        file = m_javaInstaller->currentFile();
    } else if (m_loaderInstaller->isRunning() && !m_installer->isRunning()) {
        stage = m_loaderInstaller->stage();
        file = m_loaderInstaller->currentFile();
    }
    if (stage.isEmpty())
        return file;
    if (file.isEmpty())
        return stage;
    return QStringLiteral("%1 — %2").arg(stage, file);
}

qint64 LauncherBackend::downloadBytesDone() const
{
    if (packDownloadShown())
        return m_packDownloader->bytesDone();
    if (javaDownloadShown())
        return m_javaInstaller->bytesDone();
    if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
        return m_loaderInstaller->bytesDone();
    return m_installer->bytesDone();
}

qint64 LauncherBackend::downloadBytesTotal() const
{
    if (packDownloadShown())
        return m_packDownloader->bytesTotal();
    if (javaDownloadShown())
        return m_javaInstaller->bytesTotal();
    if (m_loaderInstaller->isRunning() && !m_installer->isRunning())
        return m_loaderInstaller->bytesTotal();
    return m_installer->bytesTotal();
}

// ── Модлоадеры ─────────────────────────────────────────────────────────────

QVariantList LauncherBackend::loaderVersions(const QString &loaderKey,
                                             const QString &gameVersion) const
{
    const auto loader = loaderFromKey(loaderKey);
    if (!loader)
        return {};

    QVariantList result;
    const QList<LoaderVersionEntry> entries = m_loaderMeta->versions(*loader, gameVersion);
    result.reserve(entries.size());
    for (const LoaderVersionEntry &entry : entries) {
        QString label = entry.loaderVersion;
        if (entry.recommended)
            label += tr(" — рекомендуемая");
        else if (!entry.stable)
            label += tr(" — нестабильная");
        result.append(QVariantMap{{QStringLiteral("version"), entry.loaderVersion},
                                  {QStringLiteral("label"), label},
                                  {QStringLiteral("recommended"), entry.recommended},
                                  {QStringLiteral("stable"), entry.stable}});
    }
    return result;
}

void LauncherBackend::refreshLoaderVersions(const QString &loaderKey,
                                            const QString &gameVersion,
                                            bool force)
{
    const auto loader = loaderFromKey(loaderKey);
    if (!loader)
        return;
    m_loaderMeta->ensureLoaded(*loader, gameVersion, [this, loaderKey, gameVersion](
                                                         bool ok, const QString &warning) {
        if (!ok && !warning.isEmpty())
            emit launchError(warning);
        // Кэш мог ответить сразу, без сигнала versionsChanged.
        emit loaderVersionsChanged(loaderKey, gameVersion);
    }, force);
}

bool LauncherBackend::loaderVersionsLoading(const QString &loaderKey,
                                            const QString &gameVersion) const
{
    const auto loader = loaderFromKey(loaderKey);
    return loader && m_loaderMeta->isRefreshing(*loader, gameVersion);
}

void LauncherBackend::installLoaderForBuild(int index)
{
    if (index < 0 || index >= m_builds.size())
        return;
    if (busy()) {
        emit launchError(tr("Дождитесь окончания текущей загрузки"));
        return;
    }

    const CustomBuild &build = m_builds[index];
    if (build.minecraftVersion.trimmed().isEmpty()) {
        emit launchError(tr("Сначала выберите версию Minecraft для сборки «%1»").arg(build.name));
        return;
    }

    // Лоадер не выбран — сборка ванильная, ставим саму версию игры.
    const auto loader = loaderFromKey(build.loader);
    if (!loader) {
        if (build.resolvedVersionId != build.minecraftVersion)
            updateCustomBuild(index, {{QStringLiteral("resolvedVersionId"), QString()}});
        maybeInstall(build.minecraftVersion);
        return;
    }
    if (build.loaderVersion.trimmed().isEmpty()) {
        emit launchError(tr("Выберите версию %1 для сборки «%2»")
                             .arg(loaderTitle(*loader), build.name));
        return;
    }

    m_loaderInstallBuildId = build.id;
    m_loaderInstaller->install(gameDir(),
                               *loader,
                               build.minecraftVersion,
                               build.loaderVersion,
                               managedJavaPath().isEmpty()
                                   ? m_settings.value(QStringLiteral("javaPath")).toString()
                                   : managedJavaPath());
}

void LauncherBackend::launchGame(int profileIndex, int buildIndex)
{
    if (m_loaderInstaller->isRunning()) {
        emit launchError(tr("Идёт установка «%1» — дождитесь окончания")
                             .arg(m_loaderInstaller->label()));
        return;
    }
    if (m_installer->isRunning()) {
        emit launchError(tr("Идёт загрузка версии «%1» — дождитесь окончания")
                             .arg(m_installer->versionId()));
        return;
    }
    // Игра ищет java при запуске: стартовать посреди её установки — значит
    // найти половину распакованного дерева.
    if (m_javaInstaller->isRunning()) {
        emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания")
                             .arg(m_javaInstaller->label()));
        return;
    }
    if (m_busy) {
        emit launchError(tr("Запуск уже выполняется"));
        return;
    }
    if (m_launcher->isRunning()) {
        emit launchError(tr("Игра уже запущена"));
        return;
    }
    if (profileIndex < 0 || profileIndex >= m_profiles.size()) {
        emit launchError(tr("Выберите профиль перед запуском"));
        return;
    }
    if (buildIndex < 0 || buildIndex >= m_builds.size()) {
        emit launchError(tr("Выберите сборку перед запуском"));
        return;
    }

    // Дальше проверяется только папка игры: свои файлы лаунчер уже разложил
    // в galeonLauncher при запуске.
    const QString directory = gameDir();
    if (!QDir(directory).exists()) {
        emit launchError(tr("Папка .minecraft не найдена: %1. Укажите путь к ней в настройках.")
                             .arg(QDir::toNativeSeparators(directory)));
        return;
    }

    const CustomBuild &entry = m_builds[buildIndex];
    const QString id = launchVersionIdOf(entry);
    if (id.trimmed().isEmpty()) {
        emit launchError(tr("У сборки «%1» не выбрана версия Minecraft. Проверьте %2")
                             .arg(entry.name,
                                  QDir::toNativeSeparators(LauncherPaths::customBuildsFile())));
        return;
    }

    const QString versionsDir = QDir::toNativeSeparators(directory + QStringLiteral("/versions"));
    const QStringList installed = VersionLoader::installedVersions(directory);
    if (!installed.contains(id)) {
        emit launchError(installed.isEmpty()
                             ? tr("В %1 нет ни одной установленной версии").arg(versionsDir)
                             : tr("Версия «%1» из сборки «%2» не установлена в %3. Доступны: %4")
                                   .arg(id,
                                        entry.name,
                                        versionsDir,
                                        installed.join(QStringLiteral(", "))));
        return;
    }

    setBusy(true);
    emit launchProgress(tr("Чтение описания версии «%1»…").arg(id));

    QSet<QString> features;
    if (settingInt(m_settings, QStringLiteral("windowWidth"), 0) > 0
        && settingInt(m_settings, QStringLiteral("windowHeight"), 0) > 0)
        features << QStringLiteral("has_custom_resolution");

    QString error;
    m_pendingVersionData = VersionLoader::load(directory, id, features, &error);
    if (!m_pendingVersionData.isValid()) {
        failLaunch(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(id) : error);
        return;
    }

    LaunchOptions probe;
    probe.gameDir = directory;
    probe.versionId = id;
    const QStringList missing = GameLauncher::missingFiles(probe, m_pendingVersionData);
    if (!missing.isEmpty()) {
        failLaunch(tr("В папке игры не хватает файлов (%1):\n%2")
                       .arg(missing.size())
                       .arg(missing.join(u'\n')));
        return;
    }

    m_pendingProfile = profileIndex;
    // Держим id, а не индекс: пока идёт авторизация, сборку могли удалить из
    // модального окна, и индекс уехал бы на чужую запись.
    m_pendingBuild = entry.id;
    m_pendingInjectorPath.clear();
    beginAuthentication();
}

void LauncherBackend::startMicrosoftLogin(int profileIndex)
{
    if (!microsoftAvailable()) {
        emit microsoftLoginFailed(tr("Эта сборка лаунчера собрана без Qt WebEngine — "
                                     "показать окно входа Microsoft нечем"));
        return;
    }
    // -1 из QML означает «профиля ещё нет»; внутри держим -2, чтобы отличать
    // это состояние от закрытого окна.
    if (profileIndex >= m_profiles.size()) {
        emit microsoftLoginFailed(tr("Профиль не найден"));
        return;
    }
    m_msaLoginProfile = profileIndex < 0 ? -2 : profileIndex;
    emit microsoftLoginUrlReady(MsaAuthService::authorizationUrl().toString());
}

void LauncherBackend::cancelMicrosoftLogin()
{
    m_msaLoginProfile = -1;
}

QVariantMap LauncherBackend::inspectMicrosoftRedirect(const QString &url) const
{
    QString code;
    QString error;
    const bool matched = MsaAuthService::matchRedirect(QUrl(url), &code, &error);
    return {{QStringLiteral("matched"), matched},
            {QStringLiteral("code"), code},
            {QStringLiteral("error"), error}};
}

void LauncherBackend::finishMicrosoftLogin(const QString &code)
{
    if (m_msaLoginProfile == -1) {
        emit microsoftLoginFailed(tr("Вход в Microsoft уже отменён"));
        return;
    }

    setBusy(true);
    m_msa->loginWithCode(code, [this](const AuthResult &result) {
        setBusy(false);
        const int target = m_msaLoginProfile;
        m_msaLoginProfile = -1;
        if (target == -1)
            return;   // окно закрыли, пока шла авторизация

        if (!result.ok) {
            emit microsoftLoginFailed(result.error);
            return;
        }

        if (target == -2) {
            // Профиля ещё нет — заводим его на ник из аккаунта.
            Profile profile;
            profile.id = m_nextProfileId++;
            profile.name = result.playerName;
            profile.authType = kMicrosoft;
            profile.clientToken = AuthService::generateClientToken();
            applyMicrosoftResult(profile, result);
            m_profiles.append(profile);
        } else if (target < m_profiles.size()) {
            Profile &profile = m_profiles[target];
            profile.authType = kMicrosoft;
            applyMicrosoftResult(profile, result);
        } else {
            emit microsoftLoginFailed(tr("Профиль был удалён, пока шёл вход"));
            return;
        }

        saveProfiles();
        emit profilesChanged();
        emit microsoftLoginSucceeded(result.playerName);
    });
}

void LauncherBackend::applyMicrosoftResult(Profile &profile, const AuthResult &auth)
{
    profile.accessToken = auth.accessToken;
    // Microsoft выдаёт новый refresh-токен и при продлении, но если в ответе
    // его не было — прежний остаётся рабочим.
    if (!auth.refreshToken.isEmpty())
        profile.refreshToken = auth.refreshToken;
    if (!auth.uuid.isEmpty())
        profile.uuid = auth.uuid;
    if (!auth.playerName.isEmpty())
        profile.resolvedName = auth.playerName;
    profile.xuid = auth.xuid;
    profile.userType = auth.userType;
    profile.tokenExpiry = auth.expiresAt.isValid()
                              ? auth.expiresAt.toUTC().toString(Qt::ISODate)
                              : QString();
}

void LauncherBackend::beginMicrosoftAuthentication()
{
    const Profile &profile = m_profiles[m_pendingProfile];

    if (profile.refreshToken.isEmpty()) {
        const int index = m_pendingProfile;
        failLaunch(tr("Профиль «%1» не подключён к аккаунту Microsoft — войдите в него")
                       .arg(profile.name));
        emit microsoftReloginRequired(index);
        return;
    }

    // Сохранённый токен ещё жив — идём в игру, не дёргая Microsoft.
    const QDateTime expiry = QDateTime::fromString(profile.tokenExpiry, Qt::ISODate);
    if (!profile.accessToken.isEmpty() && expiry.isValid()
        && QDateTime::currentDateTimeUtc().addSecs(kTokenRenewMarginSecs) < expiry) {
        AuthResult stored;
        stored.ok = true;
        stored.playerName = profile.resolvedName;
        stored.uuid = profile.uuid;
        stored.accessToken = profile.accessToken;
        stored.refreshToken = profile.refreshToken;
        stored.clientToken = MsaAuthService::clientId();
        stored.xuid = profile.xuid;
        stored.userType = profile.userType.isEmpty() ? QStringLiteral("msa") : profile.userType;
        stored.expiresAt = expiry;
        continueLaunch(stored);
        return;
    }

    m_msa->loginWithRefreshToken(profile.refreshToken, [this](const AuthResult &result) {
        if (m_pendingProfile < 0)
            return;   // запуск отменили, пока шло продление
        if (result.licenseMissing) {
            failLaunch(result.error);
            return;
        }
        if (!result.ok) {
            const int index = m_pendingProfile;
            failLaunch(tr("Сессия Microsoft истекла — войдите в аккаунт заново (%1)")
                           .arg(result.error));
            emit microsoftReloginRequired(index);
            return;
        }
        continueLaunch(result);
    });
}

void LauncherBackend::beginAuthentication()
{
    const Profile &profile = m_profiles[m_pendingProfile];

    if (profile.authType == kMicrosoft) {
        beginMicrosoftAuthentication();
        return;
    }

    if (profile.authType != kElyBy) {
        const QString nickname = profile.login.isEmpty() ? profile.name : profile.login;
        const AuthResult result = AuthService::offline(nickname);
        if (!result.ok) {
            failLaunch(result.error);
            return;
        }
        continueLaunch(result);
        return;
    }

    // Ely.by работает только вместе с authlib-injector — без него игра пойдёт
    // в сессионный сервер Mojang и токен окажется недействительным.
    m_auth->ensureAuthlibInjector(LauncherPaths::runtimeDir(),
                                  [this](const QString &path, const QString &error) {
        if (m_pendingProfile < 0)
            return;   // запуск отменили, пока качался инжектор
        if (!error.isEmpty()) {
            failLaunch(error);
            return;
        }
        m_pendingInjectorPath = path;

        const Profile &pending = m_profiles[m_pendingProfile];
        m_auth->loginElyBy(pending.login,
                           pending.password,
                           pending.clientToken,
                           pending.accessToken,
                           [this](const AuthResult &result) {
                               if (m_pendingProfile < 0)
                                   return;
                               if (result.twoFactorRequired) {
                                   setBusy(false);
                                   emit twoFactorRequired(m_profiles[m_pendingProfile].name);
                                   return;
                               }
                               if (!result.ok) {
                                   failLaunch(result.error);
                                   return;
                               }
                               continueLaunch(result);
                           });
    });
}

void LauncherBackend::submitTwoFactorCode(const QString &code)
{
    if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size()) {
        emit launchError(tr("Запуск уже отменён"));
        return;
    }
    if (code.trimmed().isEmpty()) {
        emit launchError(tr("Введите код двухфакторной аутентификации"));
        return;
    }

    setBusy(true);
    const Profile &profile = m_profiles[m_pendingProfile];
    m_auth->loginElyByWithTotp(profile.login,
                               profile.password,
                               code.trimmed(),
                               profile.clientToken,
                               [this](const AuthResult &result) {
                                   if (m_pendingProfile < 0)
                                       return;
                                   if (!result.ok) {
                                       failLaunch(result.error);
                                       return;
                                   }
                                   continueLaunch(result);
                               });
}

void LauncherBackend::cancelPendingLaunch()
{
    m_pendingProfile = -1;
    m_pendingBuild = -1;
    m_pendingInjectorPath.clear();
    setBusy(false);
}

void LauncherBackend::continueLaunch(const AuthResult &auth)
{
    if (m_pendingProfile < 0 || m_pendingProfile >= m_profiles.size() || m_pendingBuild < 0) {
        failLaunch(tr("Запуск был отменён"));
        return;
    }

    const int buildIndex = indexOfBuildId(m_pendingBuild);
    if (buildIndex < 0) {
        failLaunch(tr("Сборка была удалена, пока шла авторизация"));
        return;
    }

    Profile &profile = m_profiles[m_pendingProfile];
    const CustomBuild &build = m_builds[buildIndex];

    // Ely.by возвращает игровой ник и UUID аккаунта — их и запоминаем,
    // чтобы в следующий раз обойтись без пароля.
    if (profile.authType == kMicrosoft && auth.ok) {
        applyMicrosoftResult(profile, auth);
        saveProfiles();
    } else if (!auth.accessToken.isEmpty() && profile.authType == kElyBy) {
        profile.accessToken = auth.accessToken;
        profile.clientToken = auth.clientToken;
        if (!auth.uuid.isEmpty())
            profile.uuid = auth.uuid;
        if (!auth.playerName.isEmpty())
            profile.resolvedName = auth.playerName;
        profile.userType = auth.userType;
        saveProfiles();
    }

    LaunchOptions options;
    options.gameDir = gameDir();
    options.versionId = m_pendingVersionData.id;
    options.playerName = auth.playerName.isEmpty() ? profile.resolvedName : auth.playerName;
    if (options.playerName.isEmpty())
        options.playerName = profile.login.isEmpty() ? profile.name : profile.login;
    options.uuid = auth.uuid.isEmpty() ? profile.uuid : auth.uuid;
    options.accessToken = auth.accessToken;
    options.clientToken = auth.clientToken;
    options.userType = auth.userType;
    options.xuid = auth.xuid;

    // Скачанная лаунчером сборка важнее пути из настроек: её выбрали явно в
    // окне выбора версии Java.
    options.javaPath = managedJavaPath();
    if (options.javaPath.isEmpty()) {
        if (!m_settings.value(QStringLiteral("javaRuntime")).toString().trimmed().isEmpty())
            emit launchProgress(tr("Выбранная сборка Java не найдена в папке лаунчера — "
                                   "ищем установленную в системе"));
        options.javaPath = m_settings.value(QStringLiteral("javaPath")).toString();
    }
    options.minMemoryMb = settingInt(m_settings, QStringLiteral("minMemoryMb"), 512);
    options.maxMemoryMb = settingInt(m_settings, QStringLiteral("maxMemoryMb"), 4096);
    options.windowWidth = settingInt(m_settings, QStringLiteral("windowWidth"), 0);
    options.windowHeight = settingInt(m_settings, QStringLiteral("windowHeight"), 0);
    options.fullscreen = m_settings.value(QStringLiteral("fullscreen")).toBool();
    options.serverAddress = build.serverUrl.trimmed();
    options.launcherName = m_settings.value(QStringLiteral("launcherName")).toString();
    options.launcherVersion = m_settings.value(QStringLiteral("launcherVersion")).toString();

    const QString extra = m_settings.value(QStringLiteral("jvmArgs")).toString().trimmed();
    if (!extra.isEmpty())
        options.extraJvmArgs = extra.split(QRegularExpression(QStringLiteral("\\s+")),
                                           Qt::SkipEmptyParts);

    if (profile.authType == kElyBy)
        options.authlibInjectorPath = m_pendingInjectorPath;

    QString error;
    const bool started = m_launcher->launch(options, m_pendingVersionData, &error);

    const QString profileName = profile.name;
    const QString buildName = build.name;
    const QString serverUrl = build.serverUrl;
    cancelPendingLaunch();

    if (!started) {
        emit launchError(error);
        return;
    }
    emit gameRunningChanged();
    emit launched(profileName, buildName, serverUrl);
}

void LauncherBackend::stopGame()
{
    m_launcher->terminate();
}

void LauncherBackend::failLaunch(const QString &message)
{
    cancelPendingLaunch();
    emit launchError(message);
}

bool LauncherBackend::switching() const
{
    return m_switcher->isRunning();
}

double LauncherBackend::switchProgress() const
{
    return m_switcher->fraction();
}

QString LauncherBackend::switchStage() const
{
    return m_switcher->stage();
}

QString LauncherBackend::switchStatus() const
{
    return m_switcher->status();
}

bool LauncherBackend::busy() const
{
    return m_busy || m_installer->isRunning() || m_loaderInstaller->isRunning()
           || m_javaInstaller->isRunning() || m_switcher->isRunning()
           || m_packDownloader->isRunning();
}

bool LauncherBackend::microsoftAvailable() const
{
#ifdef LAUNCHER_HAS_WEBENGINE
    return true;
#else
    return false;
#endif
}

void LauncherBackend::setBusy(bool busy)
{
    if (m_busy == busy)
        return;
    m_busy = busy;
    emit busyChanged();
}

QString LauncherBackend::gameDir() const
{
    const QString configured = m_settings.value(QStringLiteral("gameDir")).toString().trimmed();
    return configured.isEmpty() ? LauncherPaths::defaultMinecraftDir()
                                : QDir::fromNativeSeparators(configured);
}

QVariantMap LauncherBackend::settings() const
{
    QVariantMap result = m_settings;
    result[QStringLiteral("resolvedGameDir")] = QDir::toNativeSeparators(gameDir());
    return result;
}

void LauncherBackend::updateSettings(const QVariantMap &values)
{
    const QString previousGameDir = gameDir();
    for (auto it = values.cbegin(); it != values.cend(); ++it)
        m_settings.insert(it.key(), it.value());
    saveSettings();
    emit settingsChanged();
    if (gameDir() != previousGameDir) {
        invalidateCatalog();
        emit installedVersionsChanged();
    }
}

QStringList LauncherBackend::detectedJava() const
{
    QStringList result;
    const QStringList candidates = JavaLocator::findAll(gameDir());
    for (const QString &candidate : candidates) {
        const int major = JavaLocator::majorVersion(candidate);
        result << (major > 0 ? QStringLiteral("Java %1 — %2")
                                   .arg(major)
                                   .arg(QDir::toNativeSeparators(candidate))
                             : QDir::toNativeSeparators(candidate));
    }
    return result;
}

// ── Сборки Java ────────────────────────────────────────────────────────────

void LauncherBackend::invalidateJavaCatalog()
{
    m_javaCatalogDirty = true;
    emit javaCatalogChanged();
}

bool LauncherBackend::javaCatalogLoading() const
{
    return m_javaMeta->isRefreshing();
}

void LauncherBackend::refreshJavaCatalog(bool force)
{
    // Список установленного мог поменяться и без нас (папку удалили руками).
    invalidateJavaCatalog();
    m_javaMeta->ensureLoaded([this](bool ok, const QString &warning) {
        if (!ok && !warning.isEmpty())
            emit launchError(warning);
    }, force);
}

QVariantList LauncherBackend::javaCatalog() const
{
    if (m_javaCatalogDirty) {
        rebuildJavaCatalog();
        m_javaCatalogDirty = false;
    }
    return m_javaCatalog;
}

void LauncherBackend::rebuildJavaCatalog() const
{
    m_javaCatalog.clear();

    const QList<InstalledJavaRuntime> installed = JavaRuntimeStore::installed();
    QHash<QString, qint64> installedSizes;
    for (const InstalledJavaRuntime &runtime : installed)
        installedSizes.insert(runtime.id, runtime.size);

    const auto megabytes = [](qint64 bytes) {
        return bytes > 0 ? int((bytes + 512 * 1024) / (1024 * 1024)) : 0;
    };

    const auto makeRow = [&megabytes](const QString &id,
                                      const QString &kind,
                                      int major,
                                      const QString &version,
                                      const QString &detail,
                                      qint64 size,
                                      bool isInstalled,
                                      bool lts,
                                      bool downloadable) {
        return QVariantMap{
            {QStringLiteral("id"), id},
            {QStringLiteral("kind"), kind},
            {QStringLiteral("major"), major},
            {QStringLiteral("version"), version},
            {QStringLiteral("label"), QStringLiteral("Java %1").arg(version)},
            {QStringLiteral("detail"), detail},
            {QStringLiteral("coverage"), JavaRequirement::coverage(major)},
            {QStringLiteral("sizeMb"), megabytes(size)},
            {QStringLiteral("installed"), isInstalled},
            {QStringLiteral("lts"), lts},
            {QStringLiteral("downloadable"), downloadable},
            {QStringLiteral("search"),
             QStringLiteral("%1 %2 %3 %4").arg(version, QString::number(major), kind, detail).toLower()}};
    };

    // Скачанное — сверху: этим уже можно пользоваться прямо сейчас.
    QSet<QString> listed;
    for (const InstalledJavaRuntime &runtime : installed) {
        listed.insert(runtime.id);
        const auto remote = m_javaMeta->find(runtime.id);
        const QString detail = remote ? (remote->kind == JavaRuntimeKind::Mojang
                                             ? tr("Mojang · %1").arg(remote->component)
                                             : tr("Temurin · %1").arg(remote->architecture))
                                      : tr("скачана лаунчером");
        m_javaCatalog.append(makeRow(runtime.id,
                                     runtime.kind,
                                     runtime.major,
                                     runtime.version,
                                     detail,
                                     runtime.size,
                                     true,
                                     remote && remote->lts,
                                     remote.has_value()));
    }

    const QList<JavaRuntimeKind> kinds{JavaRuntimeKind::Mojang, JavaRuntimeKind::Jdk,
                                       JavaRuntimeKind::Jre};
    for (const JavaRuntimeKind kind : kinds) {
        const QList<JavaRuntimeEntry> entries = m_javaMeta->entries(kind);
        for (const JavaRuntimeEntry &entry : entries) {
            if (listed.contains(entry.id))
                continue;
            const QString detail = kind == JavaRuntimeKind::Mojang
                                       ? tr("Mojang · %1").arg(entry.component)
                                       : tr("Temurin · %1").arg(entry.architecture);
            m_javaCatalog.append(makeRow(entry.id,
                                         javaKindKey(kind),
                                         entry.major,
                                         entry.version,
                                         detail,
                                         entry.size,
                                         false,
                                         entry.lts,
                                         true));
        }
    }
}

void LauncherBackend::installJavaRuntime(const QString &runtimeId)
{
    if (m_javaInstaller->isRunning()) {
        emit launchError(tr("Идёт установка Java «%1» — дождитесь окончания")
                             .arg(m_javaInstaller->label()));
        return;
    }
    if (m_installer->isRunning() || m_loaderInstaller->isRunning() || m_switcher->isRunning()) {
        emit launchError(tr("Дождитесь окончания текущей операции"));
        return;
    }

    const auto entry = m_javaMeta->find(runtimeId);
    if (!entry) {
        emit launchError(tr("Сборка Java «%1» не найдена в каталоге").arg(runtimeId));
        return;
    }
    m_javaInstaller->install(*entry);
}

void LauncherBackend::removeJavaRuntime(const QString &runtimeId)
{
    if (m_javaInstaller->isRunning() && m_javaInstaller->runtimeId() == runtimeId) {
        emit launchError(tr("Эта сборка Java сейчас скачивается"));
        return;
    }

    QString error;
    if (!JavaRuntimeStore::remove(runtimeId, &error)) {
        emit launchError(tr("Не удалось удалить сборку Java: %1").arg(error));
        return;
    }

    // Удалили выбранную — настройка указывала бы в пустоту.
    if (m_settings.value(QStringLiteral("javaRuntime")).toString() == runtimeId) {
        m_settings.insert(QStringLiteral("javaRuntime"), QString());
        saveSettings();
        emit settingsChanged();
    }
    invalidateJavaCatalog();
    emit launchProgress(tr("Сборка Java удалена"));
}

QVariantMap LauncherBackend::javaRuntimeInfo(const QString &runtimeId) const
{
    if (runtimeId.trimmed().isEmpty())
        return {};

    const auto runtime = JavaRuntimeStore::find(runtimeId);
    if (runtime) {
        return QVariantMap{{QStringLiteral("id"), runtime->id},
                           {QStringLiteral("kind"), runtime->kind},
                           {QStringLiteral("major"), runtime->major},
                           {QStringLiteral("version"), runtime->version},
                           {QStringLiteral("label"), QStringLiteral("Java %1").arg(runtime->version)},
                           {QStringLiteral("javaPath"), QDir::toNativeSeparators(runtime->javaPath)},
                           {QStringLiteral("installed"), true}};
    }

    // Выбрана, но ещё не скачана: подпись всё равно нужна.
    const auto entry = m_javaMeta->find(runtimeId);
    if (!entry)
        return QVariantMap{{QStringLiteral("id"), runtimeId},
                           {QStringLiteral("label"), runtimeId},
                           {QStringLiteral("installed"), false}};

    return QVariantMap{{QStringLiteral("id"), entry->id},
                       {QStringLiteral("kind"), javaKindKey(entry->kind)},
                       {QStringLiteral("major"), entry->major},
                       {QStringLiteral("version"), entry->version},
                       {QStringLiteral("label"), QStringLiteral("Java %1").arg(entry->version)},
                       {QStringLiteral("javaPath"), QString()},
                       {QStringLiteral("installed"), false}};
}

int LauncherBackend::requiredJavaMajor(int buildIndex) const
{
    if (buildIndex < 0 || buildIndex >= m_builds.size())
        return 0;
    return JavaRequirement::minimumFor(m_builds.at(buildIndex).minecraftVersion);
}

QString LauncherBackend::managedJavaPath() const
{
    const QString id = m_settings.value(QStringLiteral("javaRuntime")).toString();
    if (id.trimmed().isEmpty())
        return {};
    const auto runtime = JavaRuntimeStore::find(id);
    return runtime ? runtime->javaPath : QString();
}

// ── Сезонные сборки ────────────────────────────────────────────────────────

QUrl LauncherBackend::seasonalBaseUrl() const
{
    const QString configured =
        m_settings.value(QStringLiteral("seasonalBaseUrl")).toString().trimmed();
    QUrl url(configured.isEmpty() ? kSeasonalBaseUrl : configured);
    // Хвостовой слэш превратил бы адрес каталога в «…//index.json».
    while (url.path().endsWith(u'/'))
        url.setPath(url.path().chopped(1));
    return url;
}

void LauncherBackend::invalidateSeasonalCatalog()
{
    m_seasonalCatalogDirty = true;
    emit seasonalCatalogChanged();
}

int LauncherBackend::indexOfSeasonalBuild(const QString &seasonalId) const
{
    if (seasonalId.isEmpty())
        return -1;
    for (int i = 0; i < m_builds.size(); ++i) {
        if (m_builds[i].seasonalId == seasonalId)
            return i;
    }
    return -1;
}

void LauncherBackend::rebuildSeasonalCatalog() const
{
    m_seasonalCatalog.clear();
    const QList<SeasonalBuildEntry> builds = m_seasonalMeta->builds();
    m_seasonalCatalog.reserve(builds.size());

    for (const SeasonalBuildEntry &entry : builds) {
        int installedRevision = 0;
        bool installed = false;
        for (const CustomBuild &build : m_builds) {
            if (build.seasonalId != entry.id)
                continue;
            installed = true;
            installedRevision = build.seasonalRevision;
            break;
        }
        const bool updateAvailable = installed && installedRevision < entry.revision;

        QString status;
        if (!installed)
            status = tr("Не установлена");
        else if (updateAvailable)
            status = tr("Есть обновление");
        else
            status = tr("Установлена");

        const auto loader = loaderFromKey(entry.loader);
        m_seasonalCatalog.append(QVariantMap{
            {QStringLiteral("id"), entry.id},
            {QStringLiteral("name"), entry.name},
            {QStringLiteral("minecraftVersion"), entry.minecraftVersion},
            {QStringLiteral("loader"), entry.loader},
            {QStringLiteral("loaderTitle"), loader ? loaderTitle(*loader) : tr("Ванильная")},
            {QStringLiteral("loaderVersion"), entry.loaderVersion},
            {QStringLiteral("modCount"), entry.modCount},
            {QStringLiteral("seasonStart"), formatSeasonDate(entry.seasonStart)},
            {QStringLiteral("seasonEnd"), formatSeasonDate(entry.seasonEnd)},
            {QStringLiteral("sizeBytes"), entry.archiveSize},
            {QStringLiteral("revision"), entry.revision},
            {QStringLiteral("description"), entry.description},
            {QStringLiteral("serverUrl"), entry.serverUrl},
            {QStringLiteral("installed"), installed},
            {QStringLiteral("installedRevision"), installedRevision},
            {QStringLiteral("updateAvailable"), updateAvailable},
            {QStringLiteral("status"), status}});
    }
    m_seasonalCatalogDirty = false;
}

QVariantList LauncherBackend::seasonalCatalog() const
{
    if (m_seasonalCatalogDirty)
        rebuildSeasonalCatalog();
    return m_seasonalCatalog;
}

bool LauncherBackend::seasonalCatalogLoading() const
{
    return m_seasonalMeta->isRefreshing();
}

QString LauncherBackend::seasonalCatalogError() const
{
    return m_seasonalMeta->lastError();
}

bool LauncherBackend::seasonalInstalling() const
{
    return m_season.stage != SeasonStage::Idle;
}

void LauncherBackend::refreshSeasonalCatalog(bool force)
{
    // Адрес мог смениться в settings.json между запусками окна.
    m_seasonalMeta->setBaseUrl(seasonalBaseUrl());
    m_seasonalMeta->ensureLoaded(nullptr, force);
}

// ── Установка сезонной сборки ──────────────────────────────────────────────

void LauncherBackend::installSeasonalBuild(const QString &seasonalId)
{
    if (m_season.stage != SeasonStage::Idle) {
        emit launchError(tr("Установка сборки уже идёт"));
        return;
    }
    if (m_launcher->isRunning()) {
        emit launchError(tr("Сначала закройте игру — её файлы сейчас переносятся"));
        return;
    }
    if (busy()) {
        emit launchError(tr("Дождитесь окончания текущей операции"));
        return;
    }

    const auto entry = m_seasonalMeta->find(seasonalId);
    if (!entry) {
        emit launchError(tr("Сборка «%1» не найдена в списке — обновите его").arg(seasonalId));
        return;
    }

    m_season = {};
    m_season.entry = *entry;

    // Сборка с таким seasonalId уже заведена — обновляем её описание под новую
    // ревизию, а не плодим вторую запись.
    int index = indexOfSeasonalBuild(seasonalId);
    if (index < 0) {
        CustomBuild build;
        build.id = m_nextBuildId++;
        build.name = entry->name;
        build.serverUrl = entry->serverUrl;
        build.minecraftVersion = entry->minecraftVersion;
        build.loader = entry->loader;
        build.loaderVersion = entry->loaderVersion;
        build.seasonalId = entry->id;
        // Ревизия появится только когда файлы лягут на диск: иначе оборванная
        // установка выглядела бы завершённой.
        m_builds.append(build);
        index = m_builds.size() - 1;
        saveCustomBuilds();
        emit customBuildsChanged();
        // maybeInstall() здесь намеренно нет: версию и модлоадер поставит шаг
        // цепочки, а параллельная загрузка только мешала бы ему.
    } else {
        updateCustomBuild(index,
                          {{QStringLiteral("name"), entry->name},
                           {QStringLiteral("serverUrl"), entry->serverUrl},
                           {QStringLiteral("minecraftVersion"), entry->minecraftVersion},
                           {QStringLiteral("loader"), entry->loader},
                           {QStringLiteral("loaderVersion"), entry->loaderVersion}});
    }

    m_season.buildId = m_builds[index].id;
    setBusy(true);
    emit launchProgress(tr("Установка сборки «%1»…").arg(entry->name));
    // Сигнал после seasonAdvance(): шаг цепочки выставляет stage сам, а до его
    // вызова seasonalInstalling() ещё вернул бы false.
    seasonAdvance();
    emit seasonalInstallingChanged();
}

void LauncherBackend::seasonAdvance()
{
    if (indexOfBuildId(m_season.buildId) < 0) {
        seasonFail(tr("запись сборки исчезла"));
        return;
    }

    // Каждый шаг либо начинает свою асинхронную работу и обрывает цепочку до
    // следующего сигнала, либо уступает следующему — отсюда провалы между
    // ветками.
    switch (m_season.stage) {
    case SeasonStage::Idle:
        if (seasonBeginSwitch())
            return;
        [[fallthrough]];
    case SeasonStage::Switching:
        if (seasonBeginJava())
            return;
        [[fallthrough]];
    case SeasonStage::Java:
        if (seasonBeginLoader())
            return;
        [[fallthrough]];
    case SeasonStage::Loader:
        if (seasonBeginDownload())
            return;
        [[fallthrough]];
    case SeasonStage::Downloading:
        if (seasonBeginApply())
            return;
        [[fallthrough]];
    case SeasonStage::Applying:
        seasonFinish();
        return;
    }
}

bool LauncherBackend::seasonBeginSwitch()
{
    const int index = indexOfBuildId(m_season.buildId);
    const int current = activeBuildIndex();
    if (current == index)
        return false;

    // Пак ложится на то, что сейчас разложено в .minecraft, поэтому сборка
    // должна стать активной до всего остального. Содержимое прежней уезжает в
    // её архив обычным путём.
    m_season.stage = SeasonStage::Switching;
    emit launchProgress(tr("Смена сборки на «%1»…").arg(m_builds[index].name));
    m_switcher->switchTo(current >= 0 ? m_builds[current].id : -1,
                         current >= 0 ? m_builds[current].name : QString(),
                         m_builds[index].id,
                         m_builds[index].name,
                         gameDir());
    emit switchChanged();
    emit busyChanged();
    return true;
}

bool LauncherBackend::seasonBeginJava()
{
    // Сервер может назвать нужную Java прямо; иначе спрашиваем таблицу.
    const int major = m_season.entry.javaMajor > 0
                          ? m_season.entry.javaMajor
                          : JavaRequirement::minimumFor(m_season.entry.minecraftVersion);
    if (major <= 0)
        return false;

    // Java уже есть — своя, системная или скачанная раньше.
    QString error;
    if (!JavaLocator::select(gameDir(), major, QString(), &error).isEmpty())
        return false;

    m_season.stage = SeasonStage::Java;
    emit launchProgress(tr("Для сборки нужна Java %1 — устанавливаем").arg(major));

    // Каталог Java мог ещё ни разу не читаться: окно выбора могли не открывать.
    m_javaMeta->ensureLoaded([this, major](bool ok, const QString &warning) {
        if (m_season.stage != SeasonStage::Java)
            return;
        if (!ok) {
            seasonFail(warning.isEmpty() ? tr("не удалось получить каталог Java") : warning);
            return;
        }
        const auto entry = m_javaMeta->bestFor(major);
        if (!entry) {
            seasonFail(tr("в каталоге нет сборки Java %1 для этой системы").arg(major));
            return;
        }
        m_javaInstaller->install(*entry);
    });
    return true;
}

bool LauncherBackend::seasonBeginLoader()
{
    const int index = indexOfBuildId(m_season.buildId);
    const CustomBuild &build = m_builds[index];
    const auto loader = loaderFromKey(build.loader);

    // Ванильная сборка: ставить надо только саму версию игры.
    if (!loader) {
        if (checkInstallation(index).isEmpty())
            return false;
        m_season.stage = SeasonStage::Loader;
        m_installer->install(gameDir(), build.minecraftVersion);
        return true;
    }

    // Профиль лоадера уже собран и укомплектован — второй раз не ставим.
    if (!build.resolvedVersionId.isEmpty() && checkInstallation(index).isEmpty())
        return false;

    m_season.stage = SeasonStage::Loader;
    m_loaderInstallBuildId = build.id;
    m_loaderInstaller->install(gameDir(),
                               *loader,
                               build.minecraftVersion,
                               build.loaderVersion,
                               managedJavaPath().isEmpty()
                                   ? m_settings.value(QStringLiteral("javaPath")).toString()
                                   : managedJavaPath());
    return true;
}

bool LauncherBackend::seasonBeginDownload()
{
    const SeasonalBuildEntry &entry = m_season.entry;
    // Точка в начале имени: файл служебный, и BuildSwitcher по этому признаку
    // не примет его за архив сборки и не сотрёт при следующем переключении.
    const QString path = LauncherPaths::buildDir(m_season.buildId)
                         + QStringLiteral("/.pack-r") + QString::number(entry.revision)
                         + QStringLiteral(".zip");

    m_season.stage = SeasonStage::Downloading;
    m_packDownloader->download(entry.archiveUrl,
                               path,
                               entry.archiveSha256,
                               entry.archiveSize,
                               entry.name);
    return true;
}

bool LauncherBackend::seasonBeginApply()
{
    if (m_season.packPath.isEmpty()) {
        seasonFail(tr("файлы сборки не скачаны"));
        return true;
    }

    // Под удаление идёт весь список прошлой ревизии, а не разница с новой:
    // распаковка всё равно кладёт каждый файл пака заново, поэтому разница
    // сэкономила бы лишь перезапись того, что и так перезаписывается, а вот
    // ушедшие из сборки моды так убираются наверняка. Миров и скриншотов в
    // списке нет — их пак не приносил.
    const QStringList remove = seasonalPackEntries(m_season.buildId);

    const QJsonObject note{{QStringLiteral("seasonalId"), m_season.entry.id},
                           {QStringLiteral("revision"), m_season.entry.revision},
                           {QStringLiteral("sha256"), m_season.entry.archiveSha256}};

    const int index = indexOfBuildId(m_season.buildId);
    m_season.stage = SeasonStage::Applying;
    m_switcher->applyPack(m_season.buildId,
                          m_builds[index].name,
                          m_season.packPath,
                          remove,
                          note,
                          gameDir());
    emit switchChanged();
    emit busyChanged();
    return true;
}

void LauncherBackend::seasonFinish()
{
    const int index = indexOfBuildId(m_season.buildId);
    const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
    const QString seasonalId = m_season.entry.id;

    m_season = {};
    setBusy(false);
    invalidateSeasonalCatalog();
    emit seasonalInstallingChanged();
    emit launchProgress(tr("Сборка «%1» установлена").arg(name));
    emit seasonalInstallFinished(seasonalId, name);
}

void LauncherBackend::seasonFail(const QString &message)
{
    const int index = indexOfBuildId(m_season.buildId);
    const QString name = index >= 0 ? m_builds[index].name : m_season.entry.name;
    // Недокачанный или нераскатанный пак не должен занимать место: цепочку
    // всегда можно начать заново с чистого листа.
    if (!m_season.packPath.isEmpty())
        QFile::remove(m_season.packPath);

    m_season = {};
    setBusy(false);
    emit seasonalInstallingChanged();
    // Пустое message означает, что о причине уже сообщил тот, кто её нашёл, —
    // второй раз ту же ошибку не показываем.
    emit launchError(message.isEmpty()
                         ? tr("Установка сборки «%1» прервана").arg(name)
                         : tr("Установка сборки «%1» прервана: %2").arg(name, message));
}

void LauncherBackend::cancelSeasonalInstall()
{
    switch (m_season.stage) {
    case SeasonStage::Idle:
        return;
    case SeasonStage::Java:
        m_javaInstaller->cancel();
        return;
    case SeasonStage::Loader:
        if (m_loaderInstaller->isRunning())
            m_loaderInstaller->cancel();
        else
            m_installer->cancel();
        return;
    case SeasonStage::Downloading:
        m_packDownloader->cancel();
        return;
    case SeasonStage::Switching:
    case SeasonStage::Applying:
        // На этих шагах содержимое .minecraft уже разобрано: отступать некуда,
        // операцию нужно довести до конца.
        emit launchError(tr("Сейчас переносятся файлы — отменить нельзя"));
        return;
    }
}

// ── Состояние установленной сезонной сборки ────────────────────────────────

QStringList LauncherBackend::seasonalPackEntries(int buildId) const
{
    QFile file(LauncherPaths::seasonalStateFile(buildId));
    if (!file.open(QIODevice::ReadOnly))
        return {};

    const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
    QStringList entries;
    const QJsonArray array = root.value(QStringLiteral("entries")).toArray();
    entries.reserve(array.size());
    for (const QJsonValue &value : array)
        entries << value.toString();
    return entries;
}

void LauncherBackend::recordSeasonalPack(int buildId,
                                         const QJsonObject &note,
                                         const QStringList &entries)
{
    const QString seasonalId = note.value(QStringLiteral("seasonalId")).toString();
    const int revision = note.value(QStringLiteral("revision")).toInt();
    if (seasonalId.isEmpty() || revision <= 0)
        return;

    const QJsonObject state{{QStringLiteral("seasonalId"), seasonalId},
                            {QStringLiteral("revision"), revision},
                            {QStringLiteral("sha256"),
                             note.value(QStringLiteral("sha256")).toString()},
                            {QStringLiteral("installedAt"),
                             QDateTime::currentDateTimeUtc().toString(Qt::ISODate)},
                            {QStringLiteral("entries"), QJsonArray::fromStringList(entries)}};
    // Не через writeJsonFile: файл лежит в папке сборки, а не в корне лаунчера,
    // и его отсутствие не мешает игре — ошибку достаточно показать.
    QDir().mkpath(LauncherPaths::buildDir(buildId));
    QSaveFile file(LauncherPaths::seasonalStateFile(buildId));
    if (file.open(QIODevice::WriteOnly)) {
        const QByteArray payload = QJsonDocument(state).toJson(QJsonDocument::Indented);
        if (file.write(payload) == payload.size())
            file.commit();
        else
            file.cancelWriting();
    }

    const int index = indexOfBuildId(buildId);
    if (index < 0)
        return;
    // Ревизия проставляется только здесь: до этого момента файлы сборки на
    // диске не лежали.
    updateCustomBuild(index,
                      {{QStringLiteral("seasonalId"), seasonalId},
                       {QStringLiteral("seasonalRevision"), revision}});
}

void LauncherBackend::openMinecraftFolder()
{
    const QString modsDir = gameDir() + QStringLiteral("/mods");
    QDir().mkpath(modsDir);

    if (!QDesktopServices::openUrl(QUrl::fromLocalFile(modsDir)))
        emit launchError(tr("Не удалось открыть папку с модами"));
}

void LauncherBackend::openGameFolder()
{
    const QString directory = gameDir();
    QDir().mkpath(directory);

    if (!QDesktopServices::openUrl(QUrl::fromLocalFile(directory)))
        emit launchError(tr("Не удалось открыть папку игры"));
}

// Повреждённый файл не удаляем: отводим в .bak, чтобы данные можно было спасти
// руками, а ensureDataFiles() создал на его месте пустой.
bool LauncherBackend::moveAsideBroken(const QString &path, const QString &reason)
{
    const QString backup = path + QStringLiteral(".bak");
    QFile::remove(backup);
    if (!QFile::rename(path, backup)) {
        m_storageIssues << tr("Файл %1 повреждён (%2), и его не удалось отложить в сторону")
                               .arg(QDir::toNativeSeparators(path), reason);
        return false;
    }

    m_storageIssues << tr("Файл %1 повреждён (%2). Копия сохранена как %3, файл создан заново.")
                           .arg(QDir::toNativeSeparators(path),
                                reason,
                                QDir::toNativeSeparators(backup));
    return true;
}

QJsonArray LauncherBackend::readArrayFile(const QString &path)
{
    QFile file(path);
    if (!file.exists())
        return {};   // первый запуск — это не ошибка
    if (!file.open(QIODevice::ReadOnly)) {
        m_storageIssues << tr("Не удалось прочитать %1: %2")
                               .arg(QDir::toNativeSeparators(path), file.errorString());
        return {};
    }

    QJsonParseError parseError;
    const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
    if (parseError.error != QJsonParseError::NoError) {
        file.close();
        moveAsideBroken(path, parseError.errorString());
        return {};
    }
    if (!document.isArray()) {
        file.close();
        moveAsideBroken(path, tr("ожидался список записей"));
        return {};
    }
    return document.array();
}

QJsonObject LauncherBackend::readObjectFile(const QString &path)
{
    QFile file(path);
    if (!file.exists())
        return {};
    if (!file.open(QIODevice::ReadOnly)) {
        m_storageIssues << tr("Не удалось прочитать %1: %2")
                               .arg(QDir::toNativeSeparators(path), file.errorString());
        return {};
    }

    QJsonParseError parseError;
    const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
    if (parseError.error != QJsonParseError::NoError) {
        file.close();
        moveAsideBroken(path, parseError.errorString());
        return {};
    }
    if (!document.isObject()) {
        file.close();
        moveAsideBroken(path, tr("ожидался набор настроек"));
        return {};
    }
    return document.object();
}

// Перед каждой записью убеждаемся, что папка лаунчера на месте: её могли удалить
// уже после запуска, и тогда данные молча пропадали бы.
bool LauncherBackend::writeJsonFile(const QString &path, const QJsonDocument &document)
{
    QString storageError;
    if (!LauncherPaths::ensureRootExists(&storageError)) {
        emit launchError(tr("Не удалось создать папку лаунчера: %1").arg(storageError));
        return false;
    }

    const QByteArray payload = document.toJson();
    QFile file(path);
    if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()) {
        emit launchError(tr("Не удалось сохранить %1: %2")
                             .arg(QDir::toNativeSeparators(path), file.errorString()));
        return false;
    }
    return true;
}

void LauncherBackend::loadData()
{
    auto loadFile = [this](const QString &path, auto handler) {
        for (const auto &val : readArrayFile(path))
            handler(val.toObject());
    };

    loadFile(LauncherPaths::profilesFile(), [this](const QJsonObject &o) {
        Profile profile;
        profile.id = o["id"].toInt();
        profile.name = o["name"].toString();
        profile.login = o["login"].toString();
        profile.password = o["password"].toString();
        profile.authType = o.value("authType").toString(kOffline);
        profile.clientToken = o.value("clientToken").toString();
        profile.accessToken = o.value("accessToken").toString();
        profile.uuid = o.value("uuid").toString();
        profile.resolvedName = o.value("resolvedName").toString();
        profile.userType = o.value("userType").toString();
        profile.refreshToken = o.value("refreshToken").toString();
        profile.xuid = o.value("xuid").toString();
        profile.tokenExpiry = o.value("tokenExpiry").toString();
        if (profile.clientToken.isEmpty())
            profile.clientToken = AuthService::generateClientToken();
        m_profiles.append(profile);
        if (profile.id >= m_nextProfileId)
            m_nextProfileId = profile.id + 1;
    });
    std::sort(m_profiles.begin(), m_profiles.end(),
              [](const Profile &a, const Profile &b) { return a.id < b.id; });

    int skippedBuilds = 0;
    loadFile(LauncherPaths::customBuildsFile(), [this, &skippedBuilds](const QJsonObject &o) {
        CustomBuild build;
        build.id = o["id"].toInt();
        build.name = o["name"].toString();
        build.serverUrl = o["serverUrl"].toString();
        build.minecraftVersion = o.value("minecraftVersion").toString();
        // Файл мог остаться от версии лаунчера до переименования или быть
        // поправлен руками — старый ключ тоже принимаем.
        if (build.minecraftVersion.isEmpty())
            build.minecraftVersion = o.value("versionId").toString();
        build.loader = o.value("loader").toString();
        build.loaderVersion = o.value("loaderVersion").toString();
        build.resolvedVersionId = o.value("resolvedVersionId").toString();
        build.seasonalId = o.value("seasonalId").toString();
        build.seasonalRevision = o.value("seasonalRevision").toInt();

        // Без имени и без версии запись нерабочая: launchVersionIdOf() вернёт
        // пустую строку.
        if (build.name.trimmed().isEmpty() && build.minecraftVersion.trimmed().isEmpty()) {
            ++skippedBuilds;
            return;
        }
        m_builds.append(build);
        if (build.id >= m_nextBuildId)
            m_nextBuildId = build.id + 1;
    });
    if (skippedBuilds > 0) {
        m_storageIssues << tr("В %1 пропущены сборки без имени и версии Minecraft: %2")
                               .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()))
                               .arg(skippedBuilds);
    }
    std::sort(m_builds.begin(), m_builds.end(),
              [](const CustomBuild &a, const CustomBuild &b) { return a.id < b.id; });
}

// Первый запуск после переименования «версий» в «сборки»: перекладываем
// <root>/versions.json в customBuilds.json. Старый файл не удаляем, а
// переименовываем — он остаётся резервной копией.
bool LauncherBackend::migrateLegacyBuildsFile()
{
    if (QFile::exists(LauncherPaths::customBuildsFile()))
        return false;
    if (!QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
        return false;

    // readArrayFile() сам отводит битый файл в .bak — тогда мигрировать нечего.
    const QJsonArray legacy = readArrayFile(LauncherPaths::legacyCustomBuildsFile());
    if (legacy.isEmpty() && !QFile::exists(LauncherPaths::legacyCustomBuildsFile()))
        return false;

    QJsonArray migrated;
    for (const auto &value : legacy) {
        const QJsonObject o = value.toObject();
        migrated.append(QJsonObject{{"id", o.value("id").toInt()},
                                    {"name", o.value("name").toString()},
                                    {"serverUrl", o.value("serverUrl").toString()},
                                    {"minecraftVersion", o.value("versionId").toString()},
                                    {"loader", QString()},
                                    {"loaderVersion", QString()},
                                    {"resolvedVersionId", QString()}});
    }

    // Не удалось записать — молча читаем в этой сессии из старого файла.
    // Без данных пользователь не остаётся ни при каком исходе.
    if (!writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(migrated)))
        return false;

    const QString retired = LauncherPaths::legacyCustomBuildsFile() + QStringLiteral(".migrated");
    QFile::remove(retired);
    if (!QFile::rename(LauncherPaths::legacyCustomBuildsFile(), retired)) {
        m_storageIssues << tr("Сборки перенесены в %1, но старый файл %2 удалить не удалось")
                               .arg(QDir::toNativeSeparators(LauncherPaths::customBuildsFile()),
                                    QDir::toNativeSeparators(
                                        LauncherPaths::legacyCustomBuildsFile()));
    }
    return true;
}

// Файлы могли не появиться при первом запуске, быть удалены или отложены в .bak —
// создаём заново, чтобы папка лаунчера всегда была укомплектована.
void LauncherBackend::ensureDataFiles()
{
    if (!QFile::exists(LauncherPaths::settingsFile()))
        saveSettings();
    if (!QFile::exists(LauncherPaths::profilesFile()))
        saveProfiles();
    if (!QFile::exists(LauncherPaths::customBuildsFile()))
        saveCustomBuilds();
}

void LauncherBackend::saveProfiles()
{
    QJsonArray arr;
    for (const auto &p : m_profiles)
        arr.append(QJsonObject{{"id", p.id},
                               {"name", p.name},
                               {"login", p.login},
                               {"password", p.password},
                               {"authType", p.authType},
                               {"clientToken", p.clientToken},
                               {"accessToken", p.accessToken},
                               {"uuid", p.uuid},
                               {"resolvedName", p.resolvedName},
                               {"userType", p.userType},
                               {"refreshToken", p.refreshToken},
                               {"xuid", p.xuid},
                               {"tokenExpiry", p.tokenExpiry}});

    writeJsonFile(LauncherPaths::profilesFile(), QJsonDocument(arr));
}

void LauncherBackend::saveCustomBuilds()
{
    QJsonArray arr;
    for (const auto &b : m_builds)
        arr.append(QJsonObject{{"id", b.id},
                               {"name", b.name},
                               {"serverUrl", b.serverUrl},
                               {"minecraftVersion", b.minecraftVersion},
                               {"loader", b.loader},
                               {"loaderVersion", b.loaderVersion},
                               {"resolvedVersionId", b.resolvedVersionId},
                               {"seasonalId", b.seasonalId},
                               {"seasonalRevision", b.seasonalRevision}});

    writeJsonFile(LauncherPaths::customBuildsFile(), QJsonDocument(arr));
}

void LauncherBackend::loadSettings()
{
    m_settings = {{QStringLiteral("gameDir"), QString()},
                  {QStringLiteral("javaPath"), QString()},
                  // id сборки Java из <root>/java. Пусто — java ищется сама.
                  {QStringLiteral("javaRuntime"), QString()},
                  {QStringLiteral("minMemoryMb"), 512},
                  {QStringLiteral("maxMemoryMb"), 4096},
                  {QStringLiteral("jvmArgs"), QString()},
                  {QStringLiteral("windowWidth"), 0},
                  {QStringLiteral("windowHeight"), 0},
                  {QStringLiteral("fullscreen"), false},
                  {QStringLiteral("launcherName"), QStringLiteral("KishkaLauncher")},
                  {QStringLiteral("launcherVersion"), QStringLiteral("1.0")},
                  {QStringLiteral("activeBuildId"), 0},
                  // Сервер готовых сборок. В интерфейс не вынесен: менять его
                  // приходится только при отладке против локального сервера.
                  {QStringLiteral("seasonalBaseUrl"), kSeasonalBaseUrl}};

    const QJsonObject stored = readObjectFile(LauncherPaths::settingsFile());
    for (auto it = stored.constBegin(); it != stored.constEnd(); ++it)
        m_settings.insert(it.key(), it.value().toVariant());
}

void LauncherBackend::saveSettings()
{
    writeJsonFile(LauncherPaths::settingsFile(),
                  QJsonDocument(QJsonObject::fromVariantMap(m_settings)));
}
