/* 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 <QList>
#include <QPair>
#include <QDebug>
#include <QTcpServer>
#include <QMap>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QNetworkAccessManager>
#include <QDateTime>
#include <QCryptographicHash>
#include <QTimer>
#include <QVariantMap>
#include <QUuid>
#include <QDataStream>

#include <QUrlQuery>

#include "katabasis/DeviceFlow.h"
#include "katabasis/PollServer.h"
#include "katabasis/Globals.h"

#include "JsonResponse.h"

namespace
{
	// ref: https://tools.ietf.org/html/rfc8628#section-3.2
	// Exception: Google sign-in uses "verification_url" instead of "*_uri" -
	// we'll accept both.
	bool hasMandatoryDeviceAuthParams(const QVariantMap& params)
	{
		if (!params.contains(Katabasis::OAUTH2_DEVICE_CODE))
			return false;

		if (!params.contains(Katabasis::OAUTH2_USER_CODE))
			return false;

		if (!(params.contains(Katabasis::OAUTH2_VERIFICATION_URI) ||
			  params.contains(Katabasis::OAUTH2_VERIFICATION_URL)))
			return false;

		if (!params.contains(Katabasis::OAUTH2_EXPIRES_IN))
			return false;

		return true;
	}

	QByteArray
	createQueryParameters(const QList<Katabasis::RequestParameter>& parameters)
	{
		QByteArray ret;
		bool first = true;
		for (auto& h : parameters) {
			if (first) {
				first = false;
			} else {
				ret.append("&");
			}
			ret.append(QUrl::toPercentEncoding(h.name) + "=" +
					   QUrl::toPercentEncoding(h.value));
		}
		return ret;
	}
} // namespace

namespace Katabasis
{

	DeviceFlow::DeviceFlow(Options& opts, Token& token, QObject* parent,
						   QNetworkAccessManager* manager)
		: QObject(parent), token_(token)
	{
		manager_ = manager ? manager : new QNetworkAccessManager(this);
		qRegisterMetaType<QNetworkReply::NetworkError>(
			"QNetworkReply::NetworkError");
		options_ = opts;
	}

	bool DeviceFlow::linked()
	{
		return token_.validity != Validity::None;
	}
	void DeviceFlow::setLinked(bool v)
	{
		qDebug() << "DeviceFlow::setLinked:" << (v ? "true" : "false");
		token_.validity = v ? Validity::Certain : Validity::None;
	}

	void DeviceFlow::updateActivity(Activity activity)
	{
		if (activity_ == activity) {
			return;
		}

		activity_ = activity;
		switch (activity) {
			case Katabasis::Activity::Idle:
			case Katabasis::Activity::LoggingIn:
			case Katabasis::Activity::LoggingOut:
			case Katabasis::Activity::Refreshing:
				// non-terminal states...
				break;
			case Katabasis::Activity::FailedSoft:
				// terminal state, tokens did not change
				break;
			case Katabasis::Activity::FailedHard:
			case Katabasis::Activity::FailedGone:
				// terminal state, tokens are invalid
				token_ = Token();
				break;
			case Katabasis::Activity::Succeeded:
				setLinked(true);
				break;
		}
		emit activityChanged(activity_);
	}

	QString DeviceFlow::token()
	{
		return token_.token;
	}
	void DeviceFlow::setToken(const QString& v)
	{
		token_.token = v;
	}

	QVariantMap DeviceFlow::extraTokens()
	{
		return token_.extra;
	}

	void DeviceFlow::setExtraTokens(QVariantMap extraTokens)
	{
		token_.extra = extraTokens;
	}

	void DeviceFlow::setPollServer(PollServer* server)
	{
		if (pollServer_)
			pollServer_->deleteLater();

		pollServer_ = server;
	}

	PollServer* DeviceFlow::pollServer() const
	{
		return pollServer_;
	}

	QVariantMap DeviceFlow::extraRequestParams()
	{
		return extraReqParams_;
	}

	void DeviceFlow::setExtraRequestParams(const QVariantMap& value)
	{
		extraReqParams_ = value;
	}

	QString DeviceFlow::grantType()
	{
		if (!grantType_.isEmpty())
			return grantType_;

		return OAUTH2_GRANT_TYPE_DEVICE;
	}

	void DeviceFlow::setGrantType(const QString& value)
	{
		grantType_ = value;
	}

	// First get the URL and token to display to the user
	void DeviceFlow::login()
	{
		qDebug() << "DeviceFlow::link";

		updateActivity(Activity::LoggingIn);
		setLinked(false);
		setToken("");
		setExtraTokens(QVariantMap());
		setRefreshToken(QString());
		setExpires(QDateTime());

		QList<RequestParameter> parameters;
		parameters.append(RequestParameter(OAUTH2_CLIENT_ID,
										   options_.clientIdentifier.toUtf8()));
		parameters.append(
			RequestParameter(OAUTH2_SCOPE, options_.scope.toUtf8()));
		QByteArray payload = createQueryParameters(parameters);

		QUrl url(options_.authorizationUrl);
		QNetworkRequest deviceRequest(url);
		deviceRequest.setHeader(QNetworkRequest::ContentTypeHeader,
								"application/x-www-form-urlencoded");
		QNetworkReply* tokenReply = manager_->post(deviceRequest, payload);

		connect(tokenReply, &QNetworkReply::finished, this,
				&DeviceFlow::onDeviceAuthReplyFinished, Qt::QueuedConnection);
	}

	// Then, once we get them, present them to the user
	void DeviceFlow::onDeviceAuthReplyFinished()
	{
		qDebug() << "DeviceFlow::onDeviceAuthReplyFinished";
		QNetworkReply* tokenReply = qobject_cast<QNetworkReply*>(sender());
		if (!tokenReply) {
			qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: reply is null";
			return;
		}
		if (tokenReply->error() == QNetworkReply::NoError) {
			QByteArray replyData = tokenReply->readAll();

			// Dump replyData
			// SENSITIVE DATA in RelWithDebInfo or Debug builds
			// qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: replyData\n";
			// qDebug() << QString( replyData );

			QVariantMap params = parseJsonResponse(replyData);

			// Dump tokens
			qDebug()
				<< "DeviceFlow::onDeviceAuthReplyFinished: Tokens returned:\n";
			foreach (QString key, params.keys()) {
				// SENSITIVE DATA in RelWithDebInfo or Debug builds, so it is
				// truncated first
				qDebug() << key << ": " << params.value(key).toString();
			}

			// Check for mandatory parameters
			if (hasMandatoryDeviceAuthParams(params)) {
				qDebug() << "DeviceFlow::onDeviceAuthReplyFinished: Device "
							"auth request response";

				const QString userCode =
					params.take(OAUTH2_USER_CODE).toString();
				QUrl uri = params.take(OAUTH2_VERIFICATION_URI).toUrl();
				if (uri.isEmpty())
					uri = params.take(OAUTH2_VERIFICATION_URL).toUrl();

				if (params.contains(OAUTH2_VERIFICATION_URI_COMPLETE))
					emit openBrowser(
						params.take(OAUTH2_VERIFICATION_URI_COMPLETE).toUrl());

				bool ok = false;
				int expiresIn = params[OAUTH2_EXPIRES_IN].toInt(&ok);
				if (!ok) {
					qWarning() << "DeviceFlow::startPollServer: No expired_in "
								  "parameter";
					updateActivity(Activity::FailedHard);
					return;
				}

				emit showVerificationUriAndCode(uri, userCode, expiresIn);

				startPollServer(params, expiresIn);
			} else {
				qWarning() << "DeviceFlow::onDeviceAuthReplyFinished: "
							  "Mandatory parameters missing from response";
				updateActivity(Activity::FailedHard);
			}
		}
		tokenReply->deleteLater();
	}

	// Spin up polling for the user completing the login flow out of band
	void DeviceFlow::startPollServer(const QVariantMap& params, int expiresIn)
	{
		qDebug()
			<< "DeviceFlow::startPollServer: device_ and user_code expires in"
			<< expiresIn << "seconds";

		QUrl url(options_.accessTokenUrl);
		QNetworkRequest authRequest(url);
		authRequest.setHeader(QNetworkRequest::ContentTypeHeader,
							  "application/x-www-form-urlencoded");

		const QString deviceCode = params[OAUTH2_DEVICE_CODE].toString();
		const QString grantType =
			grantType_.isEmpty() ? OAUTH2_GRANT_TYPE_DEVICE : grantType_;

		QList<RequestParameter> parameters;
		parameters.append(RequestParameter(OAUTH2_CLIENT_ID,
										   options_.clientIdentifier.toUtf8()));
		if (!options_.clientSecret.isEmpty()) {
			parameters.append(RequestParameter(OAUTH2_CLIENT_SECRET,
											   options_.clientSecret.toUtf8()));
		}
		parameters.append(RequestParameter(OAUTH2_CODE, deviceCode.toUtf8()));
		parameters.append(
			RequestParameter(OAUTH2_GRANT_TYPE, grantType.toUtf8()));
		QByteArray payload = createQueryParameters(parameters);

		PollServer* pollServer =
			new PollServer(manager_, authRequest, payload, expiresIn, this);
		if (params.contains(OAUTH2_INTERVAL)) {
			bool ok = false;
			int interval = params[OAUTH2_INTERVAL].toInt(&ok);
			if (ok) {
				pollServer->setInterval(interval);
			}
		}
		connect(pollServer, &PollServer::verificationReceived, this,
				&DeviceFlow::onVerificationReceived);
		connect(pollServer, &PollServer::serverClosed, this,
				&DeviceFlow::serverHasClosed);
		setPollServer(pollServer);
		pollServer->startPolling();
	}

	// Once the user completes the flow, update the internal state and report it
	// to observers
	void
	DeviceFlow::onVerificationReceived(const QMap<QString, QString> response)
	{
		qDebug()
			<< "DeviceFlow::onVerificationReceived: Emitting closeBrowser()";
		emit closeBrowser();

		if (response.contains("error")) {
			qWarning()
				<< "DeviceFlow::onVerificationReceived: Verification failed:"
				<< response;
			updateActivity(Activity::FailedHard);
			return;
		}

		// Check for mandatory tokens
		if (response.contains(OAUTH2_ACCESS_TOKEN)) {
			qDebug() << "DeviceFlow::onVerificationReceived: Access token "
						"returned for implicit or device flow";
			setToken(response.value(OAUTH2_ACCESS_TOKEN));
			if (response.contains(OAUTH2_EXPIRES_IN)) {
				bool ok = false;
				int expiresIn = response.value(OAUTH2_EXPIRES_IN).toInt(&ok);
				if (ok) {
					qDebug() << "DeviceFlow::onVerificationReceived: Token "
								"expires in"
							 << expiresIn << "seconds";
					setExpires(
						QDateTime::currentDateTimeUtc().addSecs(expiresIn));
				}
			}
			if (response.contains(OAUTH2_REFRESH_TOKEN)) {
				setRefreshToken(response.value(OAUTH2_REFRESH_TOKEN));
			}
			updateActivity(Activity::Succeeded);
		} else {
			qWarning() << "DeviceFlow::onVerificationReceived: Access token "
						  "missing from response for implicit or device flow";
			updateActivity(Activity::FailedHard);
		}
	}

	// Or if the flow fails or the polling times out, update the internal state
	// with error and report it to observers
	void DeviceFlow::serverHasClosed(bool paramsfound)
	{
		if (!paramsfound) {
			// server has probably timed out after receiving first response
			updateActivity(Activity::FailedHard);
		}
		// poll server is not re-used for later auth requests
		setPollServer(NULL);
	}

	void DeviceFlow::logout()
	{
		qDebug() << "DeviceFlow::unlink";
		updateActivity(Activity::LoggingOut);
		// FIXME: implement logout flows... if they exist
		token_ = Token();
		updateActivity(Activity::FailedHard);
	}

	QDateTime DeviceFlow::expires()
	{
		return token_.notAfter;
	}
	void DeviceFlow::setExpires(QDateTime v)
	{
		token_.notAfter = v;
	}

	QString DeviceFlow::refreshToken()
	{
		return token_.refresh_token;
	}

	void DeviceFlow::setRefreshToken(const QString& v)
	{
#ifndef NDEBUG
		qDebug() << "DeviceFlow::setRefreshToken" << v << "...";
#endif
		token_.refresh_token = v;
	}

	namespace
	{
		QByteArray buildRequestBody(const QMap<QString, QString>& parameters)
		{
			QByteArray body;
			bool first = true;
			foreach (QString key, parameters.keys()) {
				if (first) {
					first = false;
				} else {
					body.append("&");
				}
				QString value = parameters.value(key);
				body.append(QUrl::toPercentEncoding(key) +
							QString("=").toUtf8() +
							QUrl::toPercentEncoding(value));
			}
			return body;
		}
	} // namespace

	bool DeviceFlow::refresh()
	{
		qDebug() << "DeviceFlow::refresh: Token: ..."
				 << refreshToken().right(7);

		updateActivity(Activity::Refreshing);

		if (refreshToken().isEmpty()) {
			qWarning() << "DeviceFlow::refresh: No refresh token";
			onRefreshError(QNetworkReply::AuthenticationRequiredError, nullptr);
			return false;
		}
		if (options_.accessTokenUrl.isEmpty()) {
			qWarning() << "DeviceFlow::refresh: Refresh token URL not set";
			onRefreshError(QNetworkReply::AuthenticationRequiredError, nullptr);
			return false;
		}

		QNetworkRequest refreshRequest(options_.accessTokenUrl);
		refreshRequest.setHeader(QNetworkRequest::ContentTypeHeader,
								 MIME_TYPE_XFORM);
		QMap<QString, QString> parameters;
		parameters.insert(OAUTH2_CLIENT_ID, options_.clientIdentifier);
		if (!options_.clientSecret.isEmpty()) {
			parameters.insert(OAUTH2_CLIENT_SECRET, options_.clientSecret);
		}
		parameters.insert(OAUTH2_REFRESH_TOKEN, refreshToken());
		parameters.insert(OAUTH2_GRANT_TYPE, OAUTH2_REFRESH_TOKEN);

		QByteArray data = buildRequestBody(parameters);
		QNetworkReply* refreshReply = manager_->post(refreshRequest, data);
		timedReplies_.add(refreshReply);
		connect(refreshReply, &QNetworkReply::finished, this,
				&DeviceFlow::onRefreshFinished, Qt::QueuedConnection);
		return true;
	}

	void DeviceFlow::onRefreshFinished()
	{
		QNetworkReply* refreshReply = qobject_cast<QNetworkReply*>(sender());

		auto networkError = refreshReply->error();
		if (networkError == QNetworkReply::NoError) {
			QByteArray reply = refreshReply->readAll();
			QVariantMap tokens = parseJsonResponse(reply);
			setToken(tokens.value(OAUTH2_ACCESS_TOKEN).toString());
			setExpires(QDateTime::currentDateTimeUtc().addSecs(
				tokens.value(OAUTH2_EXPIRES_IN).toInt()));
			QString refreshToken =
				tokens.value(OAUTH2_REFRESH_TOKEN).toString();
			if (!refreshToken.isEmpty()) {
				setRefreshToken(refreshToken);
			} else {
				qDebug() << "No new refresh token. Keep the old one.";
			}
			timedReplies_.remove(refreshReply);
			refreshReply->deleteLater();
			updateActivity(Activity::Succeeded);
			qDebug() << "New token expires in" << expires() << "seconds";
		} else {
			// FIXME: differentiate the error more here
			onRefreshError(networkError, refreshReply);
		}
	}

	void DeviceFlow::onRefreshError(QNetworkReply::NetworkError error,
									QNetworkReply* refreshReply)
	{
		QString errorString = "No Reply";
		if (refreshReply) {
			timedReplies_.remove(refreshReply);
			errorString = refreshReply->errorString();
		}

		switch (error) {
			// used for invalid credentials and similar errors. Fall through.
			case QNetworkReply::AuthenticationRequiredError:
			case QNetworkReply::ContentAccessDenied:
			case QNetworkReply::ContentOperationNotPermittedError:
			case QNetworkReply::ProtocolInvalidOperationError:
				updateActivity(Activity::FailedHard);
				break;
			case QNetworkReply::ContentGoneError: {
				updateActivity(Activity::FailedGone);
				break;
			}
			case QNetworkReply::TimeoutError:
			case QNetworkReply::OperationCanceledError:
			case QNetworkReply::SslHandshakeFailedError:
			default:
				updateActivity(Activity::FailedSoft);
				return;
		}
		if (refreshReply) {
			refreshReply->deleteLater();
		}
		qDebug() << "DeviceFlow::onRefreshFinished: Error" << (int)error
				 << " - " << errorString;
	}

} // namespace Katabasis
