#include "buildswitcher.h"

#include "buildarchiveworker.h"
#include "launcherpaths.h"

#include <QDateTime>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QSaveFile>
#include <QStorageInfo>
#include <QThread>
#include <QUuid>

namespace {

const QString kIndexFile = QStringLiteral("index.json");
const QString kStateSwitching = QStringLiteral("switching");
const QString kStateIdle = QStringLiteral("idle");

// Запас на архив: точный размер заранее неизвестен, но упасть на середине
// записи из-за полного диска хуже, чем отказаться заранее.
constexpr qint64 kFreeSpaceSlackBytes = 256LL * 1024 * 1024;

// Имя файла из названия сборки: оно попадает в файловую систему, поэтому всё
// небезопасное заменяется подчёркиванием.
QString slug(const QString &name)
{
    QString result;
    result.reserve(name.size());
    for (const QChar c : name) {
        if (c.isLetterOrNumber() || c == u'-' || c == u'_')
            result.append(c);
        else if (c.isSpace() || c == u'.')
            result.append(u'_');
    }
    while (result.startsWith(u'_'))
        result.remove(0, 1);
    return result.isEmpty() ? QStringLiteral("build") : result.left(48);
}

qint64 directorySize(const QString &path, const QStringList &excludeTop)
{
    qint64 total = 0;
    const QDir root(path);
    const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
                                                 | QDir::Hidden | QDir::System);
    for (const QFileInfo &entry : top) {
        if (excludeTop.contains(entry.fileName()) || entry.isSymLink())
            continue;
        if (entry.isFile()) {
            total += entry.size();
            continue;
        }
        QDirIterator it(entry.absoluteFilePath(),
                        QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
                        QDirIterator::Subdirectories);
        while (it.hasNext()) {
            it.next();
            total += it.fileInfo().size();
        }
    }
    return total;
}

} // namespace

QStringList BuildSwitcher::sharedTopLevel()
{
    // Общий кэш, одинаковый для всех сборок и всегда восстановимый загрузкой.
    // versions/ здесь не случайно: без libraries/ описания версий бесполезны, а
    // две сборки на одной версии игры иначе таскали бы по копии клиентского jar.
    // Чем сборка запускается, помнит resolvedVersionId, а не набор папок.
    return {QStringLiteral("assets"),
            QStringLiteral("libraries"),
            QStringLiteral("versions"),
            QStringLiteral("launcher_profiles.json"),
            QStringLiteral("runtime")};
}

BuildSwitcher::BuildSwitcher(QObject *parent)
    : QObject(parent)
    , m_thread(new QThread(this))
    , m_worker(new BuildArchiveWorker)
{
    m_worker->moveToThread(m_thread);
    connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
    connect(m_worker, &BuildArchiveWorker::finished, this, &BuildSwitcher::onWorkerFinished);
    connect(m_worker, &BuildArchiveWorker::progress, this, &BuildSwitcher::onWorkerProgress);
    m_thread->start();

    sweepTempArchives();
}

BuildSwitcher::~BuildSwitcher()
{
    m_worker->requestCancel();
    m_thread->quit();
    m_thread->wait(5000);
}

double BuildSwitcher::fraction() const
{
    if (m_state == Idle || m_total <= 0)
        return -1.0;
    return double(m_done) / double(m_total);
}

QString BuildSwitcher::buildDir(int buildId) const
{
    return LauncherPaths::buildStorageDir() + u'/' + QString::number(buildId);
}

// На сборку — ровно один архив: имя с датой меняется от переключения к
// переключению, поэтому ищем по расширению, а не по имени.
QString BuildSwitcher::archivePathOf(int buildId) const
{
    const QDir dir(buildDir(buildId));
    const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files, QDir::Time);
    return zips.isEmpty() ? QString() : dir.absoluteFilePath(zips.first());
}

void BuildSwitcher::setStage(const QString &stage)
{
    m_stage = stage;
    emit progressChanged();
}

void BuildSwitcher::switchTo(int fromBuildId,
                             const QString &fromName,
                             int toBuildId,
                             const QString &toName,
                             const QString &gameDir)
{
    if (m_state != Idle) {
        emit failed(toBuildId, tr("Смена сборки уже выполняется"), true);
        return;
    }

    QString rootError;
    if (!LauncherPaths::ensureRootExists(&rootError)) {
        emit failed(toBuildId, rootError, true);
        return;
    }

    m_fromId = fromBuildId;
    m_toId = toBuildId;
    m_fromName = fromName;
    m_toName = toName;
    m_gameDir = gameDir;
    m_done = 0;
    m_total = 0;
    m_worker->clearCancel();

    // Первая сборка за всё время: архивировать нечего, сразу разворачиваем цель.
    if (fromBuildId < 0 || !QDir(gameDir).exists()) {
        m_state = Clearing;
        beginClearing();
        return;
    }

    if (!QDir().mkpath(buildDir(fromBuildId))) {
        emit failed(toBuildId,
                    tr("Не удалось создать %1")
                        .arg(QDir::toNativeSeparators(buildDir(fromBuildId))),
                    true);
        return;
    }

    const qint64 needed = directorySize(gameDir, sharedTopLevel()) + kFreeSpaceSlackBytes;
    const QStorageInfo storage(LauncherPaths::buildStorageDir());
    if (storage.isValid() && storage.bytesAvailable() < needed) {
        emit failed(toBuildId,
                    tr("Недостаточно места для архива сборки «%1»: нужно около %2 МБ")
                        .arg(fromName)
                        .arg(needed / (1024 * 1024)),
                    true);
        return;
    }

    m_tempZip = buildDir(fromBuildId) + QStringLiteral("/.tmp-")
                + QUuid::createUuid().toString(QUuid::Id128) + QStringLiteral(".zip");

    m_state = Archiving;
    setStage(tr("Сохранение сборки «%1»").arg(fromName));
    requestArchive(gameDir, m_tempZip, sharedTopLevel());
}

void BuildSwitcher::cancel()
{
    // Отмена имеет смысл только на архивации: после очистки папки отступать
    // уже некуда, там нужно доводить дело до конца.
    if (m_state == Archiving)
        m_worker->requestCancel();
}

void BuildSwitcher::requestArchive(const QString &gameDir,
                                   const QString &tempZip,
                                   const QStringList &exclude)
{
    QMetaObject::invokeMethod(m_worker, "archive", Qt::QueuedConnection,
                              Q_ARG(QString, gameDir), Q_ARG(QString, tempZip),
                              Q_ARG(QStringList, exclude));
}

void BuildSwitcher::requestClear(const QString &gameDir, const QStringList &keep)
{
    QMetaObject::invokeMethod(m_worker, "clear", Qt::QueuedConnection,
                              Q_ARG(QString, gameDir), Q_ARG(QStringList, keep));
}

void BuildSwitcher::requestRestore(const QString &zipPath, const QString &gameDir)
{
    QMetaObject::invokeMethod(m_worker, "restore", Qt::QueuedConnection,
                              Q_ARG(QString, zipPath), Q_ARG(QString, gameDir));
}

void BuildSwitcher::onWorkerProgress(int done, int total, const QString &currentPath)
{
    m_done = done;
    m_total = total;
    m_status = currentPath;
    emit progressChanged();
}

void BuildSwitcher::onWorkerFinished(bool ok, const QString &error)
{
    switch (m_state) {
    case Idle:
        return;
    case Archiving:
        if (!ok) {
            QFile::remove(m_tempZip);
            fail(error, /*gameDirIntact=*/true);
            return;
        }
        commitArchive();
        return;
    case Clearing:
        if (!ok) {
            // Папка осталась разобранной: активной сборкой считается прежняя,
            // отметка о незавершённом переключении в index.json остаётся.
            fail(error, /*gameDirIntact=*/false);
            return;
        }
        beginRestoring();
        return;
    case Restoring:
        if (!ok) {
            fail(error, /*gameDirIntact=*/false);
            return;
        }
        finish();
        return;
    }
}

void BuildSwitcher::commitArchive()
{
    const QString target = buildDir(m_fromId) + u'/'
                           + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd"))
                           + u'_' + slug(m_fromName) + QStringLiteral(".zip");

    // На сборку — один архив: новый встаёт на место, все прежние уходят.
    if (QFile::exists(target) && !QFile::remove(target)) {
        QFile::remove(m_tempZip);
        fail(tr("Не удалось заменить архив %1").arg(QDir::toNativeSeparators(target)), true);
        return;
    }
    if (!QFile::rename(m_tempZip, target)) {
        QFile::remove(m_tempZip);
        fail(tr("Не удалось сохранить архив %1").arg(QDir::toNativeSeparators(target)), true);
        return;
    }

    const QDir dir(buildDir(m_fromId));
    const QStringList zips = dir.entryList({QStringLiteral("*.zip")}, QDir::Files);
    for (const QString &name : zips) {
        if (dir.absoluteFilePath(name) != target)
            QFile::remove(dir.absoluteFilePath(name));
    }

    // Отметка ставится до первого разрушающего действия: по ней следующий
    // запуск поймёт, что переключение оборвалось на середине.
    QJsonObject index = readIndex();
    index.insert(QStringLiteral("state"), kStateSwitching);
    index.insert(QStringLiteral("from"), m_fromId);
    index.insert(QStringLiteral("to"), m_toId);
    QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
    builds.insert(QString::number(m_fromId),
                  QJsonObject{{QStringLiteral("archive"), QFileInfo(target).fileName()},
                              {QStringLiteral("name"), m_fromName},
                              {QStringLiteral("savedAt"),
                               QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}});
    index.insert(QStringLiteral("builds"), builds);
    writeIndex(index);

    m_state = Clearing;
    beginClearing();
}

void BuildSwitcher::beginClearing()
{
    setStage(tr("Подготовка сборки «%1»").arg(m_toName));
    requestClear(m_gameDir, sharedTopLevel());
}

void BuildSwitcher::beginRestoring()
{
    const QString archive = archivePathOf(m_toId);
    if (archive.isEmpty()) {
        // Сборка ещё ни разу не запускалась: пустая .minecraft рядом с общими
        // кэшами — это и есть её правильное начальное состояние.
        m_state = Restoring;
        finish();
        return;
    }

    m_state = Restoring;
    setStage(tr("Восстановление сборки «%1»").arg(m_toName));
    requestRestore(archive, m_gameDir);
}

void BuildSwitcher::finish()
{
    QJsonObject index = readIndex();
    index.insert(QStringLiteral("state"), kStateIdle);
    index.insert(QStringLiteral("active"), m_toId);
    index.remove(QStringLiteral("from"));
    index.remove(QStringLiteral("to"));
    writeIndex(index);

    const int toId = m_toId;
    m_state = Idle;
    m_stage.clear();
    m_status.clear();
    m_done = m_total = 0;
    emit progressChanged();
    emit finished(toId);
}

void BuildSwitcher::fail(const QString &message, bool gameDirIntact)
{
    const int toId = m_toId;
    m_state = Idle;
    m_stage.clear();
    m_status.clear();
    m_done = m_total = 0;
    emit progressChanged();
    emit failed(toId, message, gameDirIntact);
}

// ── Удаление сборки ────────────────────────────────────────────────────────

bool BuildSwitcher::forgetBuild(int buildId)
{
    if (buildId < 0)
        return true;

    // Запись убираем в любом случае: даже если файл не удалось стереть, ссылаться
    // на архив несуществующей сборки нельзя — иначе он «воскреснет» под новой
    // сборкой с тем же id.
    QJsonObject index = readIndex();
    QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
    builds.remove(QString::number(buildId));
    index.insert(QStringLiteral("builds"), builds);
    if (index.value(QStringLiteral("active")).toInt(-1) == buildId)
        index.remove(QStringLiteral("active"));
    writeIndex(index);

    QDir dir(buildDir(buildId));
    return !dir.exists() || dir.removeRecursively();
}

// ── Незавершённое переключение ─────────────────────────────────────────────

QString BuildSwitcher::interruptedSwitchWarning() const
{
    const QJsonObject index = readIndex();
    if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
        return {};

    const int toId = index.value(QStringLiteral("to")).toInt(-1);
    const QJsonObject builds = index.value(QStringLiteral("builds")).toObject();
    const QString name = builds.value(QString::number(toId))
                             .toObject()
                             .value(QStringLiteral("name"))
                             .toString();
    return tr("Прошлая смена сборки%1 не завершилась — восстанавливаем содержимое .minecraft.")
        .arg(name.isEmpty() ? QString() : tr(" на «%1»").arg(name));
}

void BuildSwitcher::resumeInterrupted(const QString &gameDir)
{
    const QJsonObject index = readIndex();
    if (index.value(QStringLiteral("state")).toString() != kStateSwitching)
        return;

    const int toId = index.value(QStringLiteral("to")).toInt(-1);
    if (toId < 0 || m_state != Idle)
        return;

    // Архив исходной сборки уже записан целиком — доигрываем только вторую
    // половину: очистку и распаковку цели.
    m_fromId = -1;
    m_toId = toId;
    m_toName = index.value(QStringLiteral("builds"))
                   .toObject()
                   .value(QString::number(toId))
                   .toObject()
                   .value(QStringLiteral("name"))
                   .toString();
    m_gameDir = gameDir;
    m_done = m_total = 0;
    m_worker->clearCancel();
    m_state = Clearing;
    beginClearing();
}

// ── Реестр архивов ─────────────────────────────────────────────────────────

QJsonObject BuildSwitcher::readIndex() const
{
    QFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
    if (!file.open(QIODevice::ReadOnly))
        return {};
    return QJsonDocument::fromJson(file.readAll()).object();
}

void BuildSwitcher::writeIndex(const QJsonObject &index) const
{
    if (!LauncherPaths::ensureRootExists())
        return;
    QSaveFile file(LauncherPaths::buildStorageDir() + u'/' + kIndexFile);
    if (!file.open(QIODevice::WriteOnly))
        return;
    const QByteArray payload = QJsonDocument(index).toJson(QJsonDocument::Indented);
    if (file.write(payload) != payload.size()) {
        file.cancelWriting();
        return;
    }
    file.commit();
}

// Оборванная запись архива оставляет .tmp-*.zip: он никому не нужен и может
// занимать гигабайты.
void BuildSwitcher::sweepTempArchives() const
{
    QDir root(LauncherPaths::buildStorageDir());
    const QStringList builds = root.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
    for (const QString &build : builds) {
        QDir dir(root.absoluteFilePath(build));
        const QStringList leftovers =
            dir.entryList({QStringLiteral(".tmp-*.zip")}, QDir::Files | QDir::Hidden);
        for (const QString &name : leftovers)
            QFile::remove(dir.absoluteFilePath(name));
    }
}
