/* 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/>.
 *
 *  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 "ModFolderModel.h"
#include <FileSystem.h>
#include <QCryptographicHash>
#include <QDateTime>
#include <QFile>
#include <QFileSystemWatcher>
#include <QMimeData>
#include <QString>
#include <QThreadPool>
#include <QUrl>
#include <QUuid>
#include <QDebug>
#include "ModFolderLoadTask.h"
#include <algorithm>
#include "LocalModParseTask.h"

namespace
{
	QString sha1OfFile(const QString& path)
	{
		QFile f(path);
		if (!f.open(QIODevice::ReadOnly)) {
			return {};
		}
		QCryptographicHash hash(QCryptographicHash::Sha1);
		if (!hash.addData(&f)) {
			return {};
		}
		return QString::fromLatin1(hash.result().toHex());
	}
} // namespace

ModFolderModel::ModFolderModel(const QString& dir)
	: QAbstractListModel(), m_dir(dir)
{
	FS::ensureFolderPathExists(m_dir.absolutePath());
	m_dir.setFilter(QDir::Readable | QDir::NoDotAndDotDot | QDir::Files |
					QDir::Dirs);
	m_dir.setSorting(QDir::Name | QDir::IgnoreCase | QDir::LocaleAware);
	m_watcher = new QFileSystemWatcher(this);
	connect(m_watcher, &QFileSystemWatcher::directoryChanged, this,
			&ModFolderModel::directoryChanged);

	// Persistent metadata index — sibling `.index/` directory. Loaded
	// eagerly so install/dep tasks see the previous install state right
	// away.
	m_metadata = std::make_shared<ModMetadataIndex>(m_dir);
	m_metadata->load();
}

void ModFolderModel::startWatching()
{
	if (is_watching)
		return;

	update();

	is_watching = m_watcher->addPath(m_dir.absolutePath());
	if (is_watching) {
		qDebug() << "Started watching " << m_dir.absolutePath();
	} else {
		qDebug() << "Failed to start watching " << m_dir.absolutePath();
	}
}

void ModFolderModel::stopWatching()
{
	if (!is_watching)
		return;

	is_watching = !m_watcher->removePath(m_dir.absolutePath());
	if (!is_watching) {
		qDebug() << "Stopped watching " << m_dir.absolutePath();
	} else {
		qDebug() << "Failed to stop watching " << m_dir.absolutePath();
	}
}

bool ModFolderModel::update()
{
	if (!isValid()) {
		return false;
	}
	if (m_update) {
		scheduled_update = true;
		return true;
	}

	auto task = new ModFolderLoadTask(m_dir);
	m_update = task->result();
	QThreadPool* threadPool = QThreadPool::globalInstance();
	connect(task, &ModFolderLoadTask::succeeded, this,
			&ModFolderModel::finishUpdate);
	threadPool->start(task);
	return true;
}

void ModFolderModel::finishUpdate()
{
	auto keys1 = modsIndex.keys();
	QSet<QString> currentSet(keys1.begin(), keys1.end());
	auto& newMods = m_update->mods;
	auto keys2 = newMods.keys();
	QSet<QString> newSet(keys2.begin(), keys2.end());

	// see if the kept mods changed in some way
	{
		QSet<QString> kept = currentSet;
		kept.intersect(newSet);
		for (auto& keptMod : kept) {
			auto& newMod = newMods[keptMod];
			auto row = modsIndex[keptMod];
			auto& currentMod = mods[row];
			if (newMod.dateTimeChanged() == currentMod.dateTimeChanged()) {
				// no significant change, ignore...
				continue;
			}
			auto& oldMod = mods[row];
			if (oldMod.isResolving()) {
				activeTickets.remove(oldMod.resolutionTicket());
			}
			oldMod = newMod;
			resolveMod(mods[row]);
			emit dataChanged(index(row, 0),
							 index(row, columnCount(QModelIndex()) - 1));
		}
	}

	// remove mods no longer present
	{
		QSet<QString> removed = currentSet;
		QList<int> removedRows;
		removed.subtract(newSet);
		for (auto& removedMod : removed) {
			removedRows.append(modsIndex[removedMod]);
		}
		std::sort(removedRows.begin(), removedRows.end(), std::greater<int>());
		for (auto iter = removedRows.begin(); iter != removedRows.end();
			 iter++) {
			int removedIndex = *iter;
			beginRemoveRows(QModelIndex(), removedIndex, removedIndex);
			auto removedIter = mods.begin() + removedIndex;
			if (removedIter->isResolving()) {
				activeTickets.remove(removedIter->resolutionTicket());
			}
			mods.erase(removedIter);
			endRemoveRows();
		}
	}

	// add new mods to the end
	{
		QSet<QString> added = newSet;
		added.subtract(currentSet);
		beginInsertRows(QModelIndex(), mods.size(),
						mods.size() + added.size() - 1);
		for (auto& addedMod : added) {
			mods.append(newMods[addedMod]);
			resolveMod(mods.last());
		}
		endInsertRows();
	}

	// update index
	{
		modsIndex.clear();
		int idx = 0;
		for (auto& mod : mods) {
			modsIndex[mod.mmc_id()] = idx;
			idx++;
		}
	}

	m_update.reset();

	emit updateFinished();

	if (scheduled_update) {
		scheduled_update = false;
		update();
	}
}

void ModFolderModel::resolveMod(Mod& m)
{
	if (!m.shouldResolve()) {
		return;
	}

	auto task =
		new LocalModParseTask(nextResolutionTicket, m.type(), m.filename());
	auto result = task->result();
	result->id = m.mmc_id();
	activeTickets.insert(nextResolutionTicket, result);
	m.setResolving(true, nextResolutionTicket);
	nextResolutionTicket++;
	QThreadPool* threadPool = QThreadPool::globalInstance();
	connect(task, &LocalModParseTask::finished, this,
			&ModFolderModel::finishModParse);
	threadPool->start(task);
}

void ModFolderModel::finishModParse(int token)
{
	auto iter = activeTickets.find(token);
	if (iter == activeTickets.end()) {
		return;
	}
	auto result = *iter;
	activeTickets.remove(token);
	int row = modsIndex[result->id];
	auto& mod = mods[row];
	mod.finishResolvingWithDetails(result->details);
	emit dataChanged(index(row), index(row, columnCount(QModelIndex()) - 1));
}

void ModFolderModel::disableInteraction(bool disabled)
{
	if (interaction_disabled == disabled) {
		return;
	}
	interaction_disabled = disabled;
	if (size()) {
		emit dataChanged(index(0), index(size() - 1));
	}
}

void ModFolderModel::directoryChanged(QString path)
{
	update();
}

bool ModFolderModel::isValid()
{
	return m_dir.exists() && m_dir.isReadable();
}

// FIXME: this does not take disabled mod (with extra .disable extension) into
// account...
bool ModFolderModel::installMod(const QString& filename)
{
	if (interaction_disabled) {
		return false;
	}

	// NOTE: fix for GH-1178: remove trailing slash to avoid issues with using
	// the empty result of QFileInfo::fileName
	auto originalPath = FS::NormalizePath(filename);
	QFileInfo fileinfo(originalPath);

	if (!fileinfo.exists() || !fileinfo.isReadable()) {
		qWarning() << "Caught attempt to install non-existing file or "
					  "file-like object:"
				   << originalPath;
		return false;
	}
	qDebug() << "installing: " << fileinfo.absoluteFilePath();

	Mod installedMod(fileinfo);
	if (!installedMod.valid()) {
		qDebug() << originalPath << "is not a valid mod. Ignoring it.";
		return false;
	}

	auto type = installedMod.type();
	if (type == Mod::MOD_UNKNOWN) {
		qDebug() << "Cannot recognize mod type of" << originalPath
				 << ", ignoring it.";
		return false;
	}

	auto newpath =
		FS::NormalizePath(FS::PathCombine(m_dir.path(), fileinfo.fileName()));
	if (originalPath == newpath) {
		qDebug() << "Overwriting the mod (" << originalPath
				 << ") with itself makes no sense...";
		return false;
	}

	if (type == Mod::MOD_SINGLEFILE || type == Mod::MOD_ZIPFILE ||
		type == Mod::MOD_LITEMOD) {
		const QString disabledPath = newpath + QStringLiteral(".disabled");
		const QString existing =
			QFile::exists(newpath)
				? newpath
				: (QFile::exists(disabledPath) ? disabledPath : QString());

		if (!existing.isEmpty()) {
			if (!QFile::remove(existing)) {
				// FIXME: report error in a user-visible way
				qWarning() << "Copy from" << originalPath << "to" << newpath
						   << "has failed: cannot remove existing" << existing;
				return false;
			}
			if (m_metadata) {
				// Drop the old sidecar; we are replacing the file.
				m_metadata->remove(QFileInfo(existing).fileName());
			}
			qDebug() << existing << "has been deleted (replacing).";
		}
		if (!QFile::copy(fileinfo.filePath(), newpath)) {
			qWarning() << "Copy from" << originalPath << "to" << newpath
					   << "has failed.";
			// FIXME: report error in a user-visible way
			return false;
		}
		FS::updateTimestamp(newpath);

		// Record a "local" provenance sidecar so duplicate detection and
		// the conflict analyzer can reason about manually-added files.
		if (m_metadata) {
			ModMetadataIndex::Entry e;
			e.fileName = QFileInfo(newpath).fileName();
			e.platform = QStringLiteral("local");
			e.name = installedMod.name();
			e.sha1 = sha1OfFile(newpath);
			e.fileSize = QFileInfo(newpath).size();
			e.isDependency = false;
			e.installedAt = QDateTime::currentDateTimeUtc();
			m_metadata->put(e);
		}
		installedMod.repath(QFileInfo(newpath));
		update();
		return true;
	} else if (type == Mod::MOD_FOLDER) {
		QString from = fileinfo.filePath();
		if (QFile::exists(newpath)) {
			qDebug() << "Ignoring folder " << from << ", it would merge with "
					 << newpath;
			return false;
		}

		if (!FS::copy(from, newpath)()) {
			qWarning() << "Copy of folder from" << originalPath << "to"
					   << newpath << "has (potentially partially) failed.";
			return false;
		}
		installedMod.repath(QFileInfo(newpath));
		update();
		return true;
	}
	return false;
}

bool ModFolderModel::setModStatus(const QModelIndexList& indexes,
								  ModStatusAction enable)
{
	if (interaction_disabled) {
		return false;
	}

	if (indexes.isEmpty())
		return true;

	for (auto index : indexes) {
		if (index.column() != 0) {
			continue;
		}
		setModStatus(index.row(), enable);
	}
	return true;
}

bool ModFolderModel::deleteMods(const QModelIndexList& indexes)
{
	if (interaction_disabled) {
		return false;
	}

	if (indexes.isEmpty())
		return true;

	for (auto i : indexes) {
		Mod& m = mods[i.row()];
		const QString fileName = m.filename().fileName();
		if (m.destroy()) {
			if (m_metadata) {
				m_metadata->remove(fileName);
			}
		}
	}
	return true;
}

int ModFolderModel::columnCount(const QModelIndex& parent) const
{
	return NUM_COLUMNS;
}

QVariant ModFolderModel::data(const QModelIndex& index, int role) const
{
	if (!index.isValid())
		return QVariant();

	int row = index.row();
	int column = index.column();

	if (row < 0 || row >= mods.size())
		return QVariant();

	switch (role) {
		case Qt::DisplayRole:
			switch (column) {
				case NameColumn:
					return mods[row].name();
				case VersionColumn: {
					switch (mods[row].type()) {
						case Mod::MOD_FOLDER:
							return tr("Folder");
						case Mod::MOD_SINGLEFILE:
							return tr("File");
						default:
							break;
					}
					return mods[row].version();
				}
				case DateColumn:
					return mods[row].dateTimeChanged();

				default:
					return QVariant();
			}

		case Qt::ToolTipRole:
			return mods[row].mmc_id();

		case Qt::CheckStateRole:
			switch (column) {
				case ActiveColumn:
					return mods[row].enabled() ? Qt::Checked : Qt::Unchecked;
				default:
					return QVariant();
			}
		default:
			return QVariant();
	}
}

bool ModFolderModel::setData(const QModelIndex& index, const QVariant& value,
							 int role)
{
	if (index.row() < 0 || index.row() >= rowCount(index) || !index.isValid()) {
		return false;
	}

	if (role == Qt::CheckStateRole) {
		return setModStatus(index.row(), Toggle);
	}
	return false;
}

bool ModFolderModel::setModStatus(int row,
								  ModFolderModel::ModStatusAction action)
{
	if (row < 0 || row >= mods.size()) {
		return false;
	}

	auto& mod = mods[row];
	bool desiredStatus;
	switch (action) {
		case Enable:
			desiredStatus = true;
			break;
		case Disable:
			desiredStatus = false;
			break;
		case Toggle:
		default:
			desiredStatus = !mod.enabled();
			break;
	}

	if (desiredStatus == mod.enabled()) {
		return true;
	}

	// preserve the row, but change its ID
	auto oldId = mod.mmc_id();
	if (!mod.enable(!mod.enabled())) {
		return false;
	}
	auto newId = mod.mmc_id();
	if (modsIndex.contains(newId)) {
		// NOTE: this could handle a corner case, where we are overwriting a
		// file, because the same 'mod' exists both enabled and disabled But is
		// it necessary?
	}
	modsIndex.remove(oldId);
	modsIndex[newId] = row;
	// The on-disk file name changed (toggled `.disabled` suffix); migrate
	// the sidecar so it tracks the new name. canonicalFileName() collapses
	// both forms so this is a no-op on the metadata payload itself.
	if (m_metadata) {
		m_metadata->rename(oldId, newId);
	}
	emit dataChanged(index(row, 0), index(row, columnCount(QModelIndex()) - 1));
	return true;
}

QVariant ModFolderModel::headerData(int section, Qt::Orientation orientation,
									int role) const
{
	switch (role) {
		case Qt::DisplayRole:
			switch (section) {
				case ActiveColumn:
					return QString();
				case NameColumn:
					return tr("Name");
				case VersionColumn:
					return tr("Version");
				case DateColumn:
					return tr("Last changed");
				default:
					return QVariant();
			}

		case Qt::ToolTipRole:
			switch (section) {
				case ActiveColumn:
					return tr("Is the mod enabled?");
				case NameColumn:
					return tr("The name of the mod.");
				case VersionColumn:
					return tr("The version of the mod.");
				case DateColumn:
					return tr("The date and time this mod was last changed (or "
							  "added).");
				default:
					return QVariant();
			}
		default:
			return QVariant();
	}
	return QVariant();
}

Qt::ItemFlags ModFolderModel::flags(const QModelIndex& index) const
{
	Qt::ItemFlags defaultFlags = QAbstractListModel::flags(index);
	auto flags = defaultFlags;
	if (interaction_disabled) {
		flags &= ~Qt::ItemIsDropEnabled;
	} else {
		flags |= Qt::ItemIsDropEnabled;
		if (index.isValid()) {
			flags |= Qt::ItemIsUserCheckable;
		}
	}
	return flags;
}

Qt::DropActions ModFolderModel::supportedDropActions() const
{
	// copy from outside, move from within and other mod lists
	return Qt::CopyAction | Qt::MoveAction;
}

QStringList ModFolderModel::mimeTypes() const
{
	QStringList types;
	types << "text/uri-list";
	return types;
}

bool ModFolderModel::dropMimeData(const QMimeData* data, Qt::DropAction action,
								  int, int, const QModelIndex&)
{
	if (action == Qt::IgnoreAction) {
		return true;
	}

	// check if the action is supported
	if (!data || !(action & supportedDropActions())) {
		return false;
	}

	// files dropped from outside?
	if (data->hasUrls()) {
		auto urls = data->urls();
		for (auto url : urls) {
			// only local files may be dropped...
			if (!url.isLocalFile()) {
				continue;
			}
			// TODO: implement not only copy, but also move
			// FIXME: handle errors here
			installMod(url.toLocalFile());
		}
		return true;
	}
	return false;
}
