#include "buildarchiveworker.h"

#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>

#include <algorithm>

#include <QtCore/private/qzipreader_p.h>
#include <QtCore/private/qzipwriter_p.h>

namespace {

// Имена внутри архива приходят из файла и превращаются в пути на диске —
// «../..» увёл бы распаковку за пределы .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 == QLatin1String(".") || part == QLatin1String(".."))
            return false;
    }
    return true;
}

// Последняя проверка перед записью: куда бы ни указывало имя из архива после
// всех преобразований, файл обязан оказаться внутри папки игры. Правило имён
// выше это уже гарантирует, но проверка по готовому пути не зависит от того,
// как именно QZipReader разбирает записи.
bool isInside(const QDir &root, const QString &absolutePath)
{
    const QString base = QDir::cleanPath(root.absolutePath()) + u'/';
    return QDir::cleanPath(absolutePath).startsWith(base);
}

// Верхний сегмент относительного пути: по нему проверяется, не лезет ли пак в
// общие каталоги лаунчера.
QString topSegment(const QString &path)
{
    const int slash = path.indexOf(u'/');
    return slash < 0 ? path : path.left(slash);
}

// После удаления файлов остаются пустые папки: пустой mods/ вместо удалённого
// набора модов выглядит как незавершённая установка.
void pruneEmptyDirs(const QDir &root, QStringList dirs)
{
    // Сначала самые глубокие: удаление вложенной может опустошить родителя.
    std::sort(dirs.begin(), dirs.end(),
              [](const QString &a, const QString &b) { return a.size() > b.size(); });
    for (const QString &relative : std::as_const(dirs)) {
        if (relative.isEmpty() || relative == QLatin1String("."))
            continue;
        QDir dir(root.absoluteFilePath(relative));
        if (dir.exists() && dir.isEmpty())
            root.rmdir(relative);
    }
}

// Файлы, которые попадут в архив, — относительными путями от gameDir.
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
{
    QStringList result;
    const QDir root(gameDir);
    const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
                                                 | QDir::Hidden | QDir::System);
    for (const QFileInfo &entry : top) {
        if (excludeTop.contains(entry.fileName()))
            continue;
        if (entry.isSymLink())
            continue;   // ссылку в архив не положить, а идти по ней опасно
        if (entry.isFile()) {
            result << entry.fileName();
            continue;
        }
        if (!entry.isDir())
            continue;

        QDirIterator it(entry.absoluteFilePath(),
                        QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
                        QDirIterator::Subdirectories);
        while (it.hasNext())
            result << root.relativeFilePath(it.next());
    }
    return result;
}

} // namespace

BuildArchiveWorker::BuildArchiveWorker(QObject *parent)
    : QObject(parent)
{
}

void BuildArchiveWorker::archive(const QString &gameDir,
                                 const QString &tempZipPath,
                                 const QStringList &excludeTop)
{
    const QDir root(gameDir);
    if (!root.exists()) {
        emit finished(false, tr("Папка игры не найдена: %1").arg(QDir::toNativeSeparators(gameDir)));
        return;
    }

    const QStringList files = collectFiles(gameDir, excludeTop);
    emit progress(0, files.size(), {});

    {
        QZipWriter writer(tempZipPath);
        if (writer.status() != QZipWriter::NoError) {
            emit finished(false,
                          tr("Не удалось создать архив %1")
                              .arg(QDir::toNativeSeparators(tempZipPath)));
            return;
        }
        // Миры и конфиги сжимаются хорошо, уже сжатые jar-моды — нет: пусть
        // решает эвристика, а не мы.
        writer.setCompressionPolicy(QZipWriter::AutoCompress);

        int done = 0;
        for (const QString &relative : files) {
            if (canceled()) {
                writer.close();
                QFile::remove(tempZipPath);
                emit finished(false, tr("Архивация отменена"));
                return;
            }

            QFile file(root.absoluteFilePath(relative));
            if (!file.open(QIODevice::ReadOnly)) {
                // Один нечитаемый файл не повод потерять всю сборку, но и молча
                // выкидывать его нельзя — иначе пользователь узнает о пропаже
                // уже после очистки .minecraft.
                writer.close();
                QFile::remove(tempZipPath);
                emit finished(false,
                              tr("Не удалось прочитать %1: %2")
                                  .arg(QDir::toNativeSeparators(relative), file.errorString()));
                return;
            }
            writer.addFile(relative, &file);
            file.close();

            if (writer.status() != QZipWriter::NoError) {
                writer.close();
                QFile::remove(tempZipPath);
                emit finished(false,
                              tr("Ошибка записи архива на файле %1")
                                  .arg(QDir::toNativeSeparators(relative)));
                return;
            }
            emit progress(++done, files.size(), relative);
        }
        writer.close();
    }

    emit finished(true, {});
}

void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop)
{
    QDir root(gameDir);
    if (!root.exists()) {
        emit finished(true, {});   // чистить нечего
        return;
    }

    QFileInfoList victims;
    const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
                                                 | QDir::Hidden | QDir::System);
    for (const QFileInfo &entry : top) {
        if (!keepTop.contains(entry.fileName()))
            victims << entry;
    }

    emit progress(0, victims.size(), {});
    int done = 0;
    for (const QFileInfo &entry : victims) {
        // Отмена посреди очистки оставила бы полупустую папку, которую нечем
        // склеить обратно, — на этом шаге её не слушаем.
        const bool ok = entry.isDir() && !entry.isSymLink()
                            ? QDir(entry.absoluteFilePath()).removeRecursively()
                            : QFile::remove(entry.absoluteFilePath());
        if (!ok) {
            emit finished(false,
                          tr("Не удалось удалить %1")
                              .arg(QDir::toNativeSeparators(entry.absoluteFilePath())));
            return;
        }
        emit progress(++done, victims.size(), entry.fileName());
    }

    emit finished(true, {});
}

void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
{
    QZipReader reader(zipPath);
    if (!reader.isReadable()) {
        emit finished(false,
                      tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath)));
        return;
    }

    const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
    emit progress(0, entries.size(), {});

    const QDir root(gameDir);
    if (!QDir().mkpath(gameDir)) {
        emit finished(false,
                      tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir)));
        return;
    }

    int done = 0;
    for (const QZipReader::FileInfo &entry : entries) {
        if (canceled()) {
            emit finished(false, tr("Распаковка отменена"));
            return;
        }
        if (!entry.isFile) {
            emit progress(++done, entries.size(), entry.filePath);
            continue;
        }
        if (!isSafeRelativePath(entry.filePath)) {
            emit finished(false,
                          tr("Архив повреждён: недопустимый путь «%1»").arg(entry.filePath));
            return;
        }

        const QString target = root.absoluteFilePath(entry.filePath);
        if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
            emit finished(false,
                          tr("Не удалось создать %1")
                              .arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
            return;
        }

        QFile file(target);
        const QByteArray data = reader.fileData(entry.filePath);
        if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
            emit finished(false,
                          tr("Не удалось записать %1: %2")
                              .arg(QDir::toNativeSeparators(entry.filePath), file.errorString()));
            return;
        }
        file.close();
        emit progress(++done, entries.size(), entry.filePath);
    }

    emit finished(true, {});
}

void BuildArchiveWorker::applyPack(const QString &zipPath,
                                   const QString &gameDir,
                                   const QStringList &removeRelative,
                                   const QStringList &forbiddenTop)
{
    QZipReader reader(zipPath);
    if (!reader.isReadable()) {
        emit finished(false,
                      tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath)));
        return;
    }

    // Весь архив проверяется до первой записи на диск: наполовину раскатанный
    // пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии.
    const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
    QStringList applied;
    applied.reserve(entries.size());
    for (const QZipReader::FileInfo &entry : entries) {
        if (!entry.isFile)
            continue;
        if (!isSafeRelativePath(entry.filePath)) {
            emit finished(false,
                          tr("Архив сборки повреждён: недопустимый путь «%1»").arg(entry.filePath));
            return;
        }
        if (forbiddenTop.contains(topSegment(entry.filePath))) {
            emit finished(false,
                          tr("Архив сборки пытается изменить общие файлы лаунчера: «%1»")
                              .arg(entry.filePath));
            return;
        }
        applied << entry.filePath;
    }

    const QDir root(gameDir);
    if (!QDir().mkpath(gameDir)) {
        emit finished(false, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir)));
        return;
    }

    const int total = removeRelative.size() + applied.size();
    emit progress(0, total, {});
    int done = 0;

    // ── Убираем то, чего в новой ревизии больше нет ──────────────────────
    QStringList touchedDirs;
    for (const QString &relative : removeRelative) {
        if (canceled()) {
            emit finished(false, tr("Установка сборки отменена"));
            return;
        }
        // Список пришёл из нашего же файла состояния, но он лежит на диске
        // рядом с игрой и мог быть отредактирован — правила те же, что у архива.
        if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) {
            emit progress(++done, total, relative);
            continue;
        }
        // Файл мог быть удалён игроком или прийти заново в новой ревизии —
        // и то и другое нормально.
        const QString target = root.absoluteFilePath(relative);
        if (!isInside(root, target)) {
            emit progress(++done, total, relative);
            continue;
        }
        if (QFileInfo::exists(target) && !QFile::remove(target)) {
            emit finished(false,
                          tr("Не удалось удалить %1").arg(QDir::toNativeSeparators(target)));
            return;
        }
        const QString parent = QFileInfo(relative).path();
        if (!touchedDirs.contains(parent))
            touchedDirs << parent;
        emit progress(++done, total, relative);
    }
    pruneEmptyDirs(root, touchedDirs);

    // ── Раскладываем новую ревизию поверх ────────────────────────────────
    for (const QString &relative : std::as_const(applied)) {
        if (canceled()) {
            emit finished(false, tr("Установка сборки отменена"));
            return;
        }

        const QString target = root.absoluteFilePath(relative);
        if (!isInside(root, target)) {
            emit finished(false,
                          tr("Архив сборки повреждён: путь «%1» ведёт за пределы папки игры")
                              .arg(relative));
            return;
        }
        if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
            emit finished(false,
                          tr("Не удалось создать %1")
                              .arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
            return;
        }

        QFile file(target);
        const QByteArray data = reader.fileData(relative);
        // QIODevice::Truncate обязателен: поверх более длинного старого файла
        // иначе остался бы его хвост.
        if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)
            || file.write(data) != data.size()) {
            emit finished(false,
                          tr("Не удалось записать %1: %2")
                              .arg(QDir::toNativeSeparators(relative), file.errorString()));
            return;
        }
        file.close();
        emit progress(++done, total, relative);
    }

    emit packEntries(applied);
    emit finished(true, {});
}
