#include "versioninstaller.h"

#include "gamelauncher.h"
#include "versionmanifestservice.h"

#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QTimer>

#include <algorithm>
#include <utility>

namespace {

// Своя очередь, а не тысячи QNetworkReply внутри QNetworkAccessManager: там они
// встают в невидимую очередь, каждый со своим объектом и без управления отменой.
constexpr int kMaxParallel = 8;
constexpr int kMaxAttempts = 3;
constexpr int kTransferTimeoutMs = 30000;
// Копирование тысяч мелких файлов одним циклом заморозило бы окно на секунды.
constexpr int kLegacyCopiesPerTick = 64;
// Столько уже имеющихся файлов проверяем за один заход, остальное — следующим.
constexpr int kSkipsPerTick = 256;
constexpr int kMaxInheritDepth = 10;

const QString kResourcesUrl = QStringLiteral("https://resources.download.minecraft.net/");

// Хэш из json попадает в путь на диске — принимаем только настоящий sha1.
bool isSha1Hex(const QString &value)
{
    if (value.size() != 40)
        return false;
    for (const QChar c : value) {
        if (!((c >= u'0' && c <= u'9') || (c >= u'a' && c <= u'f') || (c >= u'A' && c <= u'F')))
            return false;
    }
    return true;
}

// Относительный путь из json нельзя подставлять как есть: «../..» увёл бы
// запись за пределы .minecraft.
bool isSafeRelativePath(const QString &path)
{
    if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
        return false;
    const QStringList parts = path.split(u'/');
    for (const QString &part : parts) {
        if (part.isEmpty() || part == QLatin1String(".") || part == QLatin1String(".."))
            return false;
    }
    return true;
}

// Одиночный сегмент: id версии, id индекса ресурсов, имя конфига логирования.
bool isSafeRelativeSegment(const QString &value)
{
    return !value.contains(u'/') && isSafeRelativePath(value);
}

QString versionJsonPath(const QString &gameDir, const QString &id)
{
    return gameDir + QStringLiteral("/versions/") + id + u'/' + id + QStringLiteral(".json");
}

bool isTransient(QNetworkReply::NetworkError error, int httpStatus)
{
    if (httpStatus == 429 || httpStatus >= 500)
        return true;
    switch (error) {
    case QNetworkReply::RemoteHostClosedError:
    case QNetworkReply::TimeoutError:
    case QNetworkReply::TemporaryNetworkFailureError:
    case QNetworkReply::NetworkSessionFailedError:
    case QNetworkReply::UnknownNetworkError:
    case QNetworkReply::ProxyTimeoutError:
    case QNetworkReply::ContentReSendError:
        return true;
    default:
        return false;
    }
}

QString fileNameOf(const QString &path)
{
    return QFileInfo(path).fileName();
}

} // namespace

VersionInstaller::VersionInstaller(VersionManifestService *manifest, QObject *parent)
    : QObject(parent)
    , m_manifest(manifest)
    , m_network(new QNetworkAccessManager(this))
    , m_progressTimer(new QTimer(this))
    , m_legacyTimer(new QTimer(this))
{
    // Восемь параллельных ответов шлют downloadProgress на каждый кусок данных:
    // без склейки привязки в QML пересчитывались бы сотни раз в секунду.
    m_progressTimer->setInterval(100);
    connect(m_progressTimer, &QTimer::timeout, this, &VersionInstaller::flushProgress);

    m_legacyTimer->setInterval(0);
    connect(m_legacyTimer, &QTimer::timeout, this, &VersionInstaller::legacyTick);
}

VersionInstaller::~VersionInstaller()
{
    // Из деструктора сигналы уже некому слушать, а QSaveFile сам выбросит
    // недописанные временные файлы.
    blockSignals(true);
    cancel();
}

// ── Прогресс ───────────────────────────────────────────────────────────────

qint64 VersionInstaller::bytesDone() const
{
    qint64 total = m_bytesSettled;
    for (const auto &context : m_active)
        total += context->received;
    return total;
}

double VersionInstaller::fraction() const
{
    if (m_bytesTotal <= 0)
        return -1.0;
    const double value = double(bytesDone()) / double(m_bytesTotal);
    return std::clamp(value, 0.0, 1.0);
}

void VersionInstaller::setStage(const QString &stage)
{
    m_stage = stage;
    scheduleProgress();
}

void VersionInstaller::scheduleProgress()
{
    m_progressDirty = true;
    if (!m_progressTimer->isActive())
        m_progressTimer->start();
}

void VersionInstaller::flushProgress()
{
    if (!m_progressDirty) {
        if (!m_running)
            m_progressTimer->stop();
        return;
    }
    m_progressDirty = false;
    emit progressChanged();
}

// ── Очередь установок ──────────────────────────────────────────────────────

bool VersionInstaller::isQueued(const QString &versionId) const
{
    for (const auto &entry : m_pendingInstalls) {
        if (entry.second == versionId)
            return true;
    }
    return false;
}

void VersionInstaller::install(const QString &gameDir, const QString &versionId)
{
    const QString id = versionId.trimmed();
    if (id.isEmpty())
        return;

    if (!isSafeRelativeSegment(id)) {
        emit failed(id, tr("Недопустимое имя версии «%1»").arg(id));
        return;
    }
    if (m_canceling) {
        emit failed(id, tr("Идёт отмена предыдущей загрузки — попробуйте ещё раз"));
        return;
    }
    if (m_versionId == id || isQueued(id))
        return;

    m_pendingInstalls.enqueue({gameDir, id});
    if (!m_running)
        startNext();
}

void VersionInstaller::startNext()
{
    if (m_pendingInstalls.isEmpty()) {
        m_running = false;
        m_versionId.clear();
        m_stage.clear();
        m_currentLabel.clear();
        m_progressTimer->stop();
        emit progressChanged();
        return;
    }

    const auto entry = m_pendingInstalls.dequeue();
    m_gameDir = entry.first;
    m_versionId = entry.second;
    m_versionJson = {};
    m_version = {};
    m_queue.clear();
    m_warnings.clear();
    m_assetObjects = {};
    m_assetsVirtual = false;
    m_assetsMapToResources = false;
    m_legacyCopies.clear();
    m_legacyIndex = 0;
    m_bytesSettled = 0;
    m_bytesTotal = 0;
    m_assetsDeclaredTotal = 0;

    const bool wasRunning = m_running;
    m_running = true;
    if (!wasRunning)
        emit started(m_versionId);
    setStage(tr("Получение списка версий"));
    emit progressChanged();

    resolveManifestEntry();
}

void VersionInstaller::resolveManifestEntry()
{
    const QString id = m_versionId;
    m_manifest->ensureLoaded([this, id](bool ok, const QString &warning) {
        if (!m_running || m_canceling || m_versionId != id)
            return;

        if (!ok) {
            QFile local(versionJsonPath(m_gameDir, id));
            if (local.open(QIODevice::ReadOnly)) {
                adoptVersionJson(local.readAll(), /*alreadySaved=*/true);
                return;
            }
            failInstall(warning.isEmpty() ? tr("Список версий Mojang недоступен") : warning);
            return;
        }

        const auto entry = m_manifest->find(id);
        if (entry) {
            fetchVersionJson(entry->url, entry->sha1);
            return;
        }

        // Версии нет в манифесте, но описание могло уже лечь на диск: так
        // ставятся сборки модлоадеров, чьи id Mojang не знает.
        QFile local(versionJsonPath(m_gameDir, id));
        if (local.open(QIODevice::ReadOnly)) {
            adoptVersionJson(local.readAll(), /*alreadySaved=*/true);
            return;
        }

        // Иначе это вручную вписанный id сборки — сообщение не должно
        // выглядеть как сетевая ошибка.
        failInstall(tr("Версия «%1» отсутствует в манифесте Mojang — "
                       "установить её автоматически нельзя")
                        .arg(id));
    });
}

void VersionInstaller::fetchVersionJson(const QUrl &url, const QString &expectedSha1)
{
    setStage(tr("Описание версии"));
    m_currentLabel = m_versionId + QStringLiteral(".json");

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

    const QString id = m_versionId;
    QNetworkReply *reply = m_network->get(request);
    connect(reply, &QNetworkReply::finished, this, [this, reply, expectedSha1, id] {
        reply->deleteLater();
        if (!m_running || m_canceling || m_versionId != id)
            return;

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

        const QByteArray payload = reply->readAll();
        if (!expectedSha1.isEmpty()) {
            const QString actual = QString::fromLatin1(
                QCryptographicHash::hash(payload, QCryptographicHash::Sha1).toHex());
            if (actual.compare(expectedSha1, Qt::CaseInsensitive) != 0) {
                failInstall(tr("Контрольная сумма описания версии «%1» не совпала").arg(id));
                return;
            }
        }

        adoptVersionJson(payload, /*alreadySaved=*/false);
    });
}

// Общий хвост для описания версии, откуда бы оно ни пришло: из сети или уже
// лежащим на диске (сборки модлоадеров кладут его туда сами).
void VersionInstaller::adoptVersionJson(const QByteArray &payload, bool alreadySaved)
{
    const QString id = m_versionId;

    m_versionJson = QJsonDocument::fromJson(payload).object();
    if (m_versionJson.isEmpty()) {
        failInstall(tr("Описание версии «%1» не удалось разобрать").arg(id));
        return;
    }

    if (!alreadySaved) {
        const QString directory = m_gameDir + QStringLiteral("/versions/") + id;
        if (!QDir().mkpath(directory)) {
            failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(directory)));
            return;
        }

        QSaveFile file(directory + u'/' + id + QStringLiteral(".json"));
        if (!file.open(QIODevice::WriteOnly) || file.write(payload) != payload.size()
            || !file.commit()) {
            file.cancelWriting();
            failInstall(tr("Не удалось сохранить описание версии «%1»").arg(id));
            return;
        }
    }

    // Ванильные версии ничего не наследуют, но сборка могла бы — тогда
    // сначала ставим родителя, а потом возвращаемся к этой версии.
    const QString parentId = m_versionJson.value(QStringLiteral("inheritsFrom")).toString();
    if (!parentId.isEmpty() && isSafeRelativeSegment(parentId)
        && !QFile::exists(versionJsonPath(m_gameDir, parentId))) {
        if (m_inheritDepth >= kMaxInheritDepth) {
            failInstall(tr("Слишком длинная цепочка inheritsFrom у версии «%1»").arg(id));
            return;
        }
        ++m_inheritDepth;
        m_pendingInstalls.prepend({m_gameDir, id});
        m_pendingInstalls.prepend({m_gameDir, parentId});
        abandonCurrent();
        return;
    }

    m_inheritDepth = 0;
    if (prepareTasks())
        pump();
}

// ── Составление списка файлов ──────────────────────────────────────────────

void VersionInstaller::enqueue(DownloadTask task)
{
    if (task.url.scheme() != QLatin1String("https")) {
        // Подменённое описание версии не должно уводить загрузку на чужой адрес.
        m_warnings << tr("Пропущен %1: недопустимая ссылка").arg(task.label);
        return;
    }
    m_bytesTotal += task.size;
    m_queue.enqueue(std::move(task));
}

bool VersionInstaller::prepareTasks()
{
    setStage(tr("Подготовка"));

    QString error;
    // features пустые намеренно: Mojang отбирает библиотеки только по ОС,
    // а feature-правила живут в arguments, которые установщику не нужны.
    m_version = VersionLoader::load(m_gameDir, m_versionId, {}, &error);
    if (!m_version.isValid()) {
        failInstall(error.isEmpty() ? tr("Не удалось прочитать версию «%1»").arg(m_versionId)
                                    : error);
        return false;
    }

    const QJsonObject client = m_versionJson.value(QStringLiteral("downloads"))
                                   .toObject()
                                   .value(QStringLiteral("client"))
                                   .toObject();
    if (!client.isEmpty()) {
        DownloadTask task;
        task.kind = DownloadTask::ClientJar;
        task.url = QUrl(client.value(QStringLiteral("url")).toString());
        task.path = m_version.clientJarPath;
        task.sha1 = client.value(QStringLiteral("sha1")).toString();
        task.size = client.value(QStringLiteral("size")).toInteger();
        task.label = fileNameOf(task.path);
        enqueue(std::move(task));
    }

    // url, sha1 и size у библиотек уже разобраны VersionLoader — это готовые
    // описания загрузки, разбирать json заново не нужно.
    for (const MinecraftLibrary &library : std::as_const(m_version.libraries)) {
        if (library.url.isEmpty() || !isSafeRelativePath(library.path)) {
            m_warnings << tr("Нет ссылки на библиотеку %1").arg(library.name);
            continue;
        }
        DownloadTask task;
        task.kind = DownloadTask::Library;
        task.url = QUrl(library.url);
        task.path = m_gameDir + QStringLiteral("/libraries/") + library.path;
        task.sha1 = library.sha1;
        task.size = library.size;
        task.label = library.name;
        enqueue(std::move(task));
    }

    const QJsonObject assetIndex = m_versionJson.value(QStringLiteral("assetIndex")).toObject();
    if (!assetIndex.isEmpty() && isSafeRelativeSegment(m_version.assetIndexId)) {
        DownloadTask task;
        task.kind = DownloadTask::AssetIndex;
        task.url = QUrl(assetIndex.value(QStringLiteral("url")).toString());
        task.path = m_gameDir + QStringLiteral("/assets/indexes/") + m_version.assetIndexId
                    + QStringLiteral(".json");
        task.sha1 = assetIndex.value(QStringLiteral("sha1")).toString();
        task.size = assetIndex.value(QStringLiteral("size")).toInteger();
        task.label = tr("индекс ресурсов");
        enqueue(std::move(task));

        // totalSize — суммарный вес всех ресурсов, известный ещё до скачивания
        // индекса: без него полоса первые секунды стояла бы и потом прыгнула.
        m_assetsDeclaredTotal = assetIndex.value(QStringLiteral("totalSize")).toInteger();
        m_bytesTotal += m_assetsDeclaredTotal;
    }

    const QJsonObject logging = m_versionJson.value(QStringLiteral("logging"))
                                    .toObject()
                                    .value(QStringLiteral("client"))
                                    .toObject()
                                    .value(QStringLiteral("file"))
                                    .toObject();
    const QString loggingId = logging.value(QStringLiteral("id")).toString();
    if (!logging.isEmpty() && isSafeRelativeSegment(loggingId)) {
        DownloadTask task;
        task.kind = DownloadTask::LoggingConfig;
        task.url = QUrl(logging.value(QStringLiteral("url")).toString());
        task.path = m_gameDir + QStringLiteral("/assets/log_configs/") + loggingId;
        task.sha1 = logging.value(QStringLiteral("sha1")).toString();
        task.size = logging.value(QStringLiteral("size")).toInteger();
        task.label = loggingId;
        enqueue(std::move(task));
    }

    setStage(tr("Файлы игры"));
    return true;
}

// ── Движок загрузки ────────────────────────────────────────────────────────

void VersionInstaller::schedulePump()
{
    if (m_pumpScheduled)
        return;
    m_pumpScheduled = true;
    QTimer::singleShot(0, this, [this] {
        m_pumpScheduled = false;
        pump();
    });
}

void VersionInstaller::pump()
{
    if (!m_running || m_canceling)
        return;

    int skipped = 0;
    while (m_active.size() < kMaxParallel && !m_queue.isEmpty()) {
        const int before = m_active.size();
        startTask(m_queue.dequeue());
        // startTask мог провалить установку — тогда очередь уже разобрана.
        if (!m_running || m_canceling)
            return;
        if (m_active.size() == before) {
            // Файл уже был на месте — сотни таких проверок подряд ощутимы,
            // поэтому остаток берём следующим проходом событийного цикла.
            if (++skipped >= kSkipsPerTick && !m_queue.isEmpty()) {
                schedulePump();
                return;
            }
        }
    }

    if (m_queue.isEmpty() && m_active.isEmpty())
        afterDownloads();
}

void VersionInstaller::startTask(DownloadTask task)
{
    const QFileInfo info(task.path);
    if (info.exists() && !m_verifyExisting && (task.size == 0 || info.size() == task.size)) {
        m_bytesSettled += task.size;
        if (task.kind == DownloadTask::AssetIndex)
            expandAssetIndex(task.path);
        scheduleProgress();
        return;
    }

    // Временный файл QSaveFile ложится рядом с целевым, поэтому папка нужна
    // даже до открытия.
    if (!QDir().mkpath(info.absolutePath())) {
        failInstall(tr("Не удалось создать %1").arg(QDir::toNativeSeparators(info.absolutePath())));
        return;
    }

    auto context = std::make_shared<ActiveDownload>();
    context->task = task;
    context->file = std::make_unique<QSaveFile>(task.path);
    if (!context->file->open(QIODevice::WriteOnly)) {
        failInstall(tr("Не удалось открыть на запись %1").arg(QDir::toNativeSeparators(task.path)));
        return;
    }

    QNetworkRequest request{task.url};
    request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
                         QNetworkRequest::NoLessSafeRedirectPolicy);
    // Без таймаута зависший сокет подвесил бы установку навсегда.
    request.setTransferTimeout(kTransferTimeoutMs);

    context->reply = m_network->get(request);
    m_active.append(context);
    m_currentLabel = task.label;

    connect(context->reply, &QNetworkReply::readyRead, this, [this, context] {
        const QByteArray chunk = context->reply->readAll();
        if (chunk.isEmpty())
            return;
        context->hash.addData(chunk);
        if (context->file->write(chunk) != chunk.size())
            context->reply->abort();
    });
    connect(context->reply, &QNetworkReply::downloadProgress, this,
            [this, context](qint64 received, qint64) {
                context->received = received;
                scheduleProgress();
            });
    connect(context->reply, &QNetworkReply::finished, this,
            [this, context] { handleReplyFinished(context); });

    scheduleProgress();
}

void VersionInstaller::handleReplyFinished(const std::shared_ptr<ActiveDownload> &context)
{
    context->reply->deleteLater();
    m_active.removeOne(context);

    if (m_canceling) {
        context->file->cancelWriting();
        if (m_active.isEmpty())
            finalizeCancel();
        return;
    }
    if (!m_running)
        return;

    const int status =
        context->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();

    if (context->reply->error() != QNetworkReply::NoError) {
        context->file->cancelWriting();
        const QString reason = context->reply->errorString();
        if (isTransient(context->reply->error(), status)) {
            if (!retryOrFail(context->task, reason))
                return;
        } else {
            failInstall(tr("Не удалось скачать %1: %2").arg(context->task.label, reason));
            return;
        }
        pump();
        return;
    }

    if (!context->task.sha1.isEmpty()) {
        const QString actual = QString::fromLatin1(context->hash.result().toHex());
        if (actual.compare(context->task.sha1, Qt::CaseInsensitive) != 0) {
            context->file->cancelWriting();
            if (!retryOrFail(context->task, tr("контрольная сумма не совпала")))
                return;
            pump();
            return;
        }
    }

    if (!context->file->commit()) {
        failInstall(tr("Не удалось сохранить %1").arg(QDir::toNativeSeparators(context->task.path)));
        return;
    }

    m_bytesSettled += context->received;
    if (context->task.kind == DownloadTask::AssetIndex)
        expandAssetIndex(context->task.path);

    scheduleProgress();
    pump();
}

// Возвращает true, если задача поставлена на повтор, и false, если установка
// уже провалена.
bool VersionInstaller::retryOrFail(DownloadTask task, const QString &reason)
{
    if (task.attempts + 1 >= kMaxAttempts) {
        failInstall(tr("Не удалось скачать %1: %2").arg(task.label, reason));
        return false;
    }

    ++task.attempts;
    const int delay = 400 * task.attempts;
    QTimer::singleShot(delay, this, [this, task] {
        if (!m_running || m_canceling)
            return;
        // В начало очереди: иначе повтор ждал бы за тысячами ресурсов.
        m_queue.prepend(task);
        pump();
    });
    return true;
}

void VersionInstaller::expandAssetIndex(const QString &indexPath)
{
    QFile file(indexPath);
    if (!file.open(QIODevice::ReadOnly)) {
        failInstall(tr("Не удалось прочитать индекс ресурсов %1")
                        .arg(QDir::toNativeSeparators(indexPath)));
        return;
    }

    const QJsonObject root = QJsonDocument::fromJson(file.readAll()).object();
    m_assetObjects = root.value(QStringLiteral("objects")).toObject();
    m_assetsVirtual = root.value(QStringLiteral("virtual")).toBool();
    m_assetsMapToResources = root.value(QStringLiteral("map_to_resources")).toBool();

    setStage(tr("Ресурсы"));

    qint64 actualTotal = 0;
    for (auto it = m_assetObjects.constBegin(); it != m_assetObjects.constEnd(); ++it) {
        const QJsonObject object = it.value().toObject();
        const QString hash = object.value(QStringLiteral("hash")).toString();
        if (!isSha1Hex(hash)) {
            m_warnings << tr("Пропущен ресурс %1: некорректный хэш").arg(it.key());
            continue;
        }

        const QString prefix = hash.left(2);
        DownloadTask task;
        task.kind = DownloadTask::AssetObject;
        task.url = QUrl(kResourcesUrl + prefix + u'/' + hash);
        task.path = m_gameDir + QStringLiteral("/assets/objects/") + prefix + u'/' + hash;
        task.sha1 = hash;
        task.size = object.value(QStringLiteral("size")).toInteger();
        task.label = it.key();
        actualTotal += task.size;
        enqueue(std::move(task));
    }

    // enqueue уже прибавил вес ресурсов к итогу — снимаем заявленный заранее,
    // чтобы он не учитывался дважды.
    m_bytesTotal -= m_assetsDeclaredTotal;
    m_assetsDeclaredTotal = actualTotal;
    scheduleProgress();
}

// ── Завершение ─────────────────────────────────────────────────────────────

void VersionInstaller::afterDownloads()
{
    const bool legacy = m_assetsVirtual || m_assetsMapToResources
                        || m_version.assetsKind == QLatin1String("legacy")
                        || m_version.assetsKind == QLatin1String("pre-1.6");
    if (legacy && !m_assetObjects.isEmpty() && m_legacyCopies.isEmpty() && m_legacyIndex == 0) {
        startLegacyCopy();
        return;
    }
    complete();
}

void VersionInstaller::startLegacyCopy()
{
    setStage(tr("Подготовка ресурсов"));

    const QString targetRoot = m_assetsMapToResources
                                   ? m_gameDir + QStringLiteral("/resources/")
                                   : m_gameDir + QStringLiteral("/assets/virtual/legacy/");

    for (auto it = m_assetObjects.constBegin(); it != m_assetObjects.constEnd(); ++it) {
        const QString name = it.key();
        const QString hash = it.value().toObject().value(QStringLiteral("hash")).toString();
        if (!isSha1Hex(hash) || !isSafeRelativePath(name))
            continue;
        m_legacyCopies.append({m_gameDir + QStringLiteral("/assets/objects/") + hash.left(2) + u'/'
                                   + hash,
                               targetRoot + name});
    }

    m_legacyIndex = 0;
    if (m_legacyCopies.isEmpty()) {
        complete();
        return;
    }
    m_legacyTimer->start();
}

void VersionInstaller::legacyTick()
{
    if (!m_running || m_canceling) {
        m_legacyTimer->stop();
        return;
    }

    const int end = std::min(m_legacyIndex + kLegacyCopiesPerTick, int(m_legacyCopies.size()));
    for (; m_legacyIndex < end; ++m_legacyIndex) {
        const auto &pair = m_legacyCopies.at(m_legacyIndex);
        const QFileInfo source(pair.first);
        const QFileInfo target(pair.second);
        if (!source.exists())
            continue;
        if (target.exists() && target.size() == source.size())
            continue;

        QDir().mkpath(target.absolutePath());
        QFile::remove(pair.second);
        if (!QFile::copy(pair.first, pair.second))
            m_warnings << tr("Не удалось подготовить ресурс %1").arg(target.fileName());
    }

    m_currentLabel = tr("%1 из %2").arg(m_legacyIndex).arg(m_legacyCopies.size());
    scheduleProgress();

    if (m_legacyIndex >= m_legacyCopies.size()) {
        m_legacyTimer->stop();
        complete();
    }
}

void VersionInstaller::complete()
{
    // Приёмка той же проверкой, которой пользуется запуск: успешная установка
    // тогда по построению запускаема.
    LaunchOptions probe;
    probe.gameDir = m_gameDir;
    probe.versionId = m_versionId;
    const QStringList missing = GameLauncher::missingFiles(probe, m_version);
    if (!missing.isEmpty()) {
        failInstall(tr("После загрузки не хватает файлов (%1):\n%2")
                        .arg(missing.size())
                        .arg(missing.join(u'\n')));
        return;
    }

    const QString id = m_versionId;
    resetInstallState();
    emit finished(id);
    startNext();
}

void VersionInstaller::resetInstallState()
{
    m_queue.clear();
    m_active.clear();
    m_assetObjects = {};
    m_legacyCopies.clear();
    m_legacyIndex = 0;
    m_legacyTimer->stop();
    m_versionId.clear();
    m_stage.clear();
    m_currentLabel.clear();
    m_running = false;
    m_progressDirty = false;
    m_progressTimer->stop();
}

void VersionInstaller::failInstall(const QString &message)
{
    const QString id = m_versionId;
    QString text = message;
    if (!m_warnings.isEmpty())
        text += u'\n' + m_warnings.mid(0, 3).join(u'\n');

    // Остальные версии из очереди уже некуда ставить — их пути могли зависеть
    // от этой, да и пользователю нужен один внятный ответ.
    m_pendingInstalls.clear();
    m_inheritDepth = 0;
    for (const auto &context : std::as_const(m_active)) {
        if (context->reply)
            context->reply->abort();
        context->file->cancelWriting();
    }
    resetInstallState();
    emit failed(id, text);
}

void VersionInstaller::abandonCurrent()
{
    // Версия уступает очередь родителю: сигналов нет, установка продолжается.
    m_queue.clear();
    m_active.clear();
    m_versionId.clear();
    startNext();
}

void VersionInstaller::cancel()
{
    if (!m_running)
        return;

    m_pendingInstalls.clear();
    m_queue.clear();
    m_legacyTimer->stop();
    m_inheritDepth = 0;
    m_canceling = true;

    // abort() может доставить finished синхронно, а обработчик стирает элемент
    // из m_active — поэтому обходим копию.
    const auto active = m_active;
    for (const auto &context : active) {
        if (context->reply)
            context->reply->abort();
    }
    // abort() мог довести отмену до конца прямо здесь — тогда m_canceling уже снят.
    if (m_canceling && m_active.isEmpty())
        finalizeCancel();
}

void VersionInstaller::finalizeCancel()
{
    const QString id = m_versionId;
    m_canceling = false;
    resetInstallState();
    emit canceled(id);
}
