/*
 * 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 <QDir>
#include <QFile>
#include <QFileInfo>
#include <QRegularExpression>
#if (QT_VERSION >= QT_VERSION_CHECK(5, 4, 0))
#  include <QStorageInfo>
#else /* < Qt-5.4 */
#  warning "Compiling against version < Qt-5.4 which does not have QStorageInfo."
#endif /* >= Qt-5.4 */
#include <QString>
#include <QStringBuilder>
#include <quazip/quazip.h> /* bundled or system */
#include <quazip/quazipfile.h> /* bundled or system */

#include "src/datovka_shared/compat/compiler.h" /* macroStdMove */
#include "src/datovka_shared/identifiers/account_id.h"
#include "src/datovka_shared/log/log.h"
#include "src/io/load_mobile_zip_package.h"
#include "src/json/backup.h"

/* Macosx metadata directory name. */
#define MACOSX_METADATA_DIR_NAME "__MACOSX/"
#define APP_VARIANT "mobile"
#define DATETIME_QML_FORMAT "dd.MM.yyyy HH:mm:ss"
#define ZFO_DB_NAME "zfo.db"

/*!
 * @brief Compute file checksum.
 *
 * @param[in] filePath File path.
 * @param[in] algorithm Algorithm to use for the checksum.
 * @return Checksum, null checksum on failure.
 */
static
Json::Backup::Checksum computeFileChecksum(const QString &filePath,
    enum Json::Backup::Checksum::Algorithm
    algorith = Json::Backup::Checksum::ALG_SHA512)
{
	QFile f(filePath);
	if (f.open(QFile::ReadOnly)) {
		return Json::Backup::Checksum::computeChecksum(&f, algorith);
	}
	return Json::Backup::Checksum();
}

/*!
 * @brief Extract file from ZIP archive into target location.
 *
 * @param[in] quaZipFile QuaZIP  file taken from ZIP archive.
 * @param[in] backupBaseDir Backup base dir path in ZIP archive (can be null).
 * @param[in] subDir Source file subdir in ZIP archive (can be null).
 * @param[in] targetDir Target directory path where file will be stored.
 * @param[in,out] checksum Target file checksum.
 * @param[in,out] unZipPath Path to unziped file.
 * @return True if success.
 */
static
bool extractFileFromZip(QuaZipFile &quaZipFile, const QString &backupBaseDir,
    const QString &subDir, const QDir &targetDir,
    Json::Backup::Checksum &checksum, QString &unZipPath)
{
	QString outFilePathName = targetDir.absolutePath()
	    % QDir::separator() % quaZipFile.getActualFileName();
	/*
	 * If backupBaseDir is present in quaZipFile path,
	 * remove it from target file path.
	 */
	if (!backupBaseDir.isEmpty()) {
		outFilePathName.remove(backupBaseDir);
	}
	/*
	 * If account subdir is present in quaZipFile path,
	 * remove it from target file path.
	 */
	if (!subDir.isEmpty()) {
		outFilePathName.remove(QDir::separator() + subDir);
	}

	QDir dir(QFileInfo(outFilePathName).path());
	QFile outFile(outFilePathName);

	if (Q_UNLIKELY(!quaZipFile.open(QIODevice::ReadOnly))) {
		return false;
	}
	if (Q_UNLIKELY((!dir.exists()) && (!dir.mkpath(".")))) {
		goto fail;
	}
	if (Q_UNLIKELY(!outFile.open(QIODevice::WriteOnly))) {
		goto fail;
	}
	if (Q_UNLIKELY(outFile.write(quaZipFile.readAll())
	        != quaZipFile.size())) {
		outFile.close();
		goto fail;
	}
	outFile.close();
	if (Q_UNLIKELY(!quaZipFile.atEnd())) {
		goto fail;
	}
	quaZipFile.close();
	if (Q_UNLIKELY(quaZipFile.getZipError() != UNZ_OK)) {
		return false;
	}

	checksum = computeFileChecksum(outFilePathName);
	unZipPath = macroStdMove(outFilePathName);
	return true;
fail:
	quaZipFile.close();
	return false;
}

/*!
 * @brief Get dir path to the JSON file in the ZIP archive.
 *
 * @param[in] zip QuaZip archive.
 * @return Return dir path to the JSON file or null if root.
 */
static
QString getBackupBaseDir(QuaZip &zip)
{
	QuaZipFileInfo info;
	QString jsonFilePath;

	/* Find JSON file. */
	for (bool f = zip.goToFirstFile(); f; f = zip.goToNextFile()) {
		if (Q_UNLIKELY(!zip.getCurrentFileInfo(&info))) {
			return QString();;
		}
		QFileInfo fi(info.name);
		/* Skip root, dirs and macosx metadata in ZIP file. */
		if (fi.fileName().isEmpty() ||
		        (info.name.contains(QString(MACOSX_METADATA_DIR_NAME)))) {
			continue;
		}
		if (fi.suffix().toLower() == QStringLiteral("json")) {
			jsonFilePath = info.name;
		}
	}

	if (Q_UNLIKELY(jsonFilePath.isEmpty())) {
		return QString();
	}

	/* Return dir path to JSON or null if is in the root of ZIP archive. */
	QFileInfo jsonFi(jsonFilePath);
	if (jsonFi.path() == ".") {
		return QString();
	}

	return jsonFi.path() + QDir::separator();
}

/*!
 * @brief Test if file is excluded from restore.
 *
 * @param[in] fi File info.
 * @return True if success.
 */
static
bool isExcludedFileFromRestore(const QFileInfo &fi)
{
	return (fi.fileName().isEmpty()
	    || fi.filePath().contains(QString(MACOSX_METADATA_DIR_NAME))
	    || (fi.suffix().toLower() == QStringLiteral("json")));
}

/*!
 * @brief Detect if a database file is in the new format.
 *
 * @param[in] fileName Database file name.
 * @return True if database file is in the new format.
 */
static
bool isNewDatabase(const QString &fileName)
{
	const QRegularExpression re("^[a-zA-Z0-9]+___[0-1].db$");
	return re.match(fileName).hasMatch();
}

/*!
 * @brief Detect if a file is valid for unzip of selected account.
 *
 * @param[in] acntId Account id.
 * @param[in] fileInZip File path in ZIP.
 * @return True is valid.
 */
static
bool isValidForSelectedAccount(const AcntId &acntId, const QString &fileInZip)
{
	QFileInfo fi(fileInZip);
	QString suffix(fi.suffix().toLower());
	if (fileInZip.contains(acntId.username())) {
		if (suffix == QStringLiteral("db")) {
			if (isNewDatabase(fi.fileName())) {
				return true;
			}
		} else if (suffix == QStringLiteral("zfo") ||
		        fi.fileName() == QStringLiteral(ZFO_DB_NAME)) {
			return true;
		}
	}
	return false;
}

bool LoadMobileZipPackage::loadBackupJsonFromZip(const QString &zipPath,
    QStringList &msgDbPathList, QString &zfoDbPath, QString &infoText)
{
	debugFuncCall();

	if (Q_UNLIKELY(zipPath.isEmpty())) {
		return false;
	}

	infoText = tr("Unknown backup type.");

	/* Check if selected file is ZIP archive. */
	QFileInfo fi(zipPath);
	if (Q_UNLIKELY(fi.suffix().toLower() != QStringLiteral("zip"))) {
		logErrorNL("ZIP file is missing or invalid: '%s'.",
		    zipPath.toUtf8().constData());
		infoText = tr("The selected file isn't a valid ZIP archive containing a backup or transfer data.");
		return false;
	}
	QuaZip zip(zipPath);
	if (Q_UNLIKELY(!zip.open(QuaZip::mdUnzip))) {
		logErrorNL("ZIP file is missing or invalid: '%s'.",
		    zipPath.toUtf8().constData());
		infoText = tr("The selected file isn't a valid ZIP archive containing a backup or transfer data.");
		return false;
	}

	QuaZipFile quaZipFile(&zip);
	QuaZipFileInfo info;
	QString jsonFileName;
	QString suffix;
	qint64 uncompressedSizeBytes = 0;
	qint64 zipEntries = 0;
	Json::Backup loadedBackup;
	int newDbCount = 0;

	/* Find all message databases and read JSON backup file. */
	for (bool f = zip.goToFirstFile(); f; f = zip.goToNextFile()) {
		if (Q_UNLIKELY(!zip.getCurrentFileInfo(&info))) {
			logErrorNL("The selected file isn't a valid ZIP archive containing a backup: '%s'",
			    zipPath.toUtf8().constData());
			infoText = tr("The selected file isn't a valid ZIP archive containing a backup.");
			return false;
		}

		QFileInfo fi(info.name);

		/* Skip root, dirs and macosx metadata in ZIP file. */
		if (fi.fileName().isEmpty() ||
		        (info.name.contains(QString(MACOSX_METADATA_DIR_NAME)))) {
			continue;
		}

		suffix = fi.suffix().toLower();
		if (suffix == QStringLiteral("json")) {
			quaZipFile.open(QIODevice::ReadOnly | QIODevice::Text);
			loadedBackup = Json::Backup::fromJsonData(quaZipFile.readAll());
			jsonFileName = info.name;
			quaZipFile.close();
		} else if (suffix == QStringLiteral("zfo")) {
			uncompressedSizeBytes += info.uncompressedSize;
		} else if (suffix == QStringLiteral("db")) {
			if (isNewDatabase(fi.fileName())) {
				msgDbPathList.append(fi.filePath());
				uncompressedSizeBytes += info.uncompressedSize;
				++newDbCount;
			}
		} else if (fi.fileName() == QStringLiteral(ZFO_DB_NAME)) {
			zfoDbPath = fi.filePath();
			uncompressedSizeBytes += info.uncompressedSize;
		}
		++zipEntries;
	}
	zip.close();

	if (Q_UNLIKELY(!loadedBackup.isValidMobile())) {
		logErrorNL("JSON file '%s' in ZIP archive is missing or invalid.",
		    zipPath.toUtf8().constData());
		infoText = tr("The selected ZIP archive doesn't contain a valid backup info file.");
		return false;
	}

	if ((loadedBackup.appInfo().appName().toLower() != QStringLiteral(APP_NAME)) ||
	    (loadedBackup.appInfo().appVariant() != QStringLiteral(APP_VARIANT))) {
		logErrorNL("The backup JSON file '%s' doesn't contain a valid backup information.",
		    jsonFileName.toUtf8().constData());
		infoText = tr("The backup file '%1' doesn't contain a valid backup information.").arg(jsonFileName);
		return false;
	}

	QString backupTypePrefix;
	switch (loadedBackup.type()) {
	case Json::Backup::BUT_BACKUP:
		backupTypePrefix = tr("Backup");
		break;
	case Json::Backup::BUT_TRANSFER:
		backupTypePrefix = tr("Transfer");
		break;
	default:
		logErrorNL("%s", "Unknown backup type.");
		return false;
		break;
	}

	infoText = tr(
	    "%1 ZIP archive was taken at %2 and contains %n file(s). Found message databases for conversion: %3.",
	    "", zipEntries)
	    .arg(backupTypePrefix)
	    .arg(loadedBackup.dateTime().toString(DATETIME_QML_FORMAT))
	    .arg(msgDbPathList.count());

	bool enoughSpace = true;
	{
		qint64 bytesAvailable = -1;
#if (QT_VERSION >= QT_VERSION_CHECK(5, 4, 0))
		{
			bytesAvailable = QStorageInfo(fi.absolutePath()).bytesAvailable();
		}
#endif /* >= Qt-5.4 */
		if (bytesAvailable < 0) {
			infoText.append(" ");
			infoText.append(tr(
			    "The amount of free space on the target volume couldn't be determined."));
			/* Expect enough space to be present. */
		} else if (uncompressedSizeBytes >= bytesAvailable) {
			infoText.append(" ");
			infoText.append(tr(
			    "Not enough free space to unzip data on the target volume."));
			enoughSpace = false;
		} else if ((uncompressedSizeBytes * 5) >= bytesAvailable) {
			infoText.append(" ");
			infoText.append(tr(
			    "It is likely that the target volume has not enough free space left for databases conversion."));
			/* Let the user deal with the situation. */
		}
	}

	return enoughSpace && (!msgDbPathList.isEmpty());
}

bool LoadMobileZipPackage::unZipSelectedFilesFromZiP(const QString &zipPath,
    const QString &unZipPath, const QList<AcntId> &selectedAcnts,
    QStringList &msgDbPaths, QString &zfoDbPath)
{
	debugFuncCall();

	/* Open ZIP archive. */
	QuaZip zip(zipPath);
	if (Q_UNLIKELY(!zip.open(QuaZip::mdUnzip))) {
		logErrorNL("ZIP file '%s' is missing or invalid.",
		    zipPath.toUtf8().constData());
		return false;
	}

	/* Delete all files from unzip location. */
	QDir targetDir(unZipPath);
	targetDir.removeRecursively();

	QString backupBaseDir(getBackupBaseDir(zip));
	QuaZipFile quaZipFile(&zip);
	QuaZipFileInfo info;
	Json::Backup::Checksum checksum;
	bool success = true;
	QString outZipPath;

	/* Read and extract all selected accounts from ZIP archive (without JSON file). */
	for (bool f = zip.goToFirstFile(); f; f = zip.goToNextFile()) {

		bool isSelected = false;
		QString subdir;
		if (Q_UNLIKELY(!zip.getCurrentFileInfo(&info))) {
			logErrorNL("Cannot read file info '%s' from ZIP archive.",
			    info.name.toUtf8().constData());
			success = false;
			continue;
		}

		QFileInfo fi(info.name);
		/* Skip root, dirs and macosx metadata in ZIP file. */
		if (isExcludedFileFromRestore(fi)) {
			continue;
		}

		/* ZFO db file. */
		if (fi.fileName().toLower() == QStringLiteral(ZFO_DB_NAME)) {
			isSelected = true;
		}

		/* Test if file in ZIP archive is valid for selected account. */
		if (!isSelected) {
			foreach (const AcntId &acntId, selectedAcnts) {
				if (isValidForSelectedAccount(acntId, info.name)) {
					isSelected = true;
					break;
				}
			}
		}
		/* If the file is not valid for selected account, read next. */
		if (!isSelected) {
			continue;
		}

		/* Extract file from ZIP archive and write into target location. */
		if (Q_UNLIKELY(!extractFileFromZip(quaZipFile, backupBaseDir,
		        subdir, targetDir, checksum, outZipPath))) {
			logErrorNL("Cannot extract file '%s' from ZIP archive.",
			    fi.fileName().toUtf8().constData());
			success = false;
			continue;
		}
		if (isNewDatabase(fi.fileName())) {
			msgDbPaths.append(outZipPath);
		}
		if (fi.fileName().toLower() == QStringLiteral(ZFO_DB_NAME)) {
			zfoDbPath = outZipPath;
		}
	}
	zip.close();

	if (success) {
		logInfoNL("%s", "All data were extracted successfully.");
	} else {
		targetDir.removeRecursively();
		logErrorNL("%s", "Application data extraction has been cancelled.");
	}

	return success;
}
