/* Copyright 2013-2024 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 "InstanceImportTask.h"
#include "BaseInstance.h"
#include "FileSystem.h"
#include "Application.h"
#include "MMCZip.h"
#include "NullInstance.h"
#include "settings/INISettingsObject.h"
#include "icons/IconUtils.h"
#include <QtConcurrentRun>

// FIXME: this does not belong here, it's Minecraft/Flame specific
#include "minecraft/MinecraftInstance.h"
#include "minecraft/PackProfile.h"
#include "Json.h"
#include <quazipdir.h>
#include "modplatform/modrinth/ModrinthPackManifest.h"
#include "modplatform/technic/TechnicPackProcessor.h"

#include "icons/IconList.h"
#include "Application.h"
#include "net/ChecksumValidator.h"

#include <algorithm>
#include <iterator>

InstanceImportTask::InstanceImportTask(const QUrl sourceUrl)
{
    m_sourceUrl = sourceUrl;
}

void InstanceImportTask::executeTask()
{
    if (m_sourceUrl.isLocalFile())
    {
        m_archivePath = m_sourceUrl.toLocalFile();
        processZipPack();
    }
    else
    {
        setStatus(tr("Downloading modpack:\n%1").arg(m_sourceUrl.toString()));
        m_downloadRequired = true;

        const QString path = m_sourceUrl.host() + '/' + m_sourceUrl.path();
        auto entry = APPLICATION->metacache()->resolveEntry("general", path);
        entry->setStale(true);
        m_filesNetJob = new NetJob(tr("Modpack download"), APPLICATION->network());
        m_filesNetJob->addNetAction(Net::Download::makeCached(m_sourceUrl, entry));
        m_archivePath = entry->getFullPath();
        auto job = m_filesNetJob.get();
        connect(job, &NetJob::succeeded, this, &InstanceImportTask::downloadSucceeded);
        connect(job, &NetJob::progress, this, &InstanceImportTask::downloadProgressChanged);
        connect(job, &NetJob::failed, this, &InstanceImportTask::downloadFailed);
        m_filesNetJob->start();
    }
}

void InstanceImportTask::downloadSucceeded()
{
    processZipPack();
    m_filesNetJob.reset();
}

void InstanceImportTask::downloadFailed(QString reason)
{
    emitFailed(reason);
    m_filesNetJob.reset();
}

void InstanceImportTask::downloadProgressChanged(qint64 current, qint64 total)
{
    setProgress(current / 2, total);
}

void InstanceImportTask::processZipPack()
{
    setStatus(tr("Extracting modpack"));
    QDir extractDir(m_stagingPath);
    qDebug() << "Attempting to create instance from" << m_archivePath;

    // open the zip and find relevant files in it
    m_packZip.reset(new QuaZip(m_archivePath));
    if (!m_packZip->open(QuaZip::mdUnzip))
    {
        emitFailed(tr("Unable to open supplied modpack zip file."));
        return;
    }

    QStringList blacklist = {"instance.cfg", "manifest.json"};
    QString mmcFound = MMCZip::findFolderOfFileInZip(m_packZip.get(), "instance.cfg");
    bool technicFound = QuaZipDir(m_packZip.get()).exists("/bin/modpack.jar") || QuaZipDir(m_packZip.get()).exists("/bin/version.json");
    QString modrinthFound = MMCZip::findFolderOfFileInZip(m_packZip.get(), "modrinth.index.json");
    QString root;
    if(!mmcFound.isNull())
    {
        // process as MultiMC instance/pack
        qDebug() << "MultiMC:" << mmcFound;
        root = mmcFound;
        m_modpackType = ModpackType::MultiMC;
    }
    else if (technicFound)
    {
        // process as Technic pack
        qDebug() << "Technic:" << technicFound;
        extractDir.mkpath(".minecraft");
        extractDir.cd(".minecraft");
        m_modpackType = ModpackType::Technic;
    }
    else if(!modrinthFound.isNull())
    {
        // process as Modrinth pack
        qDebug() << "Modrinth:" << modrinthFound;
        root = modrinthFound;
        m_modpackType = ModpackType::Modrinth;
    }
    if(m_modpackType == ModpackType::Unknown)
    {
        emitFailed(tr("Archive does not contain a recognized modpack type."));
        return;
    }

    // make sure we extract just the pack
    m_extractFuture = QtConcurrent::run(QThreadPool::globalInstance(), MMCZip::extractSubDir, m_packZip.get(), root, extractDir.absolutePath());
    connect(&m_extractFutureWatcher, &QFutureWatcher<QStringList>::finished, this, &InstanceImportTask::extractFinished);
    connect(&m_extractFutureWatcher, &QFutureWatcher<QStringList>::canceled, this, &InstanceImportTask::extractAborted);
    m_extractFutureWatcher.setFuture(m_extractFuture);
}

void InstanceImportTask::extractFinished()
{
    m_packZip.reset();
    if (!m_extractFuture.result())
    {
        emitFailed(tr("Failed to extract modpack"));
        return;
    }
    QDir extractDir(m_stagingPath);

    qDebug() << "Fixing permissions for extracted pack files...";
    QDirIterator it(extractDir, QDirIterator::Subdirectories);
    while (it.hasNext())
    {
        auto filepath = it.next();
        QFileInfo file(filepath);
        auto permissions = QFile::permissions(filepath);
        auto origPermissions = permissions;
        if(file.isDir())
        {
            // Folder +rwx for current user
            permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser | QFileDevice::Permission::ExeUser;
        }
        else
        {
            // File +rw for current user
            permissions |= QFileDevice::Permission::ReadUser | QFileDevice::Permission::WriteUser;
        }
        if(origPermissions != permissions)
        {
            if(!QFile::setPermissions(filepath, permissions))
            {
                logWarning(tr("Could not fix permissions for %1").arg(filepath));
            }
            else
            {
                qDebug() << "Fixed" << filepath;
            }
        }
    }

    switch(m_modpackType)
    {
        case ModpackType::MultiMC:
            processMultiMC();
            return;
        case ModpackType::Technic:
            processTechnic();
            return;
        case ModpackType::Modrinth:
            processModrinth();
            return;
        case ModpackType::Unknown:
            emitFailed(tr("Archive does not contain a recognized modpack type."));
            return;
    }
}

void InstanceImportTask::extractAborted()
{
    emitFailed(tr("Instance import has been aborted."));
    return;
}

void InstanceImportTask::processTechnic()
{
    shared_qobject_ptr<Technic::TechnicPackProcessor> packProcessor = new Technic::TechnicPackProcessor();
    connect(packProcessor.get(), &Technic::TechnicPackProcessor::succeeded, this, &InstanceImportTask::emitSucceeded);
    connect(packProcessor.get(), &Technic::TechnicPackProcessor::failed, this, &InstanceImportTask::emitFailed);
    packProcessor->run(m_globalSettings, m_instName, m_instIcon, m_stagingPath);
}

void InstanceImportTask::processMultiMC()
{
    QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg");
    auto instanceSettings = std::make_shared<INISettingsObject>(configPath);
    instanceSettings->registerSetting("InstanceType", "Legacy");

    NullInstance instance(m_globalSettings, instanceSettings, m_stagingPath);

    // reset time played on import... because packs.
    instance.resetTimePlayed();

    // set a new nice name
    instance.setName(m_instName);

    // if the icon was specified by user, use that. otherwise pull icon from the pack
    if (m_instIcon != "default")
    {
        instance.setIconKey(m_instIcon);
    }
    else
    {
        m_instIcon = instance.iconKey();

        auto importIconPath = IconUtils::findBestIconIn(instance.instanceRoot(), m_instIcon);
        if (!importIconPath.isNull() && QFile::exists(importIconPath))
        {
            // import icon
            auto iconList = APPLICATION->icons();
            if (iconList->iconFileExists(m_instIcon))
            {
                iconList->deleteIcon(m_instIcon);
            }
            iconList->installIcons({importIconPath});
        }
    }
    emitSucceeded();
}

namespace {
bool mergeOverrides(const QString &fromDir, const QString &toDir) {
    QDir dir(fromDir);
    if(!dir.exists()) {
        return true;
    }
    if(!FS::ensureFolderPathExists(toDir)) {
        return false;
    }
    const int absSourcePathLength = dir.absoluteFilePath(fromDir).length();

    QDirIterator it(fromDir, QDirIterator::Subdirectories);
    while (it.hasNext()){
        it.next();
        const auto fileInfo = it.fileInfo();
        auto fileName = fileInfo.fileName();
        if(fileName == "." || fileName == "..") {
            continue;
        }
        const QString subPathStructure = fileInfo.absoluteFilePath().mid(absSourcePathLength);
        const QString constructedAbsolutePath = toDir + subPathStructure;

        if(fileInfo.isDir()){
            //Create directory in target folder
            dir.mkpath(constructedAbsolutePath);
        } else if(fileInfo.isFile()) {
            QFileInfo targetFileInfo(constructedAbsolutePath);
            if(targetFileInfo.exists()) {
                continue;
            }
            // move
            QFile::rename(fileInfo.absoluteFilePath(), constructedAbsolutePath);
        }
    }

    dir.removeRecursively();
    return true;
}

}

void InstanceImportTask::processModrinth() {
    std::vector<Modrinth::File> files;
    QString minecraftVersion, fabricVersion, quiltVersion, forgeVersion, neoforgeVersion;
    try
    {
        QString indexPath = FS::PathCombine(m_stagingPath, "modrinth.index.json");
        auto doc = Json::requireDocument(indexPath);
        auto obj = Json::requireObject(doc, "modrinth.index.json");
        int formatVersion = Json::requireInteger(obj, "formatVersion", "modrinth.index.json");
        if (formatVersion == 1)
        {
            auto game = Json::requireString(obj, "game", "modrinth.index.json");
            if (game != "minecraft")
            {
                throw JSONValidationError("Unknown game: " + game);
            }

            auto jsonFiles = Json::requireIsArrayOf<QJsonObject>(obj, "files", "modrinth.index.json");
            for(auto & obj: jsonFiles) {
                Modrinth::File file;
                auto dirtyPath = Json::requireString(obj, "path");
                dirtyPath.replace('\\', '/');
                auto simplifiedPath = QDir::cleanPath(dirtyPath);
                QFileInfo fileInfo (simplifiedPath);
                if(simplifiedPath.startsWith("../") || simplifiedPath.contains("/../") || fileInfo.isAbsolute()) {
                    throw JSONValidationError("Invalid path found in modpack files:\n\n" + simplifiedPath);
                }
                file.path = simplifiedPath;

                // env doesn't have to be present, in that case mod is required
                auto env = Json::ensureObject(obj, "env");
                auto clientEnv = Json::ensureString(env, "client", "required");

                if(clientEnv == "required") {
                    // NOOP
                }
                else if(clientEnv == "optional") {
                    file.path += ".disabled";
                }
                else if(clientEnv == "unsupported") {
                    continue;
                }

                QJsonObject hashes = Json::requireObject(obj, "hashes");
                QString hash;
                QCryptographicHash::Algorithm hashAlgorithm;
                hash = Json::ensureString(hashes, "sha256");
                hashAlgorithm = QCryptographicHash::Sha256;
                if (hash.isEmpty())
                {
                    hash = Json::ensureString(hashes, "sha512");
                    hashAlgorithm = QCryptographicHash::Sha512;
                    if (hash.isEmpty())
                    {
                        hash = Json::ensureString(hashes, "sha1");
                        hashAlgorithm = QCryptographicHash::Sha1;
                        if (hash.isEmpty())
                        {
                            throw JSONValidationError("No hash found for: " + file.path);
                        }
                    }
                }
                file.hash = QByteArray::fromHex(hash.toLatin1());
                file.hashAlgorithm = hashAlgorithm;
                // Do not use requireUrl, which uses StrictMode, instead use QUrl's default TolerantMode (as Modrinth seems to incorrectly handle spaces)
                file.download = Json::requireValueString(Json::ensureArray(obj, "downloads").first(), "Download URL for " + file.path);
                if (!file.download.isValid())
                {
                    throw JSONValidationError("Download URL for " + file.path + " is not a correctly formatted URL");
                }
                files.push_back(file);
            }

            auto dependencies = Json::requireObject(obj, "dependencies", "modrinth.index.json");
            for (auto it = dependencies.begin(), end = dependencies.end(); it != end; ++it)
            {
                QString name = it.key();
                if (name == "minecraft")
                {
                    if (!minecraftVersion.isEmpty())
                        throw JSONValidationError("Duplicate Minecraft version");
                    minecraftVersion = Json::requireValueString(*it, "Minecraft version");
                }
                else if (name == "fabric-loader")
                {
                    if (!fabricVersion.isEmpty())
                        throw JSONValidationError("Duplicate Fabric Loader version");
                    fabricVersion = Json::requireValueString(*it, "Fabric Loader version");
                }
                else if (name == "quilt-loader")
                {
                    if (!quiltVersion.isEmpty())
                        throw JSONValidationError("Duplicate Quilt Loader version");
                    quiltVersion = Json::requireValueString(*it, "Quilt Loader version");
                }
                else if (name == "forge")
                {
                    if (!forgeVersion.isEmpty())
                        throw JSONValidationError("Duplicate Forge version");
                    forgeVersion = Json::requireValueString(*it, "Forge version");
                }
                else if (name == "neoforge")
                {
                    if (!neoforgeVersion.isEmpty())
                        throw JSONValidationError("Duplicate NeoForge version");
                    neoforgeVersion = Json::requireValueString(*it, "NeoForge version");
                }
                else
                {
                    throw JSONValidationError("Unknown dependency type: " + name);
                }
            }
        }
        else
        {
            throw JSONValidationError(QStringLiteral("Unknown format version: %s").arg(formatVersion));
        }
        QFile::remove(indexPath);
    }
    catch (const JSONValidationError &e)
    {
        emitFailed(tr("Could not understand pack index:\n") + e.cause());
        return;
    }
    QString clientOverridePath = FS::PathCombine(m_stagingPath, "client-overrides");
    if (QFile::exists(clientOverridePath)) {
        QString mcPath = FS::PathCombine(m_stagingPath, ".minecraft");
        if (!QFile::rename(clientOverridePath, mcPath)) {
            emitFailed(tr("Could not rename the overrides folder:\n") + "overrides");
            return;
        }
    }

    // TODO: only extract things we actually want instead of everything only to just delete it afterwards ...
    if(!mergeOverrides(FS::PathCombine(m_stagingPath, "client-overrides"), FS::PathCombine(m_stagingPath, ".minecraft"))) {
        emitFailed(tr("Could not merge the overrides folder:\n") + "client-overrides");
        return;
    }

    if(!mergeOverrides(FS::PathCombine(m_stagingPath, "overrides"), FS::PathCombine(m_stagingPath, ".minecraft"))) {
        emitFailed(tr("Could not merge the overrides folder:\n") + "overrides");
        return;
    }

    FS::deletePath(FS::PathCombine(m_stagingPath, "server-overrides"));


    QString configPath = FS::PathCombine(m_stagingPath, "instance.cfg");
    auto instanceSettings = std::make_shared<INISettingsObject>(configPath);
    instanceSettings->registerSetting("InstanceType", "Legacy");
    instanceSettings->set("InstanceType", "OneSix");
    MinecraftInstance instance(m_globalSettings, instanceSettings, m_stagingPath);
    auto components = instance.getPackProfile();
    components->buildingFromScratch();
    components->setComponentVersion("net.minecraft", minecraftVersion, true);
    if (!fabricVersion.isEmpty())
        components->setComponentVersion("net.fabricmc.fabric-loader", fabricVersion, true);
    if (!quiltVersion.isEmpty())
        components->setComponentVersion("org.quiltmc.quilt-loader", quiltVersion, true);
    if (!forgeVersion.isEmpty())
        components->setComponentVersion("net.minecraftforge", forgeVersion, true);
    if (!neoforgeVersion.isEmpty())
        components->setComponentVersion("net.neoforged", neoforgeVersion, true);
    if (m_instIcon != "default")
    {
        instance.setIconKey(m_instIcon);
    }
    instance.setName(m_instName);
    instance.saveNow();

    m_filesNetJob = new NetJob(tr("Mod download"), APPLICATION->network());
    for (auto &file : files)
    {
        auto path = FS::PathCombine(m_stagingPath, ".minecraft", file.path);
        qDebug() << "Will download" << file.download << "to" << path;
        auto dl = Net::Download::makeFile(file.download, path);
        dl->addValidator(new Net::ChecksumValidator(file.hashAlgorithm, file.hash));
        m_filesNetJob->addNetAction(dl);
    }
    connect(m_filesNetJob.get(), &NetJob::succeeded, this, [&]()
    {
        m_filesNetJob.reset();
        emitSucceeded();
    });
    connect(m_filesNetJob.get(), &NetJob::failed, [&](const QString &reason)
    {
        m_filesNetJob.reset();
        emitFailed(reason);
    });
    connect(m_filesNetJob.get(), &NetJob::progress, [&](qint64 current, qint64 total)
    {
        setProgress(current, total);
    });
    setStatus(tr("Downloading mods..."));
    m_filesNetJob->start();
}
