/* 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 "AccountList.h"
#include "AccountData.h"
#include "AccountTask.h"

#include <QIODevice>
#include <QFile>
#include <QTextStream>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonParseError>
#include <QDir>
#include <QTimer>

#include <QDebug>

#include <FileSystem.h>
#include <QSaveFile>

#include <chrono>

enum AccountListVersion { MojangOnly = 2, MojangMSA = 3 };

AccountList::AccountList(QObject* parent) : QAbstractListModel(parent)
{
	m_refreshTimer = new QTimer(this);
	m_refreshTimer->setSingleShot(true);
	connect(m_refreshTimer, &QTimer::timeout, this, &AccountList::fillQueue);
	m_nextTimer = new QTimer(this);
	m_nextTimer->setSingleShot(true);
	connect(m_nextTimer, &QTimer::timeout, this, &AccountList::tryNext);
}

AccountList::~AccountList() noexcept {}

int AccountList::findAccountByProfileId(const QString& profileId) const
{
	for (int i = 0; i < count(); i++) {
		MinecraftAccountPtr account = at(i);
		if (account->profileId() == profileId) {
			return i;
		}
	}
	return -1;
}

int AccountList::findOfflineAccountByUsername(const QString& username) const
{
	if (username.isEmpty())
		return -1;
	const QString needle = username.trimmed().toLower();
	if (needle.isEmpty())
		return -1;

	for (int i = 0; i < count(); i++) {
		MinecraftAccountPtr account = at(i);
		if (!account || !account->isOffline())
			continue;
		/* profileName() returns the offlineUsername for offline accounts —
		 * see AccountData::profileName(). Compare case-insensitively. */
		if (account->profileName().trimmed().toLower() == needle)
			return i;
	}
	return -1;
}

MinecraftAccountPtr
AccountList::getAccountByProfileName(const QString& profileName) const
{
	for (int i = 0; i < count(); i++) {
		MinecraftAccountPtr account = at(i);
		if (account->profileName() == profileName) {
			return account;
		}
	}
	return nullptr;
}

const MinecraftAccountPtr AccountList::at(int i) const
{
	return MinecraftAccountPtr(m_accounts.at(i));
}

QStringList AccountList::profileNames() const
{
	QStringList out;
	for (auto& account : m_accounts) {
		auto profileName = account->profileName();
		if (profileName.isEmpty()) {
			continue;
		}
		out.append(profileName);
	}
	return out;
}

void AccountList::addAccount(const MinecraftAccountPtr account)
{
	// NOTE: Do not allow adding something that's already there
	if (m_accounts.contains(account)) {
		return;
	}

	/*
	 * Offline-account duplicate guard.
	 *
	 * Offline accounts have an empty profileId (no Mojang profile is
	 * ever fetched for them), so the existing profileId-based
	 * deduplication a few lines below silently lets a second offline
	 * account with the same username slip through. That used to
	 * surface as two identically-named "User" rows in the accounts
	 * list, both of which would refresh into AccountState::Offline and
	 * neither of which could be told apart in the picker.
	 *
	 * The right fix is to reject the addition at the source: if an
	 * offline account with this username already exists (case-
	 * insensitive — Minecraft is case-sensitive but the launcher
	 * profile picker is not, and users routinely conflate the two),
	 * silently drop the new one rather than replace the existing
	 * record. Replacing would change the account's internal id, which
	 * would orphan every instance setting that references it by id.
	 *
	 * UI code is expected to validate before calling addAccount(),
	 * but this guard makes the AccountList itself safe even when
	 * called from places that forgot to (config-file load, plugin
	 * APIs, future import paths, …).
	 */
	if (account && account->isOffline()) {
		const int existing =
			findOfflineAccountByUsername(account->profileName());
		if (existing != -1) {
			return;
		}
	}

	// hook up notifications for changes in the account
	connect(account.get(), &MinecraftAccount::changed, this,
			&AccountList::accountChanged);
	connect(account.get(), &MinecraftAccount::activityChanged, this,
			&AccountList::accountActivityChanged);

	// override/replace existing account with the same profileId
	auto profileId = account->profileId();
	if (profileId.size()) {
		auto existingAccount = findAccountByProfileId(profileId);
		if (existingAccount != -1) {
			MinecraftAccountPtr existingAccountPtr =
				m_accounts[existingAccount];
			m_accounts[existingAccount] = account;
			if (m_defaultAccount == existingAccountPtr) {
				m_defaultAccount = account;
			}
			// disconnect notifications for changes in the account being
			// replaced
			existingAccountPtr->disconnect(this);
			emit dataChanged(
				index(existingAccount),
				index(existingAccount, columnCount(QModelIndex()) - 1));
			onListChanged();
			return;
		}
	}

	// if we don't have this profileId yet, add the account to the end
	int row = m_accounts.count();
	beginInsertRows(QModelIndex(), row, row);
	m_accounts.append(account);
	endInsertRows();
	onListChanged();
}

void AccountList::removeAccount(QModelIndex index)
{
	int row = index.row();
	if (index.isValid() && row >= 0 && row < m_accounts.size()) {
		auto& account = m_accounts[row];
		if (account == m_defaultAccount) {
			m_defaultAccount = nullptr;
			onDefaultAccountChanged();
		}
		account->disconnect(this);

		beginRemoveRows(QModelIndex(), row, row);
		m_accounts.removeAt(index.row());
		endRemoveRows();
		onListChanged();
	}
}

MinecraftAccountPtr AccountList::defaultAccount() const
{
	return m_defaultAccount;
}

void AccountList::setDefaultAccount(MinecraftAccountPtr newAccount)
{
	if (!newAccount && m_defaultAccount) {
		int idx = 0;
		auto previousDefaultAccount = m_defaultAccount;
		m_defaultAccount = nullptr;
		for (MinecraftAccountPtr account : m_accounts) {
			if (account == previousDefaultAccount) {
				emit dataChanged(index(idx),
								 index(idx, columnCount(QModelIndex()) - 1));
			}
			idx++;
		}
		onDefaultAccountChanged();
	} else {
		auto currentDefaultAccount = m_defaultAccount;
		int currentDefaultAccountIdx = -1;
		auto newDefaultAccount = m_defaultAccount;
		int newDefaultAccountIdx = -1;
		int idx = 0;
		for (MinecraftAccountPtr account : m_accounts) {
			if (account == newAccount) {
				newDefaultAccount = account;
				newDefaultAccountIdx = idx;
			}
			if (currentDefaultAccount == account) {
				currentDefaultAccountIdx = idx;
			}
			idx++;
		}
		if (currentDefaultAccount != newDefaultAccount) {
			emit dataChanged(index(currentDefaultAccountIdx),
							 index(currentDefaultAccountIdx,
								   columnCount(QModelIndex()) - 1));
			emit dataChanged(
				index(newDefaultAccountIdx),
				index(newDefaultAccountIdx, columnCount(QModelIndex()) - 1));
			m_defaultAccount = newDefaultAccount;
			onDefaultAccountChanged();
		}
	}
}

void AccountList::accountChanged()
{
	// the list changed. there is no doubt.
	onListChanged();
}

void AccountList::accountActivityChanged(bool active)
{
	MinecraftAccount* account = qobject_cast<MinecraftAccount*>(sender());
	bool found = false;
	for (int i = 0; i < count(); i++) {
		if (at(i).get() == account) {
			emit dataChanged(index(i),
							 index(i, columnCount(QModelIndex()) - 1));
			found = true;
			break;
		}
	}
	if (found) {
		emit listActivityChanged();
		if (active) {
			beginActivity();
		} else {
			endActivity();
		}
	}
}

void AccountList::onListChanged()
{
	if (m_autosave)
		// TODO: Alert the user if this fails.
		saveList();

	emit listChanged();
}

void AccountList::onDefaultAccountChanged()
{
	if (m_autosave)
		saveList();

	emit defaultAccountChanged();
}

int AccountList::count() const
{
	return m_accounts.count();
}

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

	if (index.row() > count())
		return QVariant();

	MinecraftAccountPtr account = at(index.row());

	switch (role) {
		case Qt::DisplayRole:
			switch (index.column()) {
				case NameColumn:
					return account->accountDisplayString();

				case TypeColumn: {
					auto typeStr = account->typeString();
					typeStr[0] = typeStr[0].toUpper();
					return typeStr;
				}

				case StatusColumn: {
					switch (account->accountState()) {
						case AccountState::Unchecked: {
							return tr("Unchecked", "Account status");
						}
						case AccountState::Offline: {
							return tr("Offline", "Account status");
						}
						case AccountState::Online: {
							return tr("Online", "Account status");
						}
						case AccountState::Working: {
							return tr("Working", "Account status");
						}
						case AccountState::Errored: {
							return tr("Errored", "Account status");
						}
						case AccountState::Expired: {
							return tr("Expired", "Account status");
						}
						case AccountState::Gone: {
							return tr("Gone", "Account status");
						}
					}
				}

				case ProfileNameColumn: {
					return account->profileName();
				}

				default:
					return QVariant();
			}

		case Qt::ToolTipRole:
			return account->accountDisplayString();

		case PointerRole:
			return QVariant::fromValue(account);

		case Qt::CheckStateRole:
			switch (index.column()) {
				case NameColumn:
					return account == m_defaultAccount ? Qt::Checked
													   : Qt::Unchecked;
			}

		default:
			return QVariant();
	}
}

QVariant AccountList::headerData(int section, Qt::Orientation, int role) const
{
	switch (role) {
		case Qt::DisplayRole:
			switch (section) {
				case NameColumn:
					return tr("Account");
				case TypeColumn:
					return tr("Type");
				case StatusColumn:
					return tr("Status");
				case ProfileNameColumn:
					return tr("Profile");
				default:
					return QVariant();
			}

		case Qt::ToolTipRole:
			switch (section) {
				case NameColumn:
					return tr("User name of the account.");
				case TypeColumn:
					return tr("Type of the account.");
				case StatusColumn:
					return tr("Current status of the account.");
				case ProfileNameColumn:
					return tr("Name of the Minecraft profile associated with "
							  "the account.");
				default:
					return QVariant();
			}

		default:
			return QVariant();
	}
}

int AccountList::rowCount(const QModelIndex&) const
{
	// Return count
	return count();
}

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

Qt::ItemFlags AccountList::flags(const QModelIndex& index) const
{
	if (index.row() < 0 || index.row() >= rowCount(index) || !index.isValid()) {
		return Qt::NoItemFlags;
	}

	return Qt::ItemIsUserCheckable | Qt::ItemIsEnabled | Qt::ItemIsSelectable;
}

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

	if (role == Qt::CheckStateRole) {
		if (value == Qt::Checked) {
			MinecraftAccountPtr account = at(idx.row());
			setDefaultAccount(account);
		}
	}

	emit dataChanged(idx, index(idx.row(), columnCount(QModelIndex()) - 1));
	return true;
}

bool AccountList::loadList()
{
	if (m_listFilePath.isEmpty()) {
		qCritical() << "Can't load account list. No file path given and no "
					   "default set.";
		return false;
	}

	QFile file(m_listFilePath);

	// Try to open the file and fail if we can't.
	// TODO: We should probably report this error to the user.
	if (!file.open(QIODevice::ReadOnly)) {
		qCritical() << QString("Failed to read the account list file (%1).")
						   .arg(m_listFilePath)
						   .toUtf8();
		return false;
	}

	// Read the file and close it.
	QByteArray jsonData = file.readAll();
	file.close();

	QJsonParseError parseError;
	QJsonDocument jsonDoc = QJsonDocument::fromJson(jsonData, &parseError);

	// Fail if the JSON is invalid.
	if (parseError.error != QJsonParseError::NoError) {
		qCritical() << QString(
						   "Failed to parse account list file: %1 at offset %2")
						   .arg(parseError.errorString(),
								QString::number(parseError.offset))
						   .toUtf8();
		return false;
	}

	// Make sure the root is an object.
	if (!jsonDoc.isObject()) {
		qCritical() << "Invalid account list JSON: Root should be an array.";
		return false;
	}

	QJsonObject root = jsonDoc.object();

	// Make sure the format version matches.
	auto listVersion = root.value("formatVersion").toVariant().toInt();
	switch (listVersion) {
		case AccountListVersion::MojangMSA: {
			return loadV3(root);
		} break;
		default: {
			QString newName = "accounts-old.json";
			qWarning() << "Unknown format version when loading account list. "
						  "Existing one will be renamed to"
					   << newName;
			// Attempt to rename the old version.
			file.rename(newName);
			return false;
		}
	}
}

bool AccountList::loadV3(QJsonObject& root)
{
	beginResetModel();
	QJsonArray accounts = root.value("accounts").toArray();
	for (QJsonValue accountVal : accounts) {
		QJsonObject accountObj = accountVal.toObject();
		MinecraftAccountPtr account =
			MinecraftAccount::loadFromJsonV3(accountObj);
		if (account.get() != nullptr) {
			auto profileId = account->profileId();
			if (profileId.size()) {
				if (findAccountByProfileId(profileId) != -1) {
					continue;
				}
			} else if (account->isOffline()) {
				/* Offline accounts have no profileId, so the profileId
				 * dedupe above does nothing for them. Match by
				 * username instead — this rescues users whose
				 * accounts.json picked up duplicate offline entries
				 * before the addAccount() guard was added. The first
				 * one wins; later duplicates are silently dropped. */
				if (findOfflineAccountByUsername(account->profileName()) !=
					-1) {
					qWarning().noquote()
						<< "Dropping duplicate offline account on load:"
						<< account->profileName();
					continue;
				}
			}
			connect(account.get(), &MinecraftAccount::changed, this,
					&AccountList::accountChanged);
			connect(account.get(), &MinecraftAccount::activityChanged, this,
					&AccountList::accountActivityChanged);
			m_accounts.append(account);
			if (accountObj.value("active").toBool(false)) {
				m_defaultAccount = account;
			}
		} else {
			qWarning() << "Failed to load an account.";
		}
	}
	endResetModel();
	return true;
}

bool AccountList::saveList()
{
	if (m_listFilePath.isEmpty()) {
		qCritical() << "Can't save account list. No file path given and no "
					   "default set.";
		return false;
	}

	// make sure the parent folder exists
	if (!FS::ensureFilePathExists(m_listFilePath))
		return false;

	// make sure the file wasn't overwritten with a folder before (fixes a bug)
	QFileInfo finfo(m_listFilePath);
	if (finfo.isDir()) {
		QDir badDir(m_listFilePath);
		badDir.removeRecursively();
	}

	qDebug() << "Writing account list to" << m_listFilePath;

	qDebug() << "Building JSON data structure.";
	// Build the JSON document to write to the list file.
	QJsonObject root;

	root.insert("formatVersion", AccountListVersion::MojangMSA);

	// Build a list of accounts.
	qDebug() << "Building account array.";
	QJsonArray accounts;
	for (MinecraftAccountPtr account : m_accounts) {
		QJsonObject accountObj = account->saveToJson();
		if (m_defaultAccount == account) {
			accountObj["active"] = true;
		}
		accounts.append(accountObj);
	}

	// Insert the account list into the root object.
	root.insert("accounts", accounts);

	// Create a JSON document object to convert our JSON to bytes.
	QJsonDocument doc(root);

	// Now that we're done building the JSON object, we can write it to the
	// file.
	qDebug() << "Writing account list to file.";
	QSaveFile file(m_listFilePath);

	// Try to open the file and fail if we can't.
	// TODO: We should probably report this error to the user.
	if (!file.open(QIODevice::WriteOnly)) {
		qCritical() << QString("Failed to read the account list file (%1).")
						   .arg(m_listFilePath)
						   .toUtf8();
		return false;
	}

	// Write the JSON to the file.
	file.write(doc.toJson());
	file.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ReadUser |
						QFile::WriteUser);
	if (file.commit()) {
		qDebug() << "Saved account list to" << m_listFilePath;
		return true;
	} else {
		qDebug() << "Failed to save accounts to" << m_listFilePath;
		return false;
	}
}

void AccountList::setListFilePath(QString path, bool autosave)
{
	m_listFilePath = path;
	m_autosave = autosave;
}

bool AccountList::anyAccountIsValid()
{
	for (auto account : m_accounts) {
		if (account->ownsMinecraft()) {
			return true;
		}
	}
	return false;
}

void AccountList::fillQueue()
{

	if (m_defaultAccount && m_defaultAccount->shouldRefresh()) {
		auto idToRefresh = m_defaultAccount->internalId();
		m_refreshQueue.push_back(idToRefresh);
		qDebug() << "AccountList: Queued default account with internal ID "
				 << idToRefresh << " to refresh first";
	}

	for (int i = 0; i < count(); i++) {
		auto account = at(i);
		if (account == m_defaultAccount) {
			continue;
		}

		if (account->shouldRefresh()) {
			auto idToRefresh = account->internalId();
			queueRefresh(idToRefresh);
		}
	}
	tryNext();
}

void AccountList::requestRefresh(QString accountId)
{
	auto index = m_refreshQueue.indexOf(accountId);
	if (index != -1) {
		m_refreshQueue.removeAt(index);
	}
	m_refreshQueue.push_front(accountId);
	qDebug() << "AccountList: Pushed account with internal ID " << accountId
			 << " to the front of the queue";
	if (!isActive()) {
		tryNext();
	}
}

void AccountList::queueRefresh(QString accountId)
{
	if (m_refreshQueue.indexOf(accountId) != -1) {
		return;
	}
	m_refreshQueue.push_back(accountId);
	qDebug() << "AccountList: Queued account with internal ID " << accountId
			 << " to refresh";
}

void AccountList::tryNext()
{
	while (m_refreshQueue.length()) {
		auto accountId = m_refreshQueue.front();
		m_refreshQueue.pop_front();
		for (int i = 0; i < count(); i++) {
			auto account = at(i);
			if (account->internalId() == accountId) {
				m_currentTask = account->refresh();
				if (m_currentTask) {
					connect(m_currentTask.get(), &AccountTask::succeeded, this,
							&AccountList::authSucceeded);
					connect(m_currentTask.get(), &AccountTask::failed, this,
							&AccountList::authFailed);
					m_currentTask->start();
					qDebug() << "RefreshSchedule: Processing account "
							 << account->accountDisplayString()
							 << " with internal ID " << accountId;
					return;
				}
			}
		}
		qDebug() << "RefreshSchedule: Account with with internal ID "
				 << accountId << " not found.";
	}
	// if we get here, no account needed refreshing. Schedule refresh in an
	// hour.
	m_refreshTimer->start(1000 * 3600);
}

void AccountList::authSucceeded()
{
	qDebug() << "RefreshSchedule: Background account refresh succeeded";
	m_currentTask.reset();
	m_nextTimer->start(1000 * 20);
}

void AccountList::authFailed(QString reason)
{
	qDebug() << "RefreshSchedule: Background account refresh failed: "
			 << reason;
	m_currentTask.reset();
	m_nextTimer->start(1000 * 20);
}

bool AccountList::isActive() const
{
	return m_activityCount != 0;
}

void AccountList::beginActivity()
{
	bool activating = m_activityCount == 0;
	m_activityCount++;
	if (activating) {
		emit activityChanged(true);
	}
}

void AccountList::endActivity()
{
	if (m_activityCount == 0) {
		qWarning() << m_name << " - Activity count would become below zero";
		return;
	}
	bool deactivating = m_activityCount == 1;
	m_activityCount--;
	if (deactivating) {
		emit activityChanged(false);
	}
}
