// SPDX-License-Identifier: GPL-3.0-only
/*
 *  Prism Launcher - Minecraft Launcher
 *  Copyright (C) 2022 Sefa Eyeoglu <contact@scrumplex.net>
 *
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation, version 3.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * This file incorporates work covered by the following copyright and
 * permission notice:
 *
 *      Copyright 2013-2021 MultiMC Contributors
 *
 *      Licensed under the Apache License, Version 2.0 (the "License");
 *      you may not use this file except in compliance with the License.
 *      You may obtain a copy of the License at
 *
 *          http://www.apache.org/licenses/LICENSE-2.0
 *
 *      Unless required by applicable law or agreed to in writing, software
 *      distributed under the License is distributed on an "AS IS" BASIS,
 *      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *      See the License for the specific language governing permissions and
 *      limitations under the License.
 */

#include "FlameInstanceCreationTask.h"

#include "QObjectPtr.h"
#include "minecraft/mod/tasks/LocalModUpdateTask.h"
#include "modplatform/flame/FileResolvingTask.h"
#include "modplatform/flame/FlameAPI.h"
#include "modplatform/flame/FlameModIndex.h"
#include "modplatform/flame/PackManifest.h"

#include "Application.h"
#include "FileSystem.h"
#include "InstanceList.h"
#include "Json.h"

#include "minecraft/MinecraftInstance.h"
#include "minecraft/PackProfile.h"

#include "modplatform/helpers/OverrideUtils.h"

#include "settings/INISettingsObject.h"

#include "tasks/ConcurrentTask.h"
#include "ui/dialogs/BlockedModsDialog.h"
#include "ui/dialogs/CustomMessageBox.h"

#include <QDebug>
#include <QFileInfo>

#include "meta/Index.h"
#include "minecraft/World.h"
#include "minecraft/mod/tasks/LocalResourceParse.h"
#include "net/ApiDownload.h"
#include "ui/pages/modplatform/OptionalModDialog.h"

static const FlameAPI api;

bool FlameCreationTask::abort()
{
    if (!canAbort())
        return false;

    m_abort = true;
    if (m_process_update_file_info_job)
        m_process_update_file_info_job->abort();
    if (m_files_job)
        m_files_job->abort();
    if (m_mod_id_resolver)
        m_mod_id_resolver->abort();

    return Task::abort();
}

bool FlameCreationTask::updateInstance()
{
    auto instance_list = APPLICATION->instances();

    // FIXME: How to handle situations when there's more than one install already for a given modpack?
    InstancePtr inst;
    if (auto original_id = originalInstanceID(); !original_id.isEmpty()) {
        inst = instance_list->getInstanceById(original_id);
        Q_ASSERT(inst);
    } else {
        inst = instance_list->getInstanceByManagedName(originalName());

        if (!inst) {
            inst = instance_list->getInstanceById(originalName());

            if (!inst)
                return false;
        }
    }

    QString index_path(FS::PathCombine(m_stagingPath, "manifest.json"));

    try {
        Flame::loadManifest(m_pack, index_path);
    } catch (const JSONValidationError& e) {
        setError(tr("Could not understand pack manifest:\n") + e.cause());
        return false;
    }

    auto version_id = inst->getManagedPackVersionName();
    auto version_str = !version_id.isEmpty() ? tr(" (version %1)").arg(version_id) : "";

    if (shouldConfirmUpdate()) {
        auto should_update = askIfShouldUpdate(m_parent, version_str);
        if (should_update == ShouldUpdate::SkipUpdating)
            return false;
        if (should_update == ShouldUpdate::Cancel) {
            m_abort = true;
            return false;
        }
    }

    QDir old_inst_dir(inst->instanceRoot());

    QString old_index_folder(FS::PathCombine(old_inst_dir.absolutePath(), "flame"));
    QString old_index_path(FS::PathCombine(old_index_folder, "manifest.json"));

    QFileInfo old_index_file(old_index_path);
    if (old_index_file.exists()) {
        Flame::Manifest old_pack;
        Flame::loadManifest(old_pack, old_index_path);

        auto& old_files = old_pack.files;

        auto& files = m_pack.files;

        // Remove repeated files, we don't need to download them!
        auto files_iterator = files.begin();
        while (files_iterator != files.end()) {
            auto const& file = files_iterator;

            auto old_file = old_files.find(file.key());
            if (old_file != old_files.end()) {
                // We found a match, but is it a different version?
                if (old_file->fileId == file->fileId) {
                    qDebug() << "Removed file at" << file->targetFolder << "with id" << file->fileId << "from list of downloads";

                    old_files.remove(file.key());
                    files_iterator = files.erase(files_iterator);

                    if (files_iterator != files.begin())
                        files_iterator--;
                }
            }

            files_iterator++;
        }

        QDir old_minecraft_dir(inst->gameRoot());

        // We will remove all the previous overrides, to prevent duplicate files!
        // TODO: Currently 'overrides' will always override the stuff on update. How do we preserve unchanged overrides?
        // FIXME: We may want to do something about disabled mods.
        auto old_overrides = Override::readOverrides("overrides", old_index_folder);
        for (const auto& entry : old_overrides) {
            if (entry.isEmpty())
                continue;
            qDebug() << "Scheduling" << entry << "for removal";
            m_files_to_remove.append(old_minecraft_dir.absoluteFilePath(entry));
        }

        // Remove remaining old files (we need to do an API request to know which ids are which files...)
        QStringList fileIds;

        for (auto& file : old_files) {
            fileIds.append(QString::number(file.fileId));
        }

        auto raw_response = std::make_shared<QByteArray>();
        auto job = api.getFiles(fileIds, raw_response);

        QEventLoop loop;

        connect(job.get(), &Task::succeeded, this, [this, raw_response, fileIds, old_inst_dir, &old_files, old_minecraft_dir] {
            // Parse the API response
            QJsonParseError parse_error{};
            auto doc = QJsonDocument::fromJson(*raw_response, &parse_error);
            if (parse_error.error != QJsonParseError::NoError) {
                qWarning() << "Error while parsing JSON response from Flame files task at " << parse_error.offset
                           << " reason: " << parse_error.errorString();
                qWarning() << *raw_response;
                return;
            }

            try {
                QJsonArray entries;
                if (fileIds.size() == 1)
                    entries = { Json::requireObject(Json::requireObject(doc), "data") };
                else
                    entries = Json::requireArray(Json::requireObject(doc), "data");

                for (auto entry : entries) {
                    auto entry_obj = Json::requireObject(entry);

                    Flame::File file;
                    // We don't care about blocked mods, we just need local data to delete the file
                    file.version = FlameMod::loadIndexedPackVersion(entry_obj);
                    auto id = Json::requireInteger(entry_obj, "id");
                    old_files.insert(id, file);
                }
            } catch (Json::JsonException& e) {
                qCritical() << e.cause() << e.what();
            }

            // Delete the files
            for (auto& file : old_files) {
                if (file.version.fileName.isEmpty() || file.targetFolder.isEmpty())
                    continue;

                QString relative_path(FS::PathCombine(file.targetFolder, file.version.fileName));
                qDebug() << "Scheduling" << relative_path << "for removal";
                m_files_to_remove.append(old_minecraft_dir.absoluteFilePath(relative_path));
            }
        });
        connect(job.get(), &Task::failed, this, [](QString reason) { qCritical() << "Failed to get files: " << reason; });
        connect(job.get(), &Task::finished, &loop, &QEventLoop::quit);

        m_process_update_file_info_job = job;
        job->start();

        loop.exec();

        m_process_update_file_info_job = nullptr;
    } else {
        // We don't have an old index file, so we may duplicate stuff!
        auto dialog = CustomMessageBox::selectable(m_parent, tr("No index file."),
                                                   tr("We couldn't find a suitable index file for the older version. This may cause some "
                                                      "of the files to be duplicated. Do you want to continue?"),
                                                   QMessageBox::Warning, QMessageBox::Ok | QMessageBox::Cancel);

        if (dialog->exec() == QDialog::DialogCode::Rejected) {
            m_abort = true;
            return false;
        }
    }

    setOverride(true, inst->id());
    qDebug() << "Will override instance!";

    m_instance = inst;

    // We let it go through the createInstance() stage, just with a couple modifications for updating
    return false;
}

QString FlameCreationTask::getVersionForLoader(QString uid, QString loaderType, QString loaderVersion, QString mcVersion)
{
    if (loaderVersion == "recommended") {
        auto vlist = APPLICATION->metadataIndex()->get(uid);
        if (!vlist) {
            setError(tr("Failed to get local metadata index for %1").arg(uid));
            return {};
        }

        if (!vlist->isLoaded()) {
            QEventLoop loadVersionLoop;
            auto task = vlist->getLoadTask();
            connect(task.get(), &Task::finished, &loadVersionLoop, &QEventLoop::quit);
            if (!task->isRunning())
                task->start();

            loadVersionLoop.exec();
        }

        for (auto version : vlist->versions()) {
            // first recommended build we find, we use.
            if (!version->isRecommended())
                continue;
            auto reqs = version->requiredSet();

            // filter by minecraft version, if the loader depends on a certain version.
            // not all mod loaders depend on a given Minecraft version, so we won't do this
            // filtering for those loaders.
            if (loaderType == "forge" || loaderType == "neoforge") {
                auto iter = std::find_if(reqs.begin(), reqs.end(), [mcVersion](const Meta::Require& req) {
                    return req.uid == "net.minecraft" && req.equalsVersion == mcVersion;
                });
                if (iter == reqs.end())
                    continue;
            }
            return version->descriptor();
        }

        setError(tr("Failed to find version for %1 loader").arg(loaderType));
        return {};
    }

    if (loaderVersion.isEmpty()) {
        emitFailed(tr("No loader version set for modpack!"));
        return {};
    }

    return loaderVersion;
}

bool FlameCreationTask::createInstance()
{
    QEventLoop loop;

    QString parent_folder(FS::PathCombine(m_stagingPath, "flame"));

    try {
        QString index_path(FS::PathCombine(m_stagingPath, "manifest.json"));
        if (!m_pack.is_loaded)
            Flame::loadManifest(m_pack, index_path);

        // Keep index file in case we need it some other time (like when changing versions)
        QString new_index_place(FS::PathCombine(parent_folder, "manifest.json"));
        FS::ensureFilePathExists(new_index_place);
        FS::move(index_path, new_index_place);

    } catch (const JSONValidationError& e) {
        setError(tr("Could not understand pack manifest:\n") + e.cause());
        return false;
    }

    if (!m_pack.overrides.isEmpty()) {
        QString overridePath = FS::PathCombine(m_stagingPath, m_pack.overrides);
        if (QFile::exists(overridePath)) {
            // Create a list of overrides in "overrides.txt" inside flame/
            Override::createOverrides("overrides", parent_folder, overridePath);

            QString mcPath = FS::PathCombine(m_stagingPath, "minecraft");
            if (!FS::move(overridePath, mcPath)) {
                setError(tr("Could not rename the overrides folder:\n") + m_pack.overrides);
                return false;
            }
        } else {
            logWarning(
                tr("The specified overrides folder (%1) is missing. Maybe the modpack was already used before?").arg(m_pack.overrides));
        }
    }

    QString loaderType;
    QString loaderUid;
    QString loaderVersion;

    for (auto& loader : m_pack.minecraft.modLoaders) {
        auto id = loader.id;
        if (id.startsWith("neoforge-")) {
            id.remove("neoforge-");
            if (id.startsWith("1.20.1-"))
                id.remove("1.20.1-");  // this is a mess for curseforge
            loaderType = "neoforge";
            loaderUid = "net.neoforged";
        } else if (id.startsWith("forge-")) {
            id.remove("forge-");
            loaderType = "forge";
            loaderUid = "net.minecraftforge";
        } else if (id.startsWith("fabric-")) {
            id.remove("fabric-");
            loaderType = "fabric";
            loaderUid = "net.fabricmc.fabric-loader";
        } else if (id.startsWith("quilt-")) {
            id.remove("quilt-");
            loaderType = "quilt";
            loaderUid = "org.quiltmc.quilt-loader";
        } else {
            logWarning(tr("Unknown mod loader in manifest: %1").arg(id));
            continue;
        }
        loaderVersion = id;
    }

    QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg");
    auto instanceSettings = std::make_shared<INISettingsObject>(configPath);
    MinecraftInstance instance(m_globalSettings, instanceSettings, m_stagingPath);
    auto mcVersion = m_pack.minecraft.version;

    // Hack to correct some 'special sauce'...
    if (mcVersion.endsWith('.')) {
        mcVersion.remove(QRegularExpression("[.]+$"));
        logWarning(tr("Mysterious trailing dots removed from Minecraft version while importing pack."));
    }

    auto components = instance.getPackProfile();
    components->buildingFromScratch();
    components->setComponentVersion("net.minecraft", mcVersion, true);
    if (!loaderType.isEmpty()) {
        auto version = getVersionForLoader(loaderUid, loaderType, loaderVersion, mcVersion);
        if (version.isEmpty())
            return false;
        components->setComponentVersion(loaderUid, version);
    }

    if (m_instIcon != "default") {
        instance.setIconKey(m_instIcon);
    } else {
        if (m_pack.name.contains("Direwolf20")) {
            instance.setIconKey("steve");
        } else if (m_pack.name.contains("FTB") || m_pack.name.contains("Feed The Beast")) {
            instance.setIconKey("ftb_logo");
        } else {
            instance.setIconKey("flame");
        }
    }

    QString jarmodsPath = FS::PathCombine(m_stagingPath, "minecraft", "jarmods");
    QFileInfo jarmodsInfo(jarmodsPath);
    if (jarmodsInfo.isDir()) {
        // install all the jar mods
        qDebug() << "Found jarmods:";
        QDir jarmodsDir(jarmodsPath);
        QStringList jarMods;
        for (const auto& info : jarmodsDir.entryInfoList(QDir::NoDotAndDotDot | QDir::Files)) {
            qDebug() << info.fileName();
            jarMods.push_back(info.absoluteFilePath());
        }
        auto profile = instance.getPackProfile();
        profile->installJarMods(jarMods);
        // nuke the original files
        FS::deletePath(jarmodsPath);
    }

    // Don't add managed info to packs without an ID (most likely imported from ZIP)
    if (!m_managed_id.isEmpty())
        instance.setManagedPack("flame", m_managed_id, m_pack.name, m_managed_version_id, m_pack.version);
    else
        instance.setManagedPack("flame", "", name(), "", "");

    instance.setName(name());

    m_mod_id_resolver.reset(new Flame::FileResolvingTask(APPLICATION->network(), m_pack));
    connect(m_mod_id_resolver.get(), &Flame::FileResolvingTask::succeeded, this, [this, &loop] { idResolverSucceeded(loop); });
    connect(m_mod_id_resolver.get(), &Flame::FileResolvingTask::failed, [&](QString reason) {
        m_mod_id_resolver.reset();
        setError(tr("Unable to resolve mod IDs:\n") + reason);
        loop.quit();
    });
    connect(m_mod_id_resolver.get(), &Flame::FileResolvingTask::aborted, &loop, &QEventLoop::quit);
    connect(m_mod_id_resolver.get(), &Flame::FileResolvingTask::progress, this, &FlameCreationTask::setProgress);
    connect(m_mod_id_resolver.get(), &Flame::FileResolvingTask::status, this, &FlameCreationTask::setStatus);
    connect(m_mod_id_resolver.get(), &Flame::FileResolvingTask::stepProgress, this, &FlameCreationTask::propagateStepProgress);
    connect(m_mod_id_resolver.get(), &Flame::FileResolvingTask::details, this, &FlameCreationTask::setDetails);
    m_mod_id_resolver->start();

    loop.exec();

    bool did_succeed = getError().isEmpty();

    // Update information of the already installed instance, if any.
    if (m_instance && did_succeed) {
        setAbortable(false);
        auto inst = m_instance.value();

        inst->copyManagedPack(instance);
    }

    return did_succeed;
}

void FlameCreationTask::idResolverSucceeded(QEventLoop& loop)
{
    auto results = m_mod_id_resolver->getResults();

    // first check for blocked mods
    QList<BlockedMod> blocked_mods;
    auto anyBlocked = false;
    for (const auto& result : results.files.values()) {
        if (result.version.fileName.endsWith(".zip")) {
            m_ZIP_resources.append(std::make_pair(result.version.fileName, result.targetFolder));
        }

        if (result.version.downloadUrl.isEmpty()) {
            BlockedMod blocked_mod;
            blocked_mod.name = result.version.fileName;
            blocked_mod.websiteUrl = QString("%1/download/%2").arg(result.pack.websiteUrl, QString::number(result.fileId));
            blocked_mod.hash = result.version.hash;
            blocked_mod.matched = false;
            blocked_mod.localPath = "";
            blocked_mod.targetFolder = result.targetFolder;

            blocked_mods.append(blocked_mod);

            anyBlocked = true;
        }
    }
    if (anyBlocked) {
        qWarning() << "Blocked mods found, displaying mod list";

        BlockedModsDialog message_dialog(m_parent, tr("Blocked mods found"),
                                         tr("The following files are not available for download in third party launchers.<br/>"
                                            "You will need to manually download them and add them to the instance."),
                                         blocked_mods);

        message_dialog.setModal(true);

        if (message_dialog.exec()) {
            qDebug() << "Post dialog blocked mods list: " << blocked_mods;
            copyBlockedMods(blocked_mods);
            setupDownloadJob(loop);
        } else {
            m_mod_id_resolver.reset();
            setError("Canceled");
            loop.quit();
        }
    } else {
        setupDownloadJob(loop);
    }
}

void FlameCreationTask::setupDownloadJob(QEventLoop& loop)
{
    m_files_job.reset(new NetJob(tr("Mod Download Flame"), APPLICATION->network()));
    auto results = m_mod_id_resolver->getResults().files;

    QStringList optionalFiles;
    for (auto& result : results) {
        if (!result.required) {
            optionalFiles << FS::PathCombine(result.targetFolder, result.version.fileName);
        }
    }

    QStringList selectedOptionalMods;
    if (!optionalFiles.empty()) {
        OptionalModDialog optionalModDialog(m_parent, optionalFiles);
        if (optionalModDialog.exec() == QDialog::Rejected) {
            emitAborted();
            loop.quit();
            return;
        }

        selectedOptionalMods = optionalModDialog.getResult();
    }
    for (const auto& result : results) {
        auto fileName = result.version.fileName;
        fileName = FS::RemoveInvalidPathChars(fileName);
        auto relpath = FS::PathCombine(result.targetFolder, fileName);

        if (!result.required && !selectedOptionalMods.contains(relpath)) {
            relpath += ".disabled";
        }

        relpath = FS::PathCombine("minecraft", relpath);
        auto path = FS::PathCombine(m_stagingPath, relpath);

        if (!result.version.downloadUrl.isEmpty()) {
            qDebug() << "Will download" << result.version.downloadUrl << "to" << path;
            auto dl = Net::ApiDownload::makeFile(result.version.downloadUrl, path);
            m_files_job->addNetAction(dl);
        }
    }

    connect(m_files_job.get(), &NetJob::finished, this, [this, &loop]() {
        m_files_job.reset();
        validateZIPResources(loop);
    });
    connect(m_files_job.get(), &NetJob::failed, [&](QString reason) {
        m_files_job.reset();
        setError(reason);
    });
    connect(m_files_job.get(), &NetJob::progress, this, [this](qint64 current, qint64 total) {
        setDetails(tr("%1 out of %2 complete").arg(current).arg(total));
        setProgress(current, total);
    });
    connect(m_files_job.get(), &NetJob::stepProgress, this, &FlameCreationTask::propagateStepProgress);

    setStatus(tr("Downloading mods..."));
    m_files_job->start();
}

/// @brief copy the matched blocked mods to the instance staging area
/// @param blocked_mods list of the blocked mods and their matched paths
void FlameCreationTask::copyBlockedMods(QList<BlockedMod> const& blocked_mods)
{
    setStatus(tr("Copying Blocked Mods..."));
    setAbortable(false);
    int i = 0;
    int total = blocked_mods.length();
    setProgress(i, total);
    for (auto const& mod : blocked_mods) {
        if (!mod.matched) {
            qDebug() << mod.name << "was not matched to a local file, skipping copy";
            continue;
        }

        auto destPath = FS::PathCombine(m_stagingPath, "minecraft", mod.targetFolder, mod.name);

        setStatus(tr("Copying Blocked Mods (%1 out of %2 are done)").arg(QString::number(i), QString::number(total)));

        qDebug() << "Will try to copy" << mod.localPath << "to" << destPath;

        if (!FS::copy(mod.localPath, destPath)()) {
            qDebug() << "Copy of" << mod.localPath << "to" << destPath << "Failed";
        }

        i++;
        setProgress(i, total);
    }

    setAbortable(true);
}

void FlameCreationTask::validateZIPResources(QEventLoop& loop)
{
    qDebug() << "Validating whether resources stored as .zip are in the right place";
    QStringList zipMods;
    for (auto [fileName, targetFolder] : m_ZIP_resources) {
        qDebug() << "Checking" << fileName << "...";
        auto localPath = FS::PathCombine(m_stagingPath, "minecraft", targetFolder, fileName);

        /// @brief check the target and move the the file
        /// @return path where file can now be found
        auto validatePath = [&localPath, this](QString fileName, QString targetFolder, QString realTarget) {
            if (targetFolder != realTarget) {
                qDebug() << "Target folder of" << fileName << "is incorrect, it belongs in" << realTarget;
                auto destPath = FS::PathCombine(m_stagingPath, "minecraft", realTarget, fileName);
                qDebug() << "Moving" << localPath << "to" << destPath;
                if (FS::move(localPath, destPath)) {
                    return destPath;
                }
            } else {
                qDebug() << "Target folder of" << fileName << "is correct at" << targetFolder;
            }
            return localPath;
        };

        auto installWorld = [this](QString worldPath) {
            qDebug() << "Installing World from" << worldPath;
            QFileInfo worldFileInfo(worldPath);
            World w(worldFileInfo);
            if (!w.isValid()) {
                qDebug() << "World at" << worldPath << "is not valid, skipping install.";
            } else {
                w.install(FS::PathCombine(m_stagingPath, "minecraft", "saves"));
            }
        };

        QFileInfo localFileInfo(localPath);
        auto type = ResourceUtils::identify(localFileInfo);

        QString worldPath;

        switch (type) {
            case PackedResourceType::Mod:
                validatePath(fileName, targetFolder, "mods");
                zipMods.push_back(fileName);
                break;
            case PackedResourceType::ResourcePack:
                validatePath(fileName, targetFolder, "resourcepacks");
                break;
            case PackedResourceType::TexturePack:
                validatePath(fileName, targetFolder, "texturepacks");
                break;
            case PackedResourceType::DataPack:
                validatePath(fileName, targetFolder, "datapacks");
                break;
            case PackedResourceType::ShaderPack:
                // in theory flame API can't do this but who knows, that *may* change ?
                // better to handle it if it *does* occur in the future
                validatePath(fileName, targetFolder, "shaderpacks");
                break;
            case PackedResourceType::WorldSave:
                worldPath = validatePath(fileName, targetFolder, "saves");
                installWorld(worldPath);
                break;
            case PackedResourceType::UNKNOWN:
            default:
                qDebug() << "Can't Identify" << fileName << "at" << localPath << ", leaving it where it is.";
                break;
        }
    }
    auto task = makeShared<ConcurrentTask>("CreateModMetadata", APPLICATION->settings()->get("NumberOfConcurrentTasks").toInt());
    auto results = m_mod_id_resolver->getResults().files;
    auto folder = FS::PathCombine(m_stagingPath, "minecraft", "mods", ".index");
    for (auto file : results) {
        if (file.targetFolder != "mods" || (file.version.fileName.endsWith(".zip") && !zipMods.contains(file.version.fileName))) {
            continue;
        }
        task->addTask(makeShared<LocalModUpdateTask>(folder, file.pack, file.version));
    }
    connect(task.get(), &Task::finished, &loop, &QEventLoop::quit);
    m_process_update_file_info_job = task;
    task->start();
}
