/*
 * Copyright (C) 2014-2024 CZ.NIC
 *
 * 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.
 *
 * 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 <http://www.gnu.org/licenses/>.
 *
 * In addition, as a special exception, the copyright holders give
 * permission to link the code of portions of this program with the
 * OpenSSL library under certain conditions as described in each
 * individual source file, and distribute linked combinations including
 * the two.
 */

#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDir>
#include <QFileInfo>
#include <QMutexLocker>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QRegularExpression>

#include "src/about.h"
#include "src/common.h"
#include "src/datovka_shared/compat_qt/misc.h"
#include "src/datovka_shared/log/log.h"
#include "src/io/filesystem.h"
#include "src/io/update_checker.h"
#include "src/settings/prefs_specific.h"
#include "src/settings/proxy.h"

UpdateChecker::UpdateChecker(Prefs &prefs, ProxiesSettings &proxies,
    QObject *parent)
    : QObject(parent),
    m_lock(),
    m_prefs(prefs),
    m_proxies(proxies),
    m_latestCheckAttemptTime(),
    m_latestSuccessfulCheckTime(),
    m_newestVersion(),
    m_csv(),
    m_downloadedPkgType(SysDetect::PKG_UNKNOWN),
    m_state(STATE_IDLE),
    m_versionFoundEmitted(false),
    m_packageDownloadAttempt(0),
    m_nam(new (::std::nothrow) QNetworkAccessManager(this)),
    m_verRep(Q_NULLPTR),
    m_cvsRep(Q_NULLPTR),
    m_sha256Rep(Q_NULLPTR),
    m_pkgRep(Q_NULLPTR)
{
	if (m_nam != Q_NULLPTR) {
		const QNetworkProxy proxy =
		    m_proxies.networkProxy(ProxiesSettings::HTTPS);

		/* If something detected or set up. */
		if (QNetworkProxy::DefaultProxy != proxy.type()) {
			m_nam->setProxy(proxy);

			logDebugLv0NL("Using proxy host='%s' port='%d' user='%s' password='%s'",
			    proxy.hostName().toUtf8().constData(),
			    proxy.port(),
			    proxy.user().isEmpty() ? "" :
			        proxy.user().toUtf8().constData(),
			    proxy.password().isEmpty() ? "" :
			        proxy.password().toUtf8().constData());

			logDebugLv0NL("%s", "Using proxy.");
		} else {
			logDebugLv0NL("%s", "Using no proxy.");
		}

		connect(m_nam, SIGNAL(finished(QNetworkReply *)),
		    this, SLOT(processNetworkReply(QNetworkReply *)));
	}
}

UpdateChecker::~UpdateChecker(void)
{
	if (m_nam != Q_NULLPTR) {
		m_nam->disconnect(SIGNAL(finished(QNetworkReply *)),
		    this, SLOT(processNetworkReply(QNetworkReply *)));
		delete m_nam;
	}
}

bool UpdateChecker::startVersionCheck(int cooldownSecs,
    enum SysDetect::PkgType preferredPkgType)
{
	QMutexLocker locker(&m_lock);

	const QDateTime currentDateTime = QDateTime::currentDateTime().toUTC();

	if ((cooldownSecs > 0) &&
	    (!m_newestVersion.isEmpty()) && (!m_csv.isEmpty()) &&
	    m_latestCheckAttemptTime.isValid() &&
	    (m_latestCheckAttemptTime.secsTo(currentDateTime) <= cooldownSecs)) {
		/* Don't perform checks if on cool down. */
		return false;
	}

	if (Q_UNLIKELY(m_nam == Q_NULLPTR)) {
		Q_ASSERT(0);
		return false;
	}

	m_latestCheckAttemptTime = currentDateTime;

	m_packageDownloadAttempt = 0;

	QUrl verUrl(DATOVKA_CHECK_NEW_VERSION_URL);
	QUrl csvUrl(DATOVKA_PACKAGE_LIST_FILE_URL);

	const enum SysDetect::PkgType detectedPkgType = SysDetect::detectPackageType();

	/*
	 * It is unsafe to start a version check when a previous one might be
	 * in progress.
	 */
	switch (m_state) {
	case STATE_IDLE:
	case STATE_HAVE_PACKAGE:
		m_downloadedPkgType = detectedPkgType;

		/* Allow preferred package type if allowed. */
		if ((SysDetect::PKG_UNKNOWN != preferredPkgType)
		    && allowPackageTypeChange(detectedPkgType, preferredPkgType)) {
			m_downloadedPkgType = preferredPkgType;
		}

		m_state = STATE_DOWNLOADING_VERSION;
		emit stateChanged(m_state);
		break;
	default:
		return false;
		break;
	}
	m_verRep = m_nam->get(QNetworkRequest(verUrl));
	m_cvsRep = m_nam->get(QNetworkRequest(csvUrl));

	return true;
}

/*!
 * @brief Obtain file name from URL.
 *
 * @param[in] urlStr URL string.
 * @return Non-empty file name if URL is valid.
 */
static
QString urlFileName(const QString &urlStr)
{
	if (Q_UNLIKELY(urlStr.isEmpty())) {
		return QString();
	}

	return QUrl(urlStr).fileName();
}

/*!
 * @brief Determine the URL of the SHA256SUMS file. The file is located in
 *     the same location as the downloaded file.
 *
 * @param[in] packageUrl URL of the downloaded installation package.
 * @return Non-mepty URL if original URL is valid.
 */
static
QString sha256sumsUrl(const QUrl &packageUrl)
{
	if (Q_UNLIKELY(!packageUrl.isValid())) {
		return QString();
	}

	return packageUrl.adjusted(QUrl::RemoveFilename).url() +
	    QStringLiteral("SHA256SUMS");
}

bool UpdateChecker::startPackageDownload(bool forceNewDownload)
{
	if (m_state != STATE_CAN_DOWNLOAD_PACKAGE) {
		/* Don't download anything as there is nothing to download. */
		return false;
	}

	if (Q_UNLIKELY(m_nam == Q_NULLPTR)) {
		Q_ASSERT(0);
		return false;
	}

	QUrl pkgUrl(packageUrl());
	if (Q_UNLIKELY(!pkgUrl.isValid())) {
		Q_ASSERT(0);
		return false;
	}
	QUrl sha256Url(sha256sumsUrl(pkgUrl));
	if (Q_UNLIKELY(!sha256Url.isValid())) {
		Q_ASSERT(0);
		return false;
	}

	{
		const QString pkgPath = downloadedPackagePath();
		if ((!forceNewDownload) && (!pkgPath.isEmpty())) {
			/* Already have adequate package.  */
			logInfoNL("Won't download '%s' as there already exists '%s'.",
			    pkgUrl.url().toUtf8().constData(),
			    pkgPath.toUtf8().constData());
			return false;
		}
	}

	if (m_state != STATE_DOWNLOADING_PACKAGE) {
		m_state = STATE_DOWNLOADING_PACKAGE;
		emit stateChanged(m_state);
	}
	m_sha256Rep = m_nam->get(QNetworkRequest(sha256Url));
	m_pkgRep = m_nam->get(QNetworkRequest(pkgUrl));

	if (m_pkgRep != Q_NULLPTR) {
		connect(m_pkgRep, SIGNAL(downloadProgress(qint64, qint64)),
		    this, SLOT(processPackageDownloadProgress(qint64, qint64)));
	}

	return true;
}

enum UpdateChecker::State UpdateChecker::state(void) const
{
	return m_state;
}

QString UpdateChecker::newestVersion(void) const
{
	return m_newestVersion;
}

bool UpdateChecker::newerVersionAvailable(void) const
{
	return (!m_newestVersion.isEmpty()) &&
	    (1 == compareVersionStrings(m_newestVersion,
	         QCoreApplication::applicationVersion()));
}

/*!
 * @brief Convert package type into CSV prefix string.
 *
 * @param[in] pkgType Package type identifier.
 * @return CSV prefix string. Null string if not supported in CSV.
 */
static
const QString &csvPrefix(const enum SysDetect::PkgType pkgType)
{
	static const QString nullStr;
	static const QString win32Portable("WIN32_PORTABLE");
	static const QString win32InstExe("WIN32_INST_EXE");
	static const QString win32InstMsi("WIN32_INST_MSI");
	static const QString win64Portable("WIN64_PORTABLE");
	static const QString win64InstExe("WIN64_INST_EXE");
	static const QString win64InstMsi("WIN64_INST_MSI");
	static const QString macos32Dmg("MACOS32_DMG");
	static const QString macos64Dmg("MACOS64_DMG");
	static const QString macosArm64Dmg("MACOS_ARM64_DMG");

	switch (pkgType) {
	case SysDetect::PKG_UNKNOWN:
		Q_ASSERT(0);
		return nullStr;
		break;
	case SysDetect::PKG_WIN32_PORTABLE:
		return win32Portable;
		break;
	case SysDetect::PKG_WIN32_INST_EXE:
		return win32InstExe;
		break;
	case SysDetect::PKG_WIN32_INST_MSI:
		return win32InstMsi;
		break;
	case SysDetect::PKG_WIN64_PORTABLE:
		return win64Portable;
		break;
	case SysDetect::PKG_WIN64_INST_EXE:
		return win64InstExe;
		break;
	case SysDetect::PKG_WIN64_INST_MSI:
		return win64InstMsi;
		break;
	case SysDetect::PKG_MACOS32_DMG:
		return macos32Dmg;
		break;
	case SysDetect::PKG_MACOS64_DMG:
		return macos64Dmg;
		break;
	case SysDetect::PKG_MACOS_ARM64_DMG:
		return macosArm64Dmg;
		break;
	default:
		Q_ASSERT(0);
		return nullStr;
		break;
	}
}

QString UpdateChecker::packageUrl(void) const
{
	if (Q_UNLIKELY(m_csv.isEmpty())) {
		return QString();
	}

	switch (m_downloadedPkgType) {
	case SysDetect::PKG_UNKNOWN:
		/* There is nothing to be downloaded. */
		logWarningNL("%s", "Could not detect package type.");
		return QString();
		break;
	case SysDetect::PKG_WIN32_PORTABLE:
	case SysDetect::PKG_WIN64_PORTABLE:
		if (!SysDetect::canInstallPortableVersion()) {
			logWarning("%s", "Cannot install portable package.");
			return QString();
		}
		break;
	default:
		break;
	}

	const QString &csvPrefStr = csvPrefix(m_downloadedPkgType);

	QStringList csvLines = m_csv.split('\n');
	for (const QString &line : csvLines) {
		const QStringList entries = line.split(',');
		if (entries.size() < 2) {
			continue;
		}
		const QString pkgKey = entries[0].trimmed();
		const QString pkgUrl = entries[1].trimmed();

		if (pkgKey == csvPrefStr) {
			logInfoNL("Found URL '%s' for '%s'.",
			    pkgUrl.toUtf8().constData(),
			    pkgKey.toUtf8().constData());
			return pkgUrl;
		}
	}

	logWarning("No URL found for '%s'.", csvPrefStr.toUtf8().constData());
	return QString();
}

/*!
 * @brief Check the SHA256SUM of the file.
 *
 * @param[in] filePath Path to the checked file.
 * @param[in] sha256sum SHA256SUM string.
 * @return True if file is readable and the supplied checksum is valid.
 */
static
bool checkFileSha256Sum(const QString &filePath, const QString &sha256sum)
{
	if (Q_UNLIKELY(filePath.isEmpty() || sha256sum.isEmpty())) {
		return false;
	}

	/* File should already exist. */
	QCryptographicHash hash(QCryptographicHash::Sha256);
	{
		QFile file(filePath);
		if (Q_UNLIKELY(!file.open(QIODevice::ReadOnly))) {
			return false;
		}
		hash.addData(&file);
		file.close();
	}
	return hash.result().toHex() == sha256sum.toLower();
}

QString UpdateChecker::downloadedPackagePath(
    enum SysDetect::PkgType *downloadedPkgType) const
{
	if (m_newestVersion.isEmpty()) {
		return QString();
	}

	{
		QString downloadedPkgVersion =
		    PrefsSpecific::downloadedPkgVersion(m_prefs);
		if (downloadedPkgVersion != m_newestVersion) {
			/* Downloaded package version doesn't match. */
			if (Q_NULLPTR != downloadedPkgType) {
				*downloadedPkgType = SysDetect::PKG_UNKNOWN;
			}
			return QString();
		}
	}

	QString downloadedPkgSha256 =
	    PrefsSpecific::downloadedPkgSha256Sum(m_prefs);
	if (downloadedPkgSha256.isEmpty()) {
		logWarningNL("%s",
		    "Cannot obtain downloaded package SHA256SUM.");
		if (Q_NULLPTR != downloadedPkgType) {
			*downloadedPkgType = SysDetect::PKG_UNKNOWN;
		}
		return QString();
	}

	QString downloadedPkgPath = PrefsSpecific::downloadedPkgPath(m_prefs);
	if (downloadedPkgPath.isEmpty()) {
		logErrorNL("%s", "Cannot obtain downloaded package file.");
		if (Q_NULLPTR != downloadedPkgType) {
			*downloadedPkgType = SysDetect::PKG_UNKNOWN;
		}
		return QString();
	}

	{
		const QFileInfo fileInfo(downloadedPkgPath);

		const QString downloadedFileName = fileInfo.fileName();
		const QString urlFileName = QUrl(packageUrl()).fileName();
		if (Q_UNLIKELY(downloadedFileName != urlFileName)) {
			logErrorNL(
			    "Download URL points to different file name '%s' than is the already downloaded one '%s'.",
			    urlFileName.toUtf8().constData(),
			    downloadedFileName.toUtf8().constData());
			if (Q_NULLPTR != downloadedPkgType) {
				*downloadedPkgType = SysDetect::PKG_UNKNOWN;
			}
			return QString();
		}

		if ((!fileInfo.exists()) || (!fileInfo.isFile()) ||
		    (!fileInfo.isReadable())) {
			logErrorNL("Cannot access file '%s'.",
			    downloadedPkgPath.toUtf8().constData());
			if (Q_NULLPTR != downloadedPkgType) {
				*downloadedPkgType = SysDetect::PKG_UNKNOWN;
			}
			return QString();
		}
	}

	if (Q_UNLIKELY(!checkFileSha256Sum(downloadedPkgPath,
	        downloadedPkgSha256))) {
		logErrorNL("File '%s' does not match the SHA256SUM '%s'.",
		    downloadedPkgPath.toUtf8().constData(),
		    downloadedPkgSha256.toUtf8().constData());
		if (Q_NULLPTR != downloadedPkgType) {
			*downloadedPkgType = SysDetect::PKG_UNKNOWN;
		}
		return QString();
	}

	logInfoNL(
	    "Found downloaded package '%s' with SHA256SUM '%s' corresponding to version '%s'.",
	    downloadedPkgPath.toUtf8().constData(),
	    downloadedPkgSha256.toUtf8().constData(),
	    m_newestVersion.toUtf8().constData());

	if (Q_NULLPTR != downloadedPkgType) {
		*downloadedPkgType = m_downloadedPkgType;
	}
	return downloadedPkgPath;
}

bool UpdateChecker::allowPackageTypeChange(enum SysDetect::PkgType fromPkgType,
    enum SysDetect::PkgType toPkgType)
{
	if ((SysDetect::PKG_WIN32_PORTABLE == fromPkgType) &&
	    (SysDetect::PKG_WIN64_PORTABLE == toPkgType)) {
		/* Allow update from 32-bit portable to 64-bit portable. */
		return true;
	}

	return false;
}

/*!
 * @brief Extract the version string from obtained network response data.
 *
 * @param[in] data Network response data.
 * @return Version string.
 */
static
QString extractVersionString(const QByteArray &data)
{
	QString str = QString::fromUtf8(data);
	str.remove(QRegularExpression("[\n\t\r]"));
	if ((str.length() > 0) && (str[0] == '_')) {
		str.remove(0, 1);
	}
	return str;
}

/*!
 * @brief Extract SHA256SUM from the SHA256SUMS file content.
 *
 * @param[in] data Data resembling the output of sha256sum command.
 *                 Each line has two entries: checksum and file name.
 * @param[in] fileName Name of the file that should be sought in the data.
 * @return SHA256SUm string if file name found.
 */
static
QString extractSha256Sum(const QByteArray &data, const QString &fileName)
{
	QStringList lines = QString::fromUtf8(data).split('\n');
	for (const QString &line : lines) {
		const QStringList entries = line.split(' ', _Qt_SkipEmptyParts);
		if (entries.size() < 2) {
			continue;
		}

		const QString sha256sum = entries[0].trimmed();
		const QString fName = entries[1].trimmed();

		if (fName == fileName) {
			logInfoNL("Found SHA256SUM '%s' for '%s'.",
			    sha256sum.toUtf8().constData(),
			    fName.toUtf8().constData());
			return sha256sum;
		}
	}

	logWarning("No SHA256SUM found for '%s'.",
	    fileName.toUtf8().constData());
	return QString();
}

/*!
 * @brief Write file into newly created temporary directory.
 *
 * @param[in] fileName File name.
 * @param[in] data File Data.
 * @return Non-empty path to created file on success.
 */
static
QString writePackageFile(const QString &fileName, const QByteArray &data)
{
	if (Q_UNLIKELY(fileName.isEmpty())) {
		return QString();
	}

	const QString dir = createTemporarySubdir("datovka_update");
	if (Q_UNLIKELY(dir.isEmpty())) {
		return QString();
	}

	const QString filePath = dir + QDir::separator() + fileName;
	enum WriteFileState state = writeFile(filePath, data, true);
	if (state == WF_SUCCESS) {
		return filePath;
	} else {
		return QString();
	}
}

void UpdateChecker::processNetworkReply(QNetworkReply *reply)
{
	if (Q_UNLIKELY(reply == Q_NULLPTR)) {
		return;
	}

	if (reply->error() == QNetworkReply::NoError) {
		if (reply == m_verRep) {
			m_newestVersion = extractVersionString(reply->readAll());
			m_verRep = Q_NULLPTR;

			m_latestSuccessfulCheckTime = QDateTime::currentDateTime().toUTC();
		} else if (reply == m_cvsRep) {
			m_csv = QString::fromUtf8(reply->readAll());
			m_cvsRep = Q_NULLPTR;

			m_latestSuccessfulCheckTime = QDateTime::currentDateTime().toUTC();
		} else if (reply == m_sha256Rep) {
			PrefsSpecific::setDownloadedPkgSha256Sum(m_prefs,
			    extractSha256Sum(reply->readAll(), urlFileName(packageUrl())));
			m_sha256Rep = Q_NULLPTR;
		} else if (reply == m_pkgRep) {
			const QString filePath = writePackageFile(
			    m_pkgRep->request().url().fileName(),
			    reply->readAll());
			if ((!m_newestVersion.isEmpty()) && (!filePath.isEmpty())) {
				PrefsSpecific::setDownloadedPkgVersion(
				    m_prefs, m_newestVersion);
				PrefsSpecific::setDownloadedPkgPath(
				    m_prefs, filePath);
			}
			m_pkgRep->disconnect(SIGNAL(downloadProgress(qint64, qint64)),
			    this, SLOT(processPackageDownloadProgress(qint64, qint64)));
			m_pkgRep = Q_NULLPTR;
		}
	} else {
		/* Clear data on any error. */
		if (reply == m_verRep) {
			m_newestVersion.clear();
			m_verRep = Q_NULLPTR;
		} else if (reply == m_cvsRep) {
			m_csv.clear();
			m_cvsRep = Q_NULLPTR;
		} else if (reply == m_sha256Rep) {
			PrefsSpecific::setDownloadedPkgSha256Sum(m_prefs, QString());
			m_sha256Rep = Q_NULLPTR;
		} else if (reply == m_pkgRep) {
			m_pkgRep->disconnect(SIGNAL(downloadProgress(qint64, qint64)),
			    this, SLOT(processPackageDownloadProgress(qint64, qint64)));
			m_pkgRep = Q_NULLPTR;
		}
		logErrorNL("Could not acquire content of '%s'.",
		    reply->url().url().toUtf8().constData());
	}

	if (m_newestVersion.isEmpty() && m_csv.isEmpty()) {
		m_latestSuccessfulCheckTime = QDateTime();
	}

	/* Determine new state. */
	{
		enum State newState = STATE_IDLE;
		switch (m_state) {
		case STATE_IDLE:
			Q_ASSERT(0);
			break;
		case STATE_DOWNLOADING_VERSION:
			if ((m_verRep == Q_NULLPTR) && (m_cvsRep == Q_NULLPTR)) {
				if (PrefsSpecific::checkNewVersions(m_prefs) &&
				    (!m_newestVersion.isEmpty()) && (!m_csv.isEmpty())) {
					newState = STATE_HAVE_VERSION;
				} else {
					/*
					 * Move to idle on failure or when
					 * version checks are disabled.
					 */
					newState = STATE_IDLE;
				}
			} else {
				/* Wait for rest. */
				newState = STATE_DOWNLOADING_VERSION;
			}
			break;
		case STATE_HAVE_VERSION:
			Q_ASSERT(0);
			break;
		case STATE_NEW_VERSION_AVAILABLE:
			Q_ASSERT(0);
			break;
		case STATE_CAN_DOWNLOAD_PACKAGE:
			Q_ASSERT(0);
			break;
		case STATE_DOWNLOADING_PACKAGE:
			if ((m_sha256Rep == Q_NULLPTR) && (m_pkgRep == Q_NULLPTR)) {
				if (!downloadedPackagePath().isEmpty()) {
					newState = STATE_HAVE_PACKAGE;
				} else {
					newState = STATE_HAVE_VERSION;
				}
			} else {
				/* Wait for rest. */
				newState = STATE_DOWNLOADING_PACKAGE;
			}
			break;
		default:
			Q_ASSERT(0);
			break;
		}

		if ((newState == STATE_HAVE_VERSION) && newerVersionAvailable()) {
			newState = STATE_NEW_VERSION_AVAILABLE;
		}

		if ((newState == STATE_NEW_VERSION_AVAILABLE) && (!m_csv.isEmpty())) {
			/* Downloaded version and CSV, newer version available. */
			if (!packageUrl().isEmpty()) {
				/* Package is available */
				newState = STATE_CAN_DOWNLOAD_PACKAGE;
			}
		}

		if (newState == STATE_CAN_DOWNLOAD_PACKAGE) {
			if (!downloadedPackagePath().isEmpty()) {
				newState = STATE_HAVE_PACKAGE;
			}
		}

		if (m_state != newState) {
			if ((newState == STATE_CAN_DOWNLOAD_PACKAGE) &&
			    PrefsSpecific::canAutoDownloadUpdate(m_prefs) &&
			    ((m_packageDownloadAttempt++) < PrefsSpecific::autoDownloadUpdateAttemptMax(m_prefs))) {
				/* Automatic package download. */
				m_state = newState;
				if (startPackageDownload()) {
					/* Don't emit new state as it has already been done. */
					if (!m_versionFoundEmitted) {
						m_versionFoundEmitted = true;
						emit newerVersionFound(m_newestVersion);
					}
				}
			} else {
				m_state = newState;
				emit stateChanged(m_state);
				switch (newState) {
				case STATE_NEW_VERSION_AVAILABLE:
				case STATE_CAN_DOWNLOAD_PACKAGE:
				case STATE_DOWNLOADING_PACKAGE:
				case STATE_HAVE_PACKAGE:
					if (!m_versionFoundEmitted) {
						m_versionFoundEmitted = true;
						emit newerVersionFound(m_newestVersion);
					}
				default:
					break;
				}
			}
		}
	}

	reply->deleteLater();
}

void UpdateChecker::processPackageDownloadProgress(qint64 bytesReceived,
    qint64 bytesTotal)
{
	emit packageDownloadProgress(bytesReceived, bytesTotal);
}
