/* SPDX-FileCopyrightText: 2026 Project Tick
 * SPDX-FileContributor: Project Tick
 * SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-MeshMC-MMCO-Module-Exception-1.0
 *
 *   MeshMC - A Custom Launcher for Minecraft
 *   Copyright (C) 2026 Project Tick
 *
 *   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, either version 3 of the License, or
 *   (at your option) any later version, with the additional permission
 *   described in the MeshMC MMCO Module Exception 1.0.
 *
 *   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/>.
 *
 *   You should have received a copy of the MeshMC MMCO Module Exception 1.0
 *   along with this program.  If not, see <https://projecttick.org/licenses/>.
 */

#include "ComponentUpdateTask.h"

#include "PackProfile_p.h"
#include "PackProfile.h"
#include "Component.h"
#include "meta/Index.h"
#include "meta/VersionList.h"
#include "meta/Version.h"
#include "ComponentUpdateTask_p.h"
#include "cassert"
#include "Version.h"
#include "net/Mode.h"
#include "OneSixVersionFormat.h"

#include "Application.h"

/*
 * This is responsible for loading the components of a component list AND
 * resolving dependency issues between them
 */

/*
 * FIXME: the 'one shot async task' nature of this does not fit the intended
 * usage Really, it should be a reactor/state machine that receives input from
 * the application and dynamically adapts to changing requirements...
 *
 * The reactor should be the only entry into manipulating the PackProfile.
 * See: https://en.wikipedia.org/wiki/Reactor_pattern
 */

/*
 * Or make this operate on a snapshot of the PackProfile state, then merge
 * results in as long as the snapshot and PackProfile didn't change? If the
 * component list changes, start over.
 */

ComponentUpdateTask::ComponentUpdateTask(Mode mode, Net::Mode netmode,
										 PackProfile* list, QObject* parent)
	: Task(parent)
{
	d.reset(new ComponentUpdateTaskData);
	d->m_list = list;
	d->mode = mode;
	d->netmode = netmode;
}

ComponentUpdateTask::~ComponentUpdateTask() {}

void ComponentUpdateTask::executeTask()
{
	qDebug() << "Loading components";
	loadComponents();
}

namespace
{
	enum class LoadResult { LoadedLocal, RequiresRemote, Failed };

	LoadResult composeLoadResult(LoadResult a, LoadResult b)
	{
		if (a < b) {
			return b;
		}
		return a;
	}

	static LoadResult loadComponent(ComponentPtr component, Task::Ptr& loadTask,
									Net::Mode netmode)
	{
		if (component->m_loaded) {
			qDebug() << component->getName() << "is already loaded";
			return LoadResult::LoadedLocal;
		}

		LoadResult result = LoadResult::Failed;
		auto customPatchFilename = component->getFilename();
		if (QFile::exists(customPatchFilename)) {
			// if local file exists...

			// check for uid problems inside...
			bool fileChanged = false;
			auto file = ProfileUtils::parseJsonFile(
				QFileInfo(customPatchFilename), false);
			if (file->uid != component->m_uid) {
				file->uid = component->m_uid;
				fileChanged = true;
			}
			if (fileChanged) {
				// FIXME: @QUALITY do not ignore return value
				ProfileUtils::saveJsonFile(
					OneSixVersionFormat::versionFileToJson(file),
					customPatchFilename);
			}

			component->m_file = file;
			component->m_loaded = true;
			result = LoadResult::LoadedLocal;
		} else {
			auto metaVersion = APPLICATION->metadataIndex()->get(
				component->m_uid, component->m_version);
			component->m_metaVersion = metaVersion;
			if (metaVersion->isLoaded()) {
				component->m_loaded = true;
				result = LoadResult::LoadedLocal;
			} else {
				metaVersion->load(netmode);
				loadTask = metaVersion->getCurrentTask();
				if (loadTask)
					result = LoadResult::RequiresRemote;
				else if (metaVersion->isLoaded())
					result = LoadResult::LoadedLocal;
				else
					result = LoadResult::Failed;
			}
		}
		return result;
	}

	// FIXME: dead code. determine if this can still be useful?
	/*
	static LoadResult loadPackProfile(ComponentPtr component, Task::Ptr&
	loadTask, Net::Mode netmode)
	{
		if(component->m_loaded)
		{
			qDebug() << component->getName() << "is already loaded";
			return LoadResult::LoadedLocal;
		}

		LoadResult result = LoadResult::Failed;
		auto metaList = APPLICATION->metadataIndex()->get(component->m_uid);
		if(metaList->isLoaded())
		{
			component->m_loaded = true;
			result = LoadResult::LoadedLocal;
		}
		else
		{
			metaList->load(netmode);
			loadTask = metaList->getCurrentTask();
			result = LoadResult::RequiresRemote;
		}
		return result;
	}
	*/

	static LoadResult loadIndex(Task::Ptr& loadTask, Net::Mode netmode)
	{
		// FIXME: DECIDE. do we want to run the update task anyway?
		if (APPLICATION->metadataIndex()->isLoaded()) {
			qDebug() << "Index is already loaded";
			return LoadResult::LoadedLocal;
		}
		APPLICATION->metadataIndex()->load(netmode);
		loadTask = APPLICATION->metadataIndex()->getCurrentTask();
		if (loadTask) {
			return LoadResult::RequiresRemote;
		}
		// FIXME: this is assuming the load succeeded... did it really?
		return LoadResult::LoadedLocal;
	}
} // namespace

void ComponentUpdateTask::loadComponents()
{
	LoadResult result = LoadResult::LoadedLocal;
	size_t taskIndex = 0;
	size_t componentIndex = 0;
	d->remoteLoadSuccessful = true;
	// load the main index (it is needed to determine if components can revert)
	{
		// FIXME: tear out as a method? or lambda?
		Task::Ptr indexLoadTask;
		auto singleResult = loadIndex(indexLoadTask, d->netmode);
		result = composeLoadResult(result, singleResult);
		if (indexLoadTask) {
			qDebug() << "Remote loading is being run for metadata index";
			RemoteLoadStatus status;
			status.type = RemoteLoadStatus::Type::Index;
			d->remoteLoadStatusList.append(status);
			connect(indexLoadTask.get(), &Task::succeeded,
					[this, taskIndex]() { remoteLoadSucceeded(taskIndex); });
			connect(indexLoadTask.get(), &Task::failed,
					[this, taskIndex](const QString& error) {
						remoteLoadFailed(taskIndex, error);
					});
			taskIndex++;
		}
	}
	// load all the components OR their lists...
	for (auto component : d->m_list->d->components) {
		Task::Ptr loadTask;
		LoadResult singleResult;
		RemoteLoadStatus::Type loadType;
		// FIXME: to do this right, we need to load the lists and decide on
		// which versions to use during dependency resolution. For now, ignore
		// all that...
#if 0
        switch(d->mode)
        {
            case Mode::Launch:
            {
                singleResult = loadComponent(component, loadTask, d->netmode);
                loadType = RemoteLoadStatus::Type::Version;
                break;
            }
            case Mode::Resolution:
            {
                singleResult = loadPackProfile(component, loadTask, d->netmode);
                loadType = RemoteLoadStatus::Type::List;
                break;
            }
        }
#else
		singleResult = loadComponent(component, loadTask, d->netmode);
		loadType = RemoteLoadStatus::Type::Version;
#endif
		if (singleResult == LoadResult::LoadedLocal) {
			component->updateCachedData();
		}
		result = composeLoadResult(result, singleResult);
		if (loadTask) {
			qDebug() << "Remote loading is being run for"
					 << component->getName();
			connect(loadTask.get(), &Task::succeeded,
					[this, taskIndex]() { remoteLoadSucceeded(taskIndex); });
			connect(loadTask.get(), &Task::failed,
					[this, taskIndex](const QString& error) {
						remoteLoadFailed(taskIndex, error);
					});
			RemoteLoadStatus status;
			status.type = loadType;
			status.PackProfileIndex = componentIndex;
			d->remoteLoadStatusList.append(status);
			taskIndex++;
		}
		componentIndex++;
	}
	d->remoteTasksInProgress = taskIndex;
	switch (result) {
		case LoadResult::LoadedLocal: {
			// Everything got loaded. Advance to dependency resolution.
			resolveDependencies(d->mode == Mode::Launch ||
								d->netmode == Net::Mode::Offline);
			break;
		}
		case LoadResult::RequiresRemote: {
			// we wait for signals.
			break;
		}
		case LoadResult::Failed: {
			emitFailed(tr("Some component metadata load tasks failed."));
			break;
		}
	}
}

namespace
{
	struct RequireEx : public Meta::Require {
		size_t indexOfFirstDependee = 0;
	};
	struct RequireCompositionResult {
		bool ok;
		RequireEx outcome;
	};
	using RequireExSet = std::set<RequireEx>;
} // namespace

static RequireCompositionResult composeRequirement(const RequireEx& a,
												   const RequireEx& b)
{
	assert(a.uid == b.uid);
	RequireEx out;
	out.uid = a.uid;
	out.indexOfFirstDependee =
		std::min(a.indexOfFirstDependee, b.indexOfFirstDependee);
	if (a.equalsVersion.isEmpty()) {
		out.equalsVersion = b.equalsVersion;
	} else if (b.equalsVersion.isEmpty()) {
		out.equalsVersion = a.equalsVersion;
	} else if (a.equalsVersion == b.equalsVersion) {
		out.equalsVersion = a.equalsVersion;
	} else {
		// FIXME: mark error as explicit version conflict
		return {false, out};
	}

	if (a.suggests.isEmpty()) {
		out.suggests = b.suggests;
	} else if (b.suggests.isEmpty()) {
		out.suggests = a.suggests;
	} else {
		Version aVer(a.suggests);
		Version bVer(b.suggests);
		out.suggests = (aVer < bVer ? b.suggests : a.suggests);
	}
	return {true, out};
}

// gather the requirements from all components, finding any obvious conflicts
static bool gatherRequirementsFromComponents(const ComponentContainer& input,
											 RequireExSet& output)
{
	bool succeeded = true;
	size_t componentNum = 0;
	for (auto component : input) {
		auto& componentRequires = component->m_cachedRequires;
		for (const auto& componentRequire : componentRequires) {
			auto found =
				std::find_if(output.cbegin(), output.cend(),
							 [componentRequire](const Meta::Require& req) {
								 return req.uid == componentRequire.uid;
							 });

			RequireEx componenRequireEx;
			componenRequireEx.uid = componentRequire.uid;
			componenRequireEx.suggests = componentRequire.suggests;
			componenRequireEx.equalsVersion = componentRequire.equalsVersion;
			componenRequireEx.indexOfFirstDependee = componentNum;

			if (found != output.cend()) {
				// found... process it further
				auto result = composeRequirement(componenRequireEx, *found);
				if (result.ok) {
					output.erase(componenRequireEx);
					output.insert(result.outcome);
				} else {
					qCritical()
						<< "Conflicting requirements:" << componentRequire.uid
						<< "versions:" << componentRequire.equalsVersion << ";"
						<< (*found).equalsVersion;
				}
				succeeded &= result.ok;
			} else {
				// not found, accumulate
				output.insert(componenRequireEx);
			}
		}
		componentNum++;
	}
	return succeeded;
}

/// Get list of uids that can be trivially removed because nothing is depending
/// on them anymore (and they are installed as deps)
static void getTrivialRemovals(const ComponentContainer& components,
							   const RequireExSet& reqs, QStringList& toRemove)
{
	for (const auto& component : components) {
		if (!component->m_dependencyOnly)
			continue;
		if (!component->m_cachedVolatile)
			continue;
		RequireEx reqNeedle;
		reqNeedle.uid = component->m_uid;
		const auto iter = reqs.find(reqNeedle);
		if (iter == reqs.cend()) {
			toRemove.append(component->m_uid);
		}
	}
}

/**
 * handles:
 * - trivial addition (there is an unmet requirement and it can be trivially met
 * by adding something)
 * - trivial version conflict of dependencies == explicit version required and
 * installed is different
 *
 * toAdd - set of requirements than mean adding a new component
 * toChange - set of requirements that mean changing version of an existing
 * component
 */
static bool getTrivialComponentChanges(const ComponentIndex& index,
									   const RequireExSet& input,
									   RequireExSet& toAdd,
									   RequireExSet& toChange)
{
	enum class Decision {
		Undetermined,
		Met,
		Missing,
		VersionNotSame,
		LockedVersionNotSame
	} decision = Decision::Undetermined;

	QString reqStr;
	bool succeeded = true;
	// list the composed requirements and say if they are met or unmet
	for (auto& req : input) {
		do {
			if (req.equalsVersion.isEmpty()) {
				reqStr = QString("Req: %1").arg(req.uid);
				if (index.contains(req.uid)) {
					decision = Decision::Met;
				} else {
					toAdd.insert(req);
					decision = Decision::Missing;
				}
				break;
			} else {
				reqStr =
					QString("Req: %1 == %2").arg(req.uid, req.equalsVersion);
				const auto& compIter = index.find(req.uid);
				if (compIter == index.cend()) {
					toAdd.insert(req);
					decision = Decision::Missing;
					break;
				}
				auto& comp = (*compIter);
				if (comp->getVersion() != req.equalsVersion) {
					if (comp->isCustom()) {
						decision = Decision::LockedVersionNotSame;
					} else {
						if (comp->m_dependencyOnly) {
							decision = Decision::VersionNotSame;
						} else {
							decision = Decision::LockedVersionNotSame;
						}
					}
					break;
				}
				decision = Decision::Met;
			}
		} while (false);
		switch (decision) {
			case Decision::Undetermined:
				qCritical() << "No decision for" << reqStr;
				succeeded = false;
				break;
			case Decision::Met:
				qDebug() << reqStr << "Is met.";
				break;
			case Decision::Missing:
				qDebug() << reqStr << "Is missing and should be added at"
						 << req.indexOfFirstDependee;
				toAdd.insert(req);
				break;
			case Decision::VersionNotSame:
				qDebug()
					<< reqStr
					<< "already has different version that can be changed.";
				toChange.insert(req);
				break;
			case Decision::LockedVersionNotSame:
				qDebug()
					<< reqStr
					<< "already has different version that cannot be changed.";
				succeeded = false;
				break;
		}
	}
	return succeeded;
}

// FIXME, TODO: decouple dependency resolution from loading
// FIXME: This works directly with the PackProfile internals. It shouldn't! It
// needs richer data types than PackProfile uses.
// FIXME: throw all this away and use a graph
void ComponentUpdateTask::resolveDependencies(bool checkOnly)
{
	qDebug() << "Resolving dependencies";
	/*
	 * this is a naive dependency resolving algorithm. all it does is check for
	 * following conditions and react in simple ways:
	 * 1. There are conflicting dependencies on the same uid with different
	 * exact version numbers
	 *    -> hard error
	 * 2. A dependency has non-matching exact version number
	 *    -> hard error
	 * 3. A dependency is entirely missing and needs to be injected before the
	 * dependee(s)
	 *    -> requirements are injected
	 *
	 * NOTE: this is a placeholder and should eventually be replaced with
	 * something 'serious'
	 */
	auto& components = d->m_list->d->components;
	auto& componentIndex = d->m_list->d->componentIndex;

	RequireExSet allRequires;
	QStringList toRemove;
	do {
		allRequires.clear();
		toRemove.clear();
		if (!gatherRequirementsFromComponents(components, allRequires)) {
			emitFailed(tr("Conflicting requirements detected during dependency "
						  "checking!"));
			return;
		}
		getTrivialRemovals(components, allRequires, toRemove);
		if (!toRemove.isEmpty()) {
			qDebug() << "Removing obsolete components...";
			for (auto& remove : toRemove) {
				qDebug() << "Removing" << remove;
				d->m_list->remove(remove);
			}
		}
	} while (!toRemove.isEmpty());
	RequireExSet toAdd;
	RequireExSet toChange;
	bool succeeded = getTrivialComponentChanges(componentIndex, allRequires,
												toAdd, toChange);
	if (!succeeded) {
		emitFailed(tr("Instance has conflicting dependencies."));
		return;
	}
	if (checkOnly) {
		if (toAdd.size() || toChange.size()) {
			emitFailed(tr("Instance has unresolved dependencies while "
						  "loading/checking for launch."));
		} else {
			emitSucceeded();
		}
		return;
	}

	bool recursionNeeded = false;
	if (toAdd.size()) {
		// add stuff...
		for (auto& add : toAdd) {
			ComponentPtr component = new Component(d->m_list, add.uid);
			if (!add.equalsVersion.isEmpty()) {
				// exact version
				qDebug() << "Adding" << add.uid << "version"
						 << add.equalsVersion << "at position"
						 << add.indexOfFirstDependee;
				component->m_version = add.equalsVersion;
			} else {
				// version needs to be decided
				qDebug() << "Adding" << add.uid << "at position"
						 << add.indexOfFirstDependee;
				// ############################################################################################################
				// HACK HACK HACK HACK FIXME: this is a placeholder for deciding
				// what version to use. For now, it is hardcoded.
				if (!add.suggests.isEmpty()) {
					component->m_version = add.suggests;
				} else {
					if (add.uid == "org.lwjgl") {
						component->m_version = "2.9.1";
					} else if (add.uid == "org.lwjgl3") {
						component->m_version = "3.1.2";
					} else if (add.uid == "net.fabricmc.intermediary") {
						auto minecraft = std::find_if(
							components.begin(), components.end(),
							[](ComponentPtr& cmp) {
								return cmp->getID() == "net.minecraft";
							});
						if (minecraft != components.end()) {
							component->m_version = (*minecraft)->getVersion();
						}
					}
				}
				// HACK HACK HACK HACK FIXME: this is a placeholder for deciding
				// what version to use. For now, it is hardcoded.
				// ############################################################################################################
			}
			component->m_dependencyOnly = true;
			// FIXME: this should not work directly with the component list
			d->m_list->insertComponent(add.indexOfFirstDependee, component);
			componentIndex[add.uid] = component;
		}
		recursionNeeded = true;
	}
	if (toChange.size()) {
		// change a version of something that exists
		for (auto& change : toChange) {
			// FIXME: this should not work directly with the component list
			qDebug() << "Setting version of " << change.uid << "to"
					 << change.equalsVersion;
			auto component = componentIndex[change.uid];
			component->setVersion(change.equalsVersion);
		}
		recursionNeeded = true;
	}

	if (recursionNeeded) {
		loadComponents();
	} else {
		emitSucceeded();
	}
}

void ComponentUpdateTask::remoteLoadSucceeded(size_t taskIndex)
{
	auto& taskSlot = d->remoteLoadStatusList[taskIndex];
	if (taskSlot.finished) {
		qWarning() << "Got multiple results from remote load task" << taskIndex;
		return;
	}
	qDebug() << "Remote task" << taskIndex << "succeeded";
	taskSlot.succeeded = false;
	taskSlot.finished = true;
	d->remoteTasksInProgress--;
	// update the cached data of the component from the downloaded version file.
	if (taskSlot.type == RemoteLoadStatus::Type::Version) {
		auto component = d->m_list->getComponent(taskSlot.PackProfileIndex);
		component->m_loaded = true;
		component->updateCachedData();
	}
	checkIfAllFinished();
}

void ComponentUpdateTask::remoteLoadFailed(size_t taskIndex, const QString& msg)
{
	auto& taskSlot = d->remoteLoadStatusList[taskIndex];
	if (taskSlot.finished) {
		qWarning() << "Got multiple results from remote load task" << taskIndex;
		return;
	}
	qDebug() << "Remote task" << taskIndex << "failed: " << msg;
	d->remoteLoadSuccessful = false;
	taskSlot.succeeded = false;
	taskSlot.finished = true;
	taskSlot.error = msg;
	d->remoteTasksInProgress--;
	checkIfAllFinished();
}

void ComponentUpdateTask::checkIfAllFinished()
{
	if (d->remoteTasksInProgress) {
		// not yet...
		return;
	}
	if (d->remoteLoadSuccessful) {
		// nothing bad happened... clear the temp load status and proceed with
		// looking at dependencies
		d->remoteLoadStatusList.clear();
		resolveDependencies(d->mode == Mode::Launch);
	} else {
		// remote load failed... report error and bail
		QStringList allErrorsList;
		for (auto& item : d->remoteLoadStatusList) {
			if (!item.succeeded) {
				allErrorsList.append(item.error);
			}
		}
		auto allErrors = allErrorsList.join("\n");
		emitFailed(tr("Component metadata update task failed while downloading "
					  "from remote server:\n%1")
					   .arg(allErrors));
		d->remoteLoadStatusList.clear();
	}
}
