/*
 * 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 <QDirIterator>
#include <QFileInfo>
#include <QSettings>

#include "src/datovka_shared/identifiers/account_id.h"
#include "src/datovka_shared/log/log.h"
#include "src/global.h"
#include "src/gui/dlg_restore_internal.h"
#include "src/io/filesystem.h"
#include "src/io/message_db_set.h"
#include "src/settings/accounts.h"

RestoreWorker::RestoreWorker(QObject *parent)
    : QObject(parent),
    m_descr(Q_NULLPTR),
    m_breakRestoreLoop(false)
{
}

void RestoreWorker::setDescr(const RestorationDescr *descr)
{
	m_descr = descr;
}

/*!
 * @brief Check whether all source files are readable.
 *
 * @param[in] fromDir Path to directory.
 * @param[in] fromFiles List of file names.
 * @return True if all files exist and all are readable.
 */
static
bool allReadableFiles(const QString &fromDir, const QList<QString> &fromFiles)
{
	const QFileInfo fromDirInfo(fromDir);
	if ((!fromDirInfo.isDir()) || (!fromDirInfo.isReadable())) {
		logErrorNL("Path '%s' is not a readable directory",
		    fromDir.toUtf8().constData());
		return false;
	}

	bool allReadable = !fromFiles.isEmpty(); /* At least one file must be present. */
	for (const QString &file : fromFiles) {
		const QString filePath(fromDir + QStringLiteral("/") + file);
		const QFileInfo fromFileInfo(filePath);
		if ((!fromFileInfo.isFile()) || (!fromFileInfo.isReadable())) {
			logErrorNL("Path '%s' is not a readable file.",
			    filePath.toUtf8().constData());
			allReadable = false;
		}
	}
	return allReadable;
}

/*!
 * @brief Check whether all files and subdirectories are readable.
 *
 * @param[in] fromDir Path to directory.
 * @param[in] fromSubdirs List of subdirectory names.
 * @return True if all directories and contained files are readable.
 */
static
bool allReadableDirectories(const QString &fromDir, const QList<QString> &fromSubdirs)
{
	const QFileInfo fromDirInfo(fromDir);
	if ((!fromDirInfo.isDir()) || (!fromDirInfo.isReadable())) {
		logErrorNL("Path '%s' is not a readable directory",
		    fromDir.toUtf8().constData());
		return false;
	}

	bool allReadable = true; /* No subdirectories may be present. */
	for (const QString &subdir : fromSubdirs) {
		const QString dirPath(fromDir + QStringLiteral("/") + subdir);
		const QFileInfo fromFileInfo(dirPath);
		if ((!fromFileInfo.isDir()) || (!fromFileInfo.isReadable())) {
			logErrorNL("Path '%s' is not a readable directory.",
			    dirPath.toUtf8().constData());
			allReadable = false;
		} else {
			QDirIterator dirIt(dirPath, QStringList(),
			    QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot,
			    QDirIterator::Subdirectories);
			while (dirIt.hasNext()) {
				dirIt.next();

				const QString path = dirIt.filePath();
				const QFileInfo fromFileInfo(path);
				if (!fromFileInfo.isReadable()) {
					logErrorNL("Path '%s' is not readable.",
					    path.toUtf8().constData());
					allReadable = false;
				}
			}
		}
	}
	return allReadable;
}

/*!
 * @brief Check whether we can write into the directory and whether all content
 *     can be deleted.
 *
 * @param[in] from Path to directory.
 * @return True if any possible content can be deleted or overwritten.
 */
static
bool writeableAndDeletableDirectory(const QString &fromDir)
{
	const QFileInfo fromDirInfo(fromDir);
	if ((!fromDirInfo.isDir()) || (!fromDirInfo.isWritable())) {
		logErrorNL("Path '%s' is not a writeable directory.",
		    fromDir.toUtf8().constData());
		return false;
	}

	bool allWriteable = true;
	QDirIterator dirIt(fromDir, QStringList(),
	    QDir::AllDirs | QDir::Files | QDir::NoDotAndDotDot,
	    QDirIterator::Subdirectories);
	while (dirIt.hasNext()) {
		dirIt.next();

		const QString path = dirIt.filePath();
		const QFileInfo fromFileInfo(path);
		if (!fromFileInfo.isWritable()) {
			logErrorNL("Path '%s' is not writeable.",
			    path.toUtf8().constData());
			allWriteable = false;
		}
	}
	return allWriteable;
}

/*!
 * @brief Check whether we can write into the directory and whether conflicting
 *     files can be deleted.
 *
 * @param[in] toDir Path to directory.
 * @param[in] toUname Target database username.
 * @param[in] testEnv False for production environment accounts.
 * @return True if any possible files can be deleted or overwritten.
 */
static
bool writeableAndDeletable(const QString &toDir, const QString &toUname,
    bool testEnv)
{
	const QFileInfo toDirInfo(toDir);
	if ((!toDirInfo.isDir()) || (!toDirInfo.isWritable())) {
		logErrorNL("Path '%s' is not a writeable directory.",
		    toDir.toUtf8().constData());
		return false;
	}

	bool allExistingWriteable = true;
	for (const QString &file :
	         MessageDbSet::fileNamesAvailable(toDir, toUname,
	             MessageDbSet::DO_UNKNOWN, testEnv)) {
		const QString filePath(toDir + QStringLiteral("/") + file);
		const QFileInfo toFileInfo(filePath);
		if ((!toFileInfo.isFile()) || (!toFileInfo.isWritable())) {
			logErrorNL("Path '%s' is not a writeable file.",
			    filePath.toUtf8().constData());
			allExistingWriteable = false;
		}
	}
	for (const QString &dir :
	        MessageDbSet::assocFileDirNamesAvailable(toDir, toUname,
	             MessageDbSet::DO_UNKNOWN, testEnv)) {
		const QString dirPath(toDir + QStringLiteral("/") + dir);
		if (!writeableAndDeletableDirectory(dirPath)) {
			logErrorNL("Path '%s' is not a writeable directory.",
			    dirPath.toUtf8().constData());
			allExistingWriteable = false;
		}
	}
	return allExistingWriteable;
}

/*!
 * @brief Copy message database files.
 *
 * @param[in] msgDb Restoration operation descriptor.
 * @return True on success.
 */
static
bool replaceMsgDbs(const RestoreWorker::RestorationDescr::MsgDb &msgDb)
{
	if (Q_UNLIKELY(msgDb.srcDir.isEmpty() || msgDb.srcUname.isEmpty() ||
	        msgDb.srcFiles.isEmpty() || msgDb.tgtDir.isEmpty() || msgDb.tgtUname.isEmpty())) {
		Q_ASSERT(0);
		return false;
	}

	/* Permission checks. */
	if (!allReadableFiles(msgDb.srcDir, msgDb.srcFiles)) {
		return false;
	}
	if (!allReadableDirectories(msgDb.srcDir, msgDb.srcAssocDirs)) {
		return false;
	}
	if (!writeableAndDeletable(msgDb.tgtDir, msgDb.tgtUname, msgDb.testEnv)) {
		return false;
	}

	/* Erase target if it exists. */
	for (const QString &file :
	     MessageDbSet::fileNamesAvailable(msgDb.tgtDir, msgDb.tgtUname,
	         MessageDbSet::DO_UNKNOWN, msgDb.testEnv)) {
		const QString filePath(msgDb.tgtDir + QStringLiteral("/") + file);
		Q_ASSERT(QFileInfo(filePath).isFile());
		if (!QFile::remove(filePath)) {
			logErrorNL("Cannot delete file '%s'.",
			    filePath.toUtf8().constData());
			return false;
		}
	}
	for (const QString &dir :
	     MessageDbSet::assocFileDirNamesAvailable(msgDb.tgtDir, msgDb.tgtUname,
	         MessageDbSet::DO_UNKNOWN, msgDb.testEnv)) {
		const QString dirPath(msgDb.tgtDir + QStringLiteral("/") + dir);
		Q_ASSERT(QFileInfo(dirPath).isDir());
		if (!QDir(dirPath).removeRecursively()) {
			logErrorNL("Cannot delete directory '%s'.",
			    dirPath.toUtf8().constData());
			return false;
		}
	}

	/* Copy data. */
	bool allCopied = true;
	for (const QString &fromFile : msgDb.srcFiles) {
		QString toFile(fromFile);
		toFile.replace(msgDb.srcUname, msgDb.tgtUname);
		const QString from(msgDb.srcDir + QStringLiteral("/") + fromFile);
		const QString to(msgDb.tgtDir + QStringLiteral("/") + toFile);
		if (QFile::copy(from, to)) {
			logInfoNL("Copied source '%s' to target '%s'.",
			    from.toUtf8().constData(), to.toUtf8().constData());
		} else {
			logErrorNL(
			    "Could not copy source '%s' to target '%s'.",
			    from.toUtf8().constData(), to.toUtf8().constData());
			allCopied = false;
		}
	}
	for (const QString &fromDir : msgDb.srcAssocDirs) {
		QString toDir = fromDir;
		toDir.replace(msgDb.srcUname, msgDb.tgtUname);
		const QString from = msgDb.srcDir + QStringLiteral("/") + fromDir;
		QString to = msgDb.tgtDir;
		if (copyDirRecursively(from, to, true)) {
			to = msgDb.tgtDir + QStringLiteral("/") + toDir;
			QDir(msgDb.tgtDir).rename(fromDir, toDir);
			logInfoNL("Copied source '%s' to target '%s'.",
			    from.toUtf8().constData(), to.toUtf8().constData());
		} else {
			to = msgDb.tgtDir + QStringLiteral("/") + toDir;
			logErrorNL(
			    "Could not copy source '%s' to target '%s'.",
			    from.toUtf8().constData(), to.toUtf8().constData());
			allCopied = false;
		}
	}

	return allCopied;
}

/*!
 * @brief Add a new account into configuration file.
 *
 * @param[in] settingsPath Configuration file path.
 * @param[in] msgDb Restoration operation descriptor.
 * @return True on success.
 */
static
bool addNewCredentials(const QString &settingsPath,
    const RestoreWorker::RestorationDescr::MsgDb &msgDb)
{
	if (Q_UNLIKELY(settingsPath.isEmpty() || msgDb.tgtAcntName.isEmpty() ||
	        msgDb.tgtUname.isEmpty())) {
		Q_ASSERT(0);
		return false;
	}

	QSettings settings(settingsPath, QSettings::IniFormat);
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
	/*
	 * Qt-6 does not provide a replacement of QSettings::setIniCodec().
	 * The documentation claims that QSettings assumes the INI file
	 * is UTF-8 encoded.
	 * TODO - It would be great to make really sure the INI is UTF-8 encoded
	 * instead of relying on the developers and documentation.
	 */
#else /* < Qt-6.0 */
	settings.setIniCodec("UTF-8");
#endif /* >= Qt-6.0 */
	AccountsMap::addCredentialsTorso(settings, msgDb.tgtAcntName,
	    AcntId(msgDb.tgtUname, msgDb.testEnv));
	settings.sync();
	confFileRemovePwdQuotes(settingsPath);

	return true;
}

/*!
 * @brief Copy file content.
 *
 * @note First ensure that source can be read and target can be overwritten
 *     if it exists.
 *
 * @param[in] from Source file path.
 * @param[in] to Target file path.
 * @return True in success.
 */
static
bool copyFile(const QString &from, const QString &to)
{
	if (Q_UNLIKELY(from.isEmpty() || to.isEmpty())) {
		Q_ASSERT(0);
		return false;
	}

	/* Permission checks. */
	{
		const QFileInfo fromInfo(from);
		if ((!fromInfo.isFile()) || (!fromInfo.isReadable())) {
			logErrorNL("Path '%s' is not a readable file.",
			    from.toUtf8().constData());
			return false;
		}

		const QFileInfo toInfo(to);
		if (toInfo.exists() && (!toInfo.isFile())) {
			logErrorNL("Path '%s' exists and it is not a file.",
			    to.toUtf8().constData());
			return false;
		} else if (toInfo.exists() && (!toInfo.isWritable())) {
			logErrorNL("File '%s' cannot be overwritten.",
			    to.toUtf8().constData());
			return false;
		}
	}

	/* Erase target if it exists. */
	{
		const QFileInfo toInfo(to);
		if (toInfo.exists()) {
			if (!QFile::remove(to)) {
				logErrorNL("Cannot delete file '%s'.",
				    to.toUtf8().constData());
				return false;
			}
		}
	}

	bool ret = QFile::copy(from, to);
	if (ret) {
		logInfoNL("Copied source '%s' to target '%s'.",
		    from.toUtf8().constData(), to.toUtf8().constData());
	} else {
		logErrorNL(
		    "Could not copy source '%s' to target '%s'. Target has probably already been deleted.",
		    from.toUtf8().constData(), to.toUtf8().constData());
	}
	return ret;
}

void RestoreWorker::process(void)
{
	bool interrupt = false;
	bool fail = false;

	if (Q_UNLIKELY(m_descr == Q_NULLPTR)) {
		Q_ASSERT(0);
		emit started(0, 1000);
		emit progress(1000);
		emit succeeded();
		emit finished();
		return;
	}

#define PRGR_MAX 1000 /* Maximal progress value. */
#define MSG_DB_PRGR_MAX 900 /* Message back-up progress. */
	int val = 0;
	int msgIncr = 0;
	if (m_descr->msgDbs.size() > 0) {
		msgIncr = MSG_DB_PRGR_MAX / m_descr->msgDbs.size();
	}

	emit started(0, PRGR_MAX);
	emit progress(val);
#undef PRGR_MAX
#undef MSG_DB_PRGR_MAX

	QStringList interActList, failActList;

	for (const RestorationDescr::MsgDb &msgDb : m_descr->msgDbs) {
		if (m_breakRestoreLoop) {
			interrupt = true;
		}
		const QString dbName(tr("databases for data box '%1'").arg(msgDb.tgtAcntName));
		emit working(dbName);
		if (!interrupt) {
			if ((GlobInstcs::msgDbsPtr == Q_NULLPTR) &&
			    replaceMsgDbs(msgDb)) {
			} else {
				fail = true;
				failActList.append(dbName);
			}
			if (msgDb.createNew) {
				/* Also create new setting entries. */
				if (addNewCredentials(m_descr->saveConfPath, msgDb)) {
				} else {
					fail = true;
					failActList.append(dbName);
				}
			}
		} else {
			interActList.append(dbName);
		}
		val += msgIncr;
		emit progress(val);
	}
	val = 900;
	emit progress(val);

	if (!m_descr->tagDbFile.isEmpty()) {
		if (m_breakRestoreLoop) {
			interrupt = true;
		}
		const QString dbName(tr("tag database"));
		emit working(dbName);
		if (!interrupt) {
			if ((GlobInstcs::tagContPtr == Q_NULLPTR) &&
			    copyFile(m_descr->backupDir + QStringLiteral("/") + m_descr->tagDbFile,
			        m_descr->runningTagDbFilePath)) {
			} else {
				fail = true;
				failActList.append(dbName);
			}
		} else {
			interActList.append(dbName);
		}
	}
	val = 950;
	emit progress(val);

	if (!m_descr->accountDbFile.isEmpty()) {
		if (m_breakRestoreLoop) {
			interrupt = true;
		}
		const QString dbName(tr("data-box database"));
		emit working(dbName);
		if (!interrupt) {
			if ((GlobInstcs::accntDbPtr == Q_NULLPTR) &&
			    copyFile(m_descr->backupDir + QStringLiteral("/") + m_descr->accountDbFile,
			        m_descr->runningAccountDbFilePath)) {
			} else {
				fail = true;
				failActList.append(dbName);
			}
		} else {
			interActList.append(dbName);
		}
	}
	val = 1000;
	emit progress(val);

	if (interrupt || fail) {
		QString msg;
		if (fail) {
			msg = tr("These data failed to be restored:") +
			    QStringLiteral("\n") +
			    failActList.join(QStringLiteral("\n"));
		}
		if (interrupt) {
			if (fail) {
				msg += QStringLiteral("\n\n");
			}
			msg = tr("Restoration of these data were skipped:") +
			    QStringLiteral("\n") +
			    interActList.join(QStringLiteral("\n"));
		}
		emit warning(tr("Restoration incomplete"), msg);
		emit failed();
	} else {
		emit succeeded();
	}
	emit finished();
}

void RestoreWorker::setBreakRestoreLoop(void)
{
	m_breakRestoreLoop = true;
}
