/*
 * Copyright (C) 2014-2026 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 <QMutexLocker>
#include <QRegularExpression>
#include <QSet>
#include <QSqlError>
#include <QSqlQuery>
#include <QStringBuilder>
#include <QTemporaryDir>

#include "src/datovka_shared/compat/compiler.h" /* macroStdMove */
#include "src/datovka_shared/compat_qt/misc.h" /* qsizetype */
#include "src/datovka_shared/compat_qt/variant.h" /* nullVariantWhenIsNull */
#include "src/datovka_shared/io/db_helper.h"
#include "src/datovka_shared/io/db_time.h"
#include "src/datovka_shared/io/draft_db.h"
#include "src/datovka_shared/io/draft_db_tables.h"
#include "src/datovka_shared/io/filesystem.h" /* createDirRecursive(), writeFile() */
#include "src/datovka_shared/json/basic.h"
#include "src/datovka_shared/json/db_info.h"
#include "src/datovka_shared/log/log.h"
#include "src/datovka_shared/utility/date_time.h"
#include "src/datovka_shared/utility/strings.h"

/* Current database version. */
#define DB_VER_MAJOR 1
#define DB_VER_MINOR 0

enum ErrorCode {
	EC_OK = 0, /*!< Operation succeeded. */
	EC_INPUT, /*!< Invalid input supplied. */
	EC_DB, /*!< Error occurred when interacting with the database engine. */
	EC_NO_DATA, /*!< No available data stored in the database. */
	EC_EXISTS, /*!< Data already exist or inserted values are in conflict with existing ones. */
	EC_NO_CHANGE /*!< No data are changed. */
};

enum Strictness {
	ST_STRICT = 0, /*!< Fail on any error, all must succeed. */
	ST_RELAXED /*!< Continue on errors. */
};

static const QString dbInfoEntryName("db_info");

void DraftEntry::declareTypes(void)
{
	qRegisterMetaType<DraftEntry>("DraftEntry");
	qRegisterMetaType< QList<DraftEntry> >("QList<DraftEntry>");
}

void DraftEntryList::declareTypes(void)
{
	qRegisterMetaType< QList<DraftEntry> >("QList<DraftEntry>");
	qRegisterMetaType<DraftEntryList>("DraftEntryList");
}

DraftEntryList::DraftEntryList(void)
    : QList<DraftEntry>()
{
}

DraftEntryList::DraftEntryList(const QList<DraftEntry> &other)
    : QList<DraftEntry>(other)
{
}
#ifdef Q_COMPILER_RVALUE_REFS
DraftEntryList::DraftEntryList(QList<DraftEntry> &&other)
    : QList<DraftEntry>(::std::move(other))
{
}
#endif /* Q_COMPILER_RVALUE_REFS */

void DraftCounts::declareTypes(void)
{
	qRegisterMetaType<DraftCounts>("DraftCounts");
}

QString DraftDb::assocFileDirPath(void) const
{
	return _assocFileDirPath(fileName());
}

bool DraftDb::getDbInfo(Json::DbInfo &info) const
{
	QSqlQuery query(m_db);
	QString jsonStr;
	bool iOk = false;

	QString queryStr = "SELECT entry_json FROM _db_info "
	    "WHERE (entry_name = :entry_name)";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	query.bindValue(":entry_name", dbInfoEntryName);
	if (Q_UNLIKELY(!(query.exec() && query.isActive()))) {
		logErrorNL("Cannot execute SQL query: %s; %s",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (query.first() && query.isValid()) {
		jsonStr = query.value(0).toString();
	} else {
		logWarningNL("Cannot read SQL data: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	if (Q_UNLIKELY(jsonStr.isEmpty())) {
		goto fail;
	}

	info = Json::DbInfo::fromJson(jsonStr.toUtf8(), &iOk);
	if (Q_UNLIKELY(!iOk)) {
		goto fail;
	}

	return true;

fail:
	info = Json::DbInfo();
	return false;
}

bool DraftDb::updateDbInfo(const Json::DbInfo &info)
{
	QSqlQuery query(m_db);
	QString jsonStr;

	QString queryStr =
	    "INSERT OR REPLACE INTO _db_info (entry_name, entry_json) "
	    "VALUES (:entry_name, :entry_json)";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	query.bindValue(":entry_name", dbInfoEntryName);
	query.bindValue(":entry_json", (!info.isNull()) ? QString::fromUtf8(info.toJsonData(false)) : QVariant());
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	return true;

fail:
	return false;
}

/*!
 * @brief Create a path of a directory base where to store all drafts for a given account.
 *
 * @note The directory is not actually created.
 *
 * @param[in] assocFileDirPath General path for this database.
 * @param[in] acntId Account identifier.
 * @return String containing a directory path.
 */
static
QString constructAccountDraftBaseDirPath(const QString &assocFileDirPath,
    const AcntId &acntId)
{
	if (Q_UNLIKELY(assocFileDirPath.isEmpty())) {
		return QString();
	}

	return assocFileDirPath % QStringLiteral("/")
	    % acntId.strId();
}

/*!
 * @brief Create a path of a directory where to store data of message with given location key.
 *
 * @note The directory is not actually created.
 *
 * @param[in] assocFileDirPath General path for this database.
 * @param[in] acntId Account identifier.
 * @param[in] locationKey Location key.
 * @return String containing a directory path.
 */
static
QString constructDraftBaseDirPath(const QString &assocFileDirPath,
    const AcntId &acntId, const QString &locationKey)
{
	if (Q_UNLIKELY(assocFileDirPath.isEmpty())) {
		return QString();
	}

	return assocFileDirPath % QStringLiteral("/")
	    % acntId.strId() % QStringLiteral("/")
	    % locationKey;
}

/*!
 * @brief Create unique subdirectory in \a baseDirPath.
 *
 * @note The directory is created.
 *
 * @param[in] baseDirPath Complete path to specific draft content.
 * @return Path to existing newly created subdirectory of \a baseDirPath on success,
 *     empty string on error.
 */
static
QString constructAttachmentDir(const QString &baseDirPath)
{
	if (Q_UNLIKELY(!createDirRecursive(baseDirPath))) {
		logErrorNL("Cannot access or create directory '%s'.",
		    baseDirPath.toUtf8().constData());
		return QString();
	}

	QTemporaryDir tmpDir(baseDirPath % QLatin1String("/") % QLatin1String("XXXXXX"));
	if (Q_UNLIKELY(!tmpDir.isValid())) {
		logErrorNL("%s", "Could not create a temporary directory.");
		return QString();
	}
	tmpDir.setAutoRemove(false);
	return tmpDir.path();
}

/*!
 * @brief Construct a unique location key for given username.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in] acntId Account identifier.
 * @param[in] time Time.
 * @param[out] locationKey Constructed location key.
 * @return Error code.
 */
static
enum ErrorCode _constructUniqueLocationKey(QSqlQuery &query,
    const AcntId &acntId, const QDateTime &time, QString &locationKey)
{
	if (Q_UNLIKELY(acntId.username().isEmpty())) {
		logErrorNL("%s", "Cannot use empty username.");
		return EC_INPUT;
	}

	QSet<QString> locationKeys;

	enum ErrorCode ec = EC_OK;

	QString queryStr = "SELECT location_key FROM drafts "
	    "WHERE username = :username AND test_env = :test_env";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":username", acntId.username());
	query.bindValue(":test_env", acntId.testing());
	if (query.exec() && query.isActive()) {
		query.first();
		if (Q_UNLIKELY(query.isValid())) {
			while (query.isValid()) {
				locationKeys.insert(query.value(0).toString());
				query.next();
			}
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	do {
		locationKey = time.toString(Utility::dateTimeFileSuffixFormat)
		    % QStringLiteral("-")
		    % Utility::generateRandomString(4).toUtf8().toHex();
	} while (locationKeys.contains(locationKey));

fail:
	return ec;
}

/*!
 * @brief Get relative path of \a filePath with respect to \a baseDirPath.
 *
 * @param[in] filePath File path that should lie within \a baseDirPath.
 * @param[in] baseDirPath Complete path to specific draft content.
 * @return Relative path of \a filePath, null string on any error.
 */
static
QString relativeFilePath(const QString &filePath, const QString &baseDirPath)
{
	QString relFilePath;

	const QString base = baseDirPath + QStringLiteral("/");
	qsizetype pos = filePath.indexOf(base);
	/* Position should be 0. */
	if (Q_UNLIKELY(pos != 0)) {
		goto fail;
	}
	relFilePath = filePath;
	relFilePath.replace(pos, base.length(), QString());

fail:
	return relFilePath;
}

/*!
 * @brief Convert attachment list into a format suitable for storing.
 *
 * @note Data are stored as files inside \a baseDirPath. No Base64-encoded data
 *     are stored into actual tables.
 *
 * @param[out] tgtAttachments Converted attachment list.
 * @param[in] srcAttachments Input attachment list.
 * @param[in] baseDirPath Complete path to specific draft content.
 * @param[out] newlyCreatedAttDirs Set of newly created attachment directories.
 * @return True on success, false on any error.
 */
static
bool normaliseAttachments(Json::DmDraft::AttachmentList &tgtAttachments,
    const Json::DmDraft::AttachmentList &srcAttachments,
    const QString &baseDirPath, QSet<QString> *newlyCreatedAttDirs = Q_NULLPTR)
{
	QSet<QString> newAttDirs; /* New attachment directories. */

	tgtAttachments.clear();

	for (const Json::DmDraft::Attachment &srcAtt : srcAttachments) {
		if (srcAtt.path().isEmpty()) {
			/* Attachment file name and data must be set. */
			if (Q_UNLIKELY(srcAtt.name().isEmpty() || srcAtt.data().isEmpty())) {
				logErrorNL("%s",
				    "Attachment name or data are missing.");
				goto fail;
			}

			/* Create file and store relative paths to database. */
			const QString attDir = constructAttachmentDir(baseDirPath);
			if (Q_UNLIKELY(attDir.isEmpty())) {
				goto fail;
			}
			newAttDirs.insert(attDir);
			const QString filePath = attDir % QLatin1String("/") % srcAtt.name();
			enum WriteFileState wfs = writeFile(filePath, srcAtt.data(), true);
			if (Q_UNLIKELY(wfs != WF_SUCCESS)) {
				logErrorNL("Cannot write file '%s'",
				    filePath.toUtf8().constData());
				goto fail;
			}

			/* Get path relative to baseDirPath. */
			QString relFilePath =
			    relativeFilePath(filePath, baseDirPath);
			if (Q_UNLIKELY(relFilePath.isEmpty())) {
				logErrorNL(
				    "Cannot determine directory base '%s'.",
				    baseDirPath.toUtf8().constData());
				goto fail;
			}

			Json::DmDraft::Attachment tgtAtt;
			tgtAtt.setPath(macroStdMove(relFilePath));
			tgtAtt.setCreatedFromText(srcAtt.createdFromText());
			tgtAttachments.append(tgtAtt);
		} else {
			/* Ignore file name but fail if data are set. */
			if (Q_UNLIKELY(!srcAtt.data().isEmpty())) {
				logErrorNL("%s",
				    "File path and data cannot be specified both at once.");
				goto fail;
			}

			/* Check whether file lies within baseDirPath. */
			bool externalFile = false;
			QString fileName;
			{
				QString canonicalFilePath;
				{
					const QFileInfo fi(srcAtt.path());
					if (Q_UNLIKELY(!fi.isFile())) {
						logErrorNL("File '%s' isn't a file.",
						    srcAtt.path().toUtf8().constData());
						goto fail;
					}
					canonicalFilePath = fi.canonicalFilePath();
					fileName = fi.fileName();
					if (Q_UNLIKELY(canonicalFilePath.isEmpty())) {
						logErrorNL("File '%s' doesn't exist.",
						    srcAtt.path().toUtf8().constData());
						goto fail;
					}
				}

				const QString canonicalBaseDirPath =
				    QFileInfo(baseDirPath).canonicalFilePath();
				if (canonicalBaseDirPath.isEmpty()) {
					/* The baseDirPath doesn't exist. */
					externalFile = true;
				} else {
					qsizetype pos = canonicalFilePath.indexOf(canonicalBaseDirPath);
					/* Position should be 0 or -1. */
					if (pos != 0) {
						externalFile = true;
					}
					/*
					 * The file already lies within
					 * the intended location.
					 */
				}
			}

			Json::DmDraft::Attachment tgtAtt;

			if (externalFile) {
				const QString attDir = constructAttachmentDir(baseDirPath);
				if (Q_UNLIKELY(attDir.isEmpty())) {
					goto fail;
				}
				newAttDirs.insert(attDir);
				const QString filePath = attDir % QLatin1String("/") % fileName;
				/* Copy srcAtt.path() to filePath. */
				if (Q_UNLIKELY(!QFile::copy(srcAtt.path(), filePath))) {
					logErrorNL("Cannot write file '%s'",
					    filePath.toUtf8().constData());
					goto fail;
				}

				/* Get path relative to baseDirPath. */
				QString relFilePath =
				    relativeFilePath(filePath, baseDirPath);
				if (Q_UNLIKELY(relFilePath.isEmpty())) {
					logErrorNL(
					    "Cannot determine directory base '%s'.",
					    baseDirPath.toUtf8().constData());
					goto fail;
				}

				tgtAtt.setPath(macroStdMove(relFilePath));
			} else {
				/* Use present path. */
				const QString filePath = srcAtt.path();

				/* Get path relative to baseDirPath. */
				QString relFilePath =
				    relativeFilePath(filePath, baseDirPath);
				if (Q_UNLIKELY(relFilePath.isEmpty())) {
					logErrorNL(
					    "Cannot determine directory base '%s'.",
					    baseDirPath.toUtf8().constData());
					goto fail;
				}

				tgtAtt.setPath(macroStdMove(relFilePath));
			}
			tgtAtt.setCreatedFromText(srcAtt.createdFromText());
			tgtAttachments.append(tgtAtt);
		}
	}

	if (Q_NULLPTR != newlyCreatedAttDirs) {
		*newlyCreatedAttDirs = macroStdMove(newAttDirs);
	}
	return true;

fail:
	/* Clean up. */
	for (const QString &newAttDir : newAttDirs) {
		if (Q_UNLIKELY(!QDir(newAttDir).removeRecursively())) {
			logErrorNL("Couldn't completely remove '%s'.",
			    newAttDir.toUtf8().constData());
		}
	}
	tgtAttachments.clear();
	if (Q_NULLPTR != newlyCreatedAttDirs) {
		newlyCreatedAttDirs->clear();
	}
	return false;
}

/*!
 * @brief Convert attachment list into a format suitable for returning to the
 *     caller.
 *
 * @note All relative paths to attachments are converted to absolute paths.
 *
 * @param[out] tgtAttachments Converted attachment list.
 * @param[in] srcAttachments Input attachment list.
 * @param[in] baseDirPath Complete path to specific draft content.
 * @param[in] st Strictness.
 * @return True on success, false on any error.
 */
static
bool absolutiseAttachments(Json::DmDraft::AttachmentList &tgtAttachments,
    const Json::DmDraft::AttachmentList &srcAttachments,
    const QString &baseDirPath, enum Strictness st)
{
	bool allSuccess = true;
	if (Q_UNLIKELY(baseDirPath.isEmpty())) {
		goto fail;
	}

	tgtAttachments.clear();

	for (const Json::DmDraft::Attachment &srcAtt : srcAttachments) {
		if (!srcAtt.path().isEmpty()) {
			/* Ignore file name but path must be set. */
			/* Paths must be relative to baseDirPath. */
			QString filePath = baseDirPath % QStringLiteral("/") % srcAtt.path();

			{
				const QFileInfo fi(filePath);
				if (Q_UNLIKELY(!fi.exists())) {
					logErrorNL("File '%s' doesn't exist.",
					    filePath.toUtf8().constData());
					allSuccess = false;
					if (ST_RELAXED == st) {
						continue;
					} else {
						goto fail;
					}
				}
				if (Q_UNLIKELY(!fi.isFile())) {
					logErrorNL("Path '%s' is not a file.",
					    filePath.toUtf8().constData());
					allSuccess = false;
					if (ST_RELAXED == st) {
						continue;
					} else {
						goto fail;
					}
				}
			}

			Json::DmDraft::Attachment tgtAtt;
			tgtAtt.setPath(macroStdMove(filePath));
			tgtAtt.setCreatedFromText(srcAtt.createdFromText());
			tgtAttachments.append(tgtAtt);
		} else {
			logErrorNL("%s",
			    "Attachment with empty path found.");
			allSuccess = false;
			if (ST_RELAXED == st) {
				continue;
			} else {
				goto fail;
			}
		}
	}

	return allSuccess;

fail:
	tgtAttachments.clear();
	return false;
}

/*!
 * @brief Convert attachment list in normalised draft \a dmDraft into a
 *     format suitable for returning to the caller.
 *
 * @note All relative paths to attachments are converted to absolute paths.
 *
 * @param[in,out] dmDraft Normalised draft to be processed.
 * @param[in] assocFileDirPath General path for this database.
 * @param[in] acntId Account identifier.
 * @param[in] locationKey Location key.
 * @param[in] st Strictness.
 * @return True on success, false on any error.
 */
static
bool absolutiseDraftAttachments(Json::DmDraft &dmDraft,
    const QString &assocFileDirPath, const AcntId &acntId,
    const QString &locationKey, enum Strictness st)
{
	Json::DmDraft::AttachmentList absolutisedAttachments;
	const QString baseDirPath = constructDraftBaseDirPath(
	    assocFileDirPath, acntId, locationKey);
	bool success = absolutiseAttachments(absolutisedAttachments,
	    dmDraft.dmAttachments(), baseDirPath, st);
	if (Q_UNLIKELY((!success) && (ST_STRICT == st))) {
		return false;
	}

	dmDraft.setDmAttachments(macroStdMove(absolutisedAttachments));

	return success;
}

/*!
 * @brief Get absolute paths from paths present in \a srcAttachments.
 *
 * @param[out] absoluteFilePaths Obtained absolute file paths.
 * @param[in] srcAttachments Input attachment list.
 * @return True on success, false on any error.
 */
static
bool getAbsolutePaths(QSet<QString> &absoluteFilePaths,
    const Json::DmDraft::AttachmentList &srcAttachments)
{
	absoluteFilePaths.clear();

	for (const Json::DmDraft::Attachment &srcAtt : srcAttachments) {
		if (!srcAtt.path().isEmpty()) {
			const QFileInfo fi(srcAtt.path());
			if (Q_UNLIKELY(!fi.isFile())) {
				logErrorNL("File '%s' isn't a file.",
				    srcAtt.path().toUtf8().constData());
				goto fail;
			}

			QString absoluteFilePath = fi.absoluteFilePath();
			if (Q_UNLIKELY(absoluteFilePath.isEmpty())) {
				logErrorNL("Cannot determine absolute file path of file '%s'.",
				    srcAtt.path().toUtf8().constData());
				goto fail;
			}
			absoluteFilePaths.insert(absoluteFilePath);
		}
	}

	return true;

fail:
	absoluteFilePaths.clear();
	return false;
}

/*!
 * @brief Get draft count.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in] acntId Account identifier.
 * @param[out] draftCount Draft count or respective account.
 * @return Error code.
 */
static
enum ErrorCode _getDraftCounts(QSqlQuery &query, const AcntId &acntId,
    qint64 &draftCount)
{
	if (Q_UNLIKELY(!acntId.isValid())) {
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	QString queryStr = "SELECT COUNT(*) FROM drafts "
	    "WHERE username = :username AND test_env = :test_env";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":username", acntId.username());
	query.bindValue(":test_env", acntId.testing());

	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			draftCount = query.value(0).toLongLong();
		} else {
			/* Data must be present, don't return EC_NO_DATA. */
			logWarningNL("Cannot read SQL data: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			ec = EC_DB;
			goto fail;
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	return ec;

fail:
	draftCount = -1;
	return ec;
}

bool DraftDb::insertDraft(const AcntId &acntId,
    const Json::DmDraft &dmDraft)
{
	const QDateTime utcTime = QDateTime::currentDateTime().toUTC();

	qint64 key = -1;
	Json::DmDraft normalisedDraft;
	Json::DmDraft absolutisedDraft;
	qint64 draftCount = -1;

	{
		bool transaction = false;

		QMutexLocker locker(&m_lock);
		QSqlQuery query(m_db);

		QString queryStr;
		QString baseDirPath;
		Json::DmDraft normalisedDraft;

		QString newLocationKey;
		{
			enum ErrorCode ec = _constructUniqueLocationKey(query, acntId,
			    utcTime, newLocationKey);
			if (Q_UNLIKELY(ec != EC_OK)) {
				goto fail;
			}
		}

		{
			Json::DmDraft::AttachmentList normalisedAttachments;
			baseDirPath = constructDraftBaseDirPath(
			    assocFileDirPath(), acntId, newLocationKey);
			bool normalised = normaliseAttachments(normalisedAttachments,
			    dmDraft.dmAttachments(), baseDirPath);
			if (Q_UNLIKELY(!normalised)) {
				logErrorNL("%s", "Cannot normalise attachment list.");
				goto fail;
			}

			normalisedDraft = dmDraft;
			normalisedDraft.setDmAttachments(
			    macroStdMove(normalisedAttachments));
		}

		{
			absolutisedDraft = normalisedDraft;
			bool allAbsolutised = absolutiseDraftAttachments(
			    absolutisedDraft, assocFileDirPath(),
			    acntId, newLocationKey, ST_STRICT);
			if (Q_UNLIKELY(!allAbsolutised)) {
				logErrorNL("%s",
				    "Cannot make all paths in attachment list absolute.");
				goto fail;
			}
		}

		transaction = beginTransaction();
		if (Q_UNLIKELY(!transaction)) {
			goto fail;
		}

#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
		queryStr = "INSERT INTO drafts (username, test_env, location_key, "
		    "creationTime, updateTime, jsonData) VALUES (:username, "
		    ":test_env, :locationKey, :creationTime, :updateTime, :jsonData) "
		    "RETURNING id";
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		query.bindValue(":username", acntId.username());
		query.bindValue(":test_env", acntId.testing());
		query.bindValue(":locationKey", newLocationKey);
		query.bindValue(":creationTime", nullVariantWhenIsNull(
		     qDateTimeToDbFormat(utcTime)));
		query.bindValue(":updateTime", nullVariantWhenIsNull(QDateTime()));
		query.bindValue(":jsonData", QString::fromUtf8(normalisedDraft.toJsonData())); /* As string. */
		if (query.exec() && query.isActive()) {
			if (query.first() && query.isValid()) {
				key = query.value(0).toLongLong();
			} else {
				logWarningNL("Cannot read SQL data: %s; %s.",
				    query.lastQuery().toUtf8().constData(),
				    query.lastError().text().toUtf8().constData());
				goto fail;
			}
		} else {
			logErrorNL("Cannot execute SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
#else /* < Qt-6.9.0 */
		/* Insert. */
		queryStr = "INSERT INTO drafts (username, test_env, location_key, "
		    "creationTime, updateTime, jsonData) VALUES (:username, "
		    ":test_env, :locationKey, :creationTime, :updateTime, :jsonData)";
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		query.bindValue(":username", acntId.username());
		query.bindValue(":test_env", acntId.testing());
		query.bindValue(":locationKey", newLocationKey);
		query.bindValue(":creationTime", nullVariantWhenIsNull(
		     qDateTimeToDbFormat(utcTime)));
		query.bindValue(":updateTime", nullVariantWhenIsNull(QDateTime()));
		query.bindValue(":jsonData", QString::fromUtf8(normalisedDraft.toJsonData())); /* As string. */
		if (Q_UNLIKELY(!query.exec())) {
			logErrorNL("Cannot execute SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}

		/* Read inserted. */
		queryStr = "SELECT id FROM drafts "
		    "WHERE username = :username AND test_env = :test_env "
		    "AND location_key = :locationKey AND jsonData = :jsonData";
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		query.bindValue(":username", acntId.username());
		query.bindValue(":test_env", acntId.testing());
		query.bindValue(":locationKey", newLocationKey);
		query.bindValue(":jsonData", QString::fromUtf8(normalisedDraft.toJsonData())); /* As string. */
		if (query.exec() && query.isActive()) {
			if (query.first() && query.isValid()) {
				key = query.value(0).toLongLong();
			} else {
				logWarningNL("Cannot read SQL data: %s; %s.",
				    query.lastQuery().toUtf8().constData(),
				    query.lastError().text().toUtf8().constData());
				goto fail;
			}
		} else {
			logErrorNL("Cannot execute SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
#endif /* >= Qt-6.9.0 */

		/* Read draft count. */
		{
			enum ErrorCode ec = _getDraftCounts(query, acntId, draftCount);
			if (ec == EC_OK) {
				/* Do nothing. */
			} else if (ec == EC_NO_DATA) {
				draftCount = 0;
			} else {
				goto fail;
			}
		}

		commitTransaction();
		goto success;

fail:
		if (transaction) {
			rollbackTransaction();
		}
		/* Remove created attachments. */
		if (!baseDirPath.isEmpty()) {
			if (Q_UNLIKELY(!QDir(baseDirPath).removeRecursively())) {
				logErrorNL("Couldn't completely remove '%s'.",
				    baseDirPath.toUtf8().constData());
			}
		}
		return false;
	}

success:
	/* Signal must not be emitted when write lock is active. */
	Q_EMIT draftsInserted(
	    {DraftEntry(key, acntId, utcTime.toLocalTime(), QDateTime(), absolutisedDraft)});
	{
		DraftCounts readDraftCounts;
		readDraftCounts[acntId] = draftCount;
		Q_EMIT draftCountChanged(readDraftCounts);
	}
	return true;
}

bool DraftDb::updateDraft(qint64 id, const AcntId &newAcntId,
    const Json::DmDraft &dmDraft)
{
	const QDateTime utcTime = QDateTime::currentDateTime().toUTC();

	if (Q_UNLIKELY(!newAcntId.isValid())) {
		logErrorNL("%s", "Passed invalid account identifier.");
		return false;
	}

	AcntId oldAcntId;
	QDateTime creationTime;
	Json::DmDraft normalisedDraft;
	Json::DmDraft absolutisedDraft;
	DraftCounts readDraftCounts;

	{
		QMutexLocker locker(&m_lock);
		QSqlQuery query(m_db);

		/*
		 * The methods works this way:
		 *   1. Convert stored attachment paths to absolute.
		 *   2. Convert paths of new files to absolute if file paths provided.
		 *   3. Get files to be removed:
		 *     (stored absolute - new absolute) = files to be deleted
		 *   4. Normalise all new and store to database.
		 *   5. Delete files to be deleted.
		 */

		QString locationKey;
		QString baseDirPath;
		QString removedBaseDirPath;
		QSet<QString> storedAbsoluteFilePaths;
		QSet<QString> newAbsoluteFilePaths;
		QSet<QString> removedStoredAbsoluteFilePaths;
		QSet<QString> newAttDirs;

		/* Get stored draft attachment absolute paths. */
		QString queryStr = "SELECT username, test_env, location_key, "
		    "creationTime, jsonData FROM drafts WHERE id = :id";
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		query.bindValue(":id", id);
		if (Q_UNLIKELY(!(query.exec() && query.isActive()))) {
			logErrorNL("Cannot execute SQL query: %s; %s",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		if (query.first() && query.isValid()) {
			oldAcntId = AcntId(query.value(0).toString(), query.value(1).toBool());
			locationKey = query.value(2).toString();
			creationTime = dateTimeFromDbFormat(query.value(3).toString());
			if (Q_UNLIKELY((!oldAcntId.isValid()) || locationKey.isEmpty())) {
				logErrorNL("%s",
				    "Missing account or location key data.");
				goto fail;
			}
			const QByteArray jsonData = query.value(4).toString().toUtf8();
			bool iOk = false;
			Json::DmDraft storedDraft =
			    Json::DmDraft::fromJson(jsonData, &iOk);
			if (Q_UNLIKELY(!iOk)) {
				logErrorNL("Cannot process JSON '%s'.",
				    jsonData.constData());
				goto fail;
			}

			Json::DmDraft::AttachmentList absolutisedAttachments;
			baseDirPath = constructDraftBaseDirPath(assocFileDirPath(),
			    oldAcntId, locationKey);
			bool allAbsolutised = absolutiseAttachments(absolutisedAttachments,
			    storedDraft.dmAttachments(), baseDirPath, ST_RELAXED);
			if (Q_UNLIKELY(!allAbsolutised)) {
				logErrorNL("%s",
				    "Cannot make all paths in attachment list absolute.");
				/* Don't fail. Allow reading some data. */
			}

			for (const Json::DmDraft::Attachment &a : absolutisedAttachments) {
				storedAbsoluteFilePaths.insert(a.path());
			}
		} else {
			/* No data to be updated. */
			logWarningNL("Cannot read SQL data: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}

		if (oldAcntId == newAcntId) {
			/* Get new absolute paths. */
			if (Q_UNLIKELY(!getAbsolutePaths(newAbsoluteFilePaths,
			        dmDraft.dmAttachments()))) {
				logErrorNL("%s",
				    "Cannot make attachment paths in new attachment list absolute.");
				goto fail;
			}

			/* Determine files to be removed. */
			removedStoredAbsoluteFilePaths = storedAbsoluteFilePaths - newAbsoluteFilePaths;
		} else {
			/* Storing under new account. */

			/* The directory may be deleted. */
			removedBaseDirPath = macroStdMove(baseDirPath);
			/* Use new base directory path. */
			baseDirPath = constructDraftBaseDirPath(assocFileDirPath(),
			    newAcntId, locationKey);
		}

		/* Normalise input. */
		{
			Json::DmDraft::AttachmentList normalisedAttachments;
			bool normalised = normaliseAttachments(normalisedAttachments,
			    dmDraft.dmAttachments(), baseDirPath, &newAttDirs);
			if (Q_UNLIKELY(!normalised)) {
				logErrorNL("%s", "Cannot normalise attachment list.");
				goto fail;
			}

			normalisedDraft = dmDraft;
			normalisedDraft.setDmAttachments(
			    macroStdMove(normalisedAttachments));
		}

		/* Absolutise input. */
		{
			absolutisedDraft = normalisedDraft;
			bool allAbsolutised = absolutiseDraftAttachments(
			    absolutisedDraft, assocFileDirPath(),
			    newAcntId, locationKey, ST_STRICT);
			if (Q_UNLIKELY(!allAbsolutised)) {
				logErrorNL("%s",
				    "Cannot make all paths in attachment list absolute.");
				goto fail;
			}
		}

		/* Store new draft. */
		queryStr = "UPDATE drafts SET username = :username, "
		    "test_env = :test_env, updateTime = :updateTime, "
		    "jsonData = :jsonData WHERE id = :id";
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		query.bindValue(":id", id);
		query.bindValue(":username", newAcntId.username());
		query.bindValue(":test_env", newAcntId.testing());
		query.bindValue(":updateTime", nullVariantWhenIsNull(
		     qDateTimeToDbFormat(utcTime)));
		query.bindValue(":jsonData", QString::fromUtf8(normalisedDraft.toJsonData())); /* As string. */

		if (Q_UNLIKELY(!query.exec())) {
			logErrorNL("Cannot execute SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}

		if (oldAcntId != newAcntId) {
			/* Read draft counts. */
			for (const AcntId &key : {oldAcntId, newAcntId}) {
				qint64 draftCount = -1;
				enum ErrorCode ec = _getDraftCounts(query, key, draftCount);
				if (ec == EC_OK) {
					readDraftCounts[key] = draftCount;
				} else if (ec == EC_NO_DATA) {
					/* Return empty list. */
					readDraftCounts[key] = 0;
				} else {
					goto fail;
				}
			}
		}

		/* Delete unused files. */
		for (const QString &filePath : removedStoredAbsoluteFilePaths) {
			if (Q_UNLIKELY(!QFile::remove(filePath))) {
				logErrorNL("Couldn't remove file '%s'.",
				    filePath.toUtf8().constData());
			}
		}
		/* Delete unused directories. */
		if (!removedBaseDirPath.isEmpty()) {
			if (Q_UNLIKELY(!QDir(removedBaseDirPath).removeRecursively())) {
				logErrorNL("Couldn't completely remove '%s'.",
				    removedBaseDirPath.toUtf8().constData());
			}
		}
		goto success;

fail:
		/* Remove all newly created attachments. */
		for (const QString &newAttDir : newAttDirs) {
			if (Q_UNLIKELY(!QDir(newAttDir).removeRecursively())) {
				logErrorNL("Couldn't completely remove '%s'.",
				    newAttDir.toUtf8().constData());
			}
		}
		return false;
	}

success:
	/* Signal must not be emitted when write lock is active. */
	Q_EMIT draftsUpdated(
	    {DraftEntry(id, newAcntId, creationTime, utcTime.toLocalTime(), absolutisedDraft)});
	if (!readDraftCounts.isEmpty()) {
		Q_EMIT draftCountChanged(readDraftCounts);
	}
	return true;
}

/*!
 * @brief Constructs a string containing a comma-separated list
 *     of message identifiers.
 *
 * @param[in] msgIds Message identifiers.
 * @return String with list or empty string when list empty or on failure.
 */
static
QString toListString(const Json::Int64StringList &ids)
{
	QStringList list;
	for (qint64 id : ids) {
		if (Q_UNLIKELY(id < 0)) {
			/* Ignore negative identifiers. */
			continue;
		}
		list.append(QString::number(id));
	}
	if (!list.isEmpty()) {
		return list.join(", ");
	}
	return QString();
}

/*!
 * @brief Construct a condition expression from supplied identifiers for matching
 *     username and testEnv pairs.
 *
 * @param[in] acntIds Account identifiers.
 * @return String with condition expression or empty string when \a acntIds empty or on failure.
 */
static
QString toConditionExpr(const QList<AcntId> &acntIds)
{
	QStringList list;
	for (const AcntId &acntId : acntIds) {
		if (Q_UNLIKELY(!acntId.isValid())) {
			/* Ignore invalid identifiers. */
			continue;
		}
		list.append(
		    QString("((username = '%1') AND (test_env = %2))")
		        .arg(acntId.username())
		        .arg(acntId.testing() ? "1" : "0"));
	}
	if (!list.isEmpty()) {
		return list.join(" OR ");
	}
	return QString();
}

/*!
 * @brief Filter out identifiers not existing in the database.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in,out] ids Draft identifiers to look for. Only existing identifiers
 *                    are left in the list on successful return.
 * @return Error code.
 */
static
enum ErrorCode _existingIdentifiers(QSqlQuery &query,
    Json::Int64StringList &ids)
{
	QString queryStr;
	{
		QString idListing = toListString(ids);
		if (Q_UNLIKELY(idListing.isEmpty())) {
			ids.clear();
			return EC_OK;
		}

		/* There is no way how to use query.bind() to enter list values. */
		queryStr = QString("SELECT id FROM drafts WHERE id IN (%1)")
		    .arg(idListing);
	}

	enum ErrorCode ec = EC_OK;

	Json::Int64StringList result;

	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				result.append(query.value(0).toLongLong());
				query.next();
			}
		} else {
			ec = EC_NO_DATA;
			goto fail;
		}
	}  else {
		logErrorNL("Cannot execute SQL query: %s; %s",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	ids = macroStdMove(result);

fail:
	return ec;
}

/*!
 * @brief Filter out identifiers not existing in the database.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in,out] acntIds Account identifiers to look for. Only existing
 *                        identifiers rre left in the list on successful return.
 * @return Error code.
 */
static
enum ErrorCode _existingIdentifiers(QSqlQuery &query,
    QList<AcntId> &acntIds)
{
	QString queryStr;
	{
		QString conditionListing = toConditionExpr(acntIds);
		if (Q_UNLIKELY(conditionListing.isEmpty())) {
			acntIds.clear();
			return EC_OK;
		}

		/* There is no way how to use query.bind() to enter list values. */
		queryStr = QString("SELECT username, test_env FROM drafts WHERE %1")
		    .arg(conditionListing);
	}

	enum ErrorCode ec = EC_OK;

	QSet<AcntId> result;

	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				result.insert(AcntId(query.value(0).toString(), query.value(1).toBool()));
				query.next();
			}
		} else {
			ec = EC_NO_DATA;
			goto fail;
		}
	}  else {
		logErrorNL("Cannot execute SQL query: %s; %s",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
	acntIds = QList<AcntId>(result.constBegin(), result.constEnd());
#else /* < Qt-5.14.0 */
	acntIds = result.toList();
#endif /* >= Qt-5.14.0 */

fail:
	return ec;
}

bool DraftDb::deleteDrafts(Json::Int64StringList ids)
{
	DraftCounts readDraftCounts;

	{
		bool transaction = false;

		QMutexLocker locker(&m_lock);
		QSqlQuery query(m_db);

		enum ErrorCode ec = EC_OK;
		QString queryStr;
		QString idListing;
		QSet<AcntId> acntIds;
		QSet<QString> deletedBaseDirPaths;

		transaction = beginTransaction();
		if (Q_UNLIKELY(!transaction)) {
			goto fail;
		}

		/* Get only existing identifiers. */
		ec = _existingIdentifiers(query, ids);
		if (Q_UNLIKELY(ec != EC_OK)) {
			goto fail;
		}
		idListing = toListString(ids);
		if (Q_UNLIKELY(idListing.isEmpty())) {
			goto fail;
		}

		/* Collect directories to be deleted. */
		/* There is no way how to use query.bind() to enter list values. */
		queryStr = QString("SELECT username, test_env, location_key "
		    "FROM drafts WHERE id IN (%1)").arg(idListing);
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		if (query.exec() && query.isActive()) {
			if (query.first() && query.isValid()) {
				while (query.isValid()) {
					AcntId acntId(query.value(0).toString(), query.value(1).toBool());
					acntIds.insert(acntId);
					const QString locationKey = query.value(2).toString();
					if (acntId.isValid() && (!locationKey.isEmpty())) {
						deletedBaseDirPaths.insert(
						    constructDraftBaseDirPath(
						        assocFileDirPath(), acntId, locationKey));
					} else {
						/* Ignore this path. */
					}

					query.next();
				}
			} else {
				goto fail;
			}
		}  else {
			logErrorNL("Cannot execute SQL query: %s; %s",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}

		/* Delete entries in tables. */
		queryStr = QString("DELETE FROM drafts WHERE id IN (%1)").arg(idListing);
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		if (Q_UNLIKELY(!query.exec())) {
			logErrorNL("Cannot execute SQL query: %s; %s",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}

		/* Read draft counts. */
		for (const AcntId &key : acntIds) {
			qint64 draftCount = -1;
			ec = _getDraftCounts(query, key, draftCount);
			if (ec == EC_OK) {
				readDraftCounts[key] = draftCount;
			} else if (ec == EC_NO_DATA) {
				/* Return empty list. */
				readDraftCounts[key] = 0;
			} else {
				goto fail;
			}
		}

		/* Delete attachments. */
		for (const QString &dir : deletedBaseDirPaths) {
			if (Q_UNLIKELY(!QDir(dir).removeRecursively())) {
				logErrorNL("Couldn't completely remove '%s'.",
				    dir.toUtf8().constData());
			}
		}

		commitTransaction();
		goto success;

fail:
		if (transaction) {
			rollbackTransaction();
		}
		return false;
	}

success:
	/* Signal must not be emitted when write lock is active. */
	if (!ids.isEmpty()) {
		Q_EMIT draftsDeleted(ids);
	}
	if (!readDraftCounts.isEmpty()) {
		Q_EMIT draftCountChanged(readDraftCounts);
	}
	return true;
}

bool DraftDb::deleteDrafts(QList<AcntId> acntIds)
{
	Json::Int64StringList deletedDraftIds;
	DraftCounts readDraftCounts;

	{
		bool transaction = false;

		QMutexLocker locker(&m_lock);
		QSqlQuery query(m_db);

		enum ErrorCode ec = EC_OK;
		QString queryStr;
		QString conditionListing;
		QSet<QString> deletedBaseDirPaths;

		transaction = beginTransaction();
		if (Q_UNLIKELY(!transaction)) {
			goto fail;
		}

		/* Get only existing identifiers. */
		ec = _existingIdentifiers(query, acntIds);
		if (Q_UNLIKELY(ec != EC_OK)) {
			goto fail;
		}
		conditionListing = toConditionExpr(acntIds);
		if (Q_UNLIKELY(conditionListing.isEmpty())) {
			goto fail;
		}

		/* Collect directories to be deleted. */
		/* There is no way how to use query.bind() to enter list values. */
		queryStr = QString("SELECT id, username, test_env, location_key "
		    "FROM drafts WHERE %1").arg(conditionListing);
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		if (query.exec() && query.isActive()) {
			if (query.first() && query.isValid()) {
				while (query.isValid()) {
					deletedDraftIds.append(query.value(0).toLongLong());
					AcntId acntId(query.value(1).toString(), query.value(2).toBool());

					const QString locationKey = query.value(3).toString();
					if (acntId.isValid() && (!locationKey.isEmpty())) {
						deletedBaseDirPaths.insert(
						    constructAccountDraftBaseDirPath(
						        assocFileDirPath(), acntId));
					} else {
						/* Ignore this path. */
					}

					query.next();
				}
			} else {
				goto fail;
			}
		}  else {
			logErrorNL("Cannot execute SQL query: %s; %s",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}

		/* Delete entries in tables. */
		queryStr = QString("DELETE FROM drafts WHERE %1").arg(conditionListing);
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s; %s.",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}
		if (Q_UNLIKELY(!query.exec())) {
			logErrorNL("Cannot execute SQL query: %s; %s",
			    query.lastQuery().toUtf8().constData(),
			    query.lastError().text().toUtf8().constData());
			goto fail;
		}

		/* Read draft counts. */
		for (const AcntId &key : acntIds) {
			qint64 draftCount = -1;
			ec = _getDraftCounts(query, key, draftCount);
			if (ec == EC_OK) {
				readDraftCounts[key] = draftCount;
			} else if (ec == EC_NO_DATA) {
				/* Return empty list. */
				readDraftCounts[key] = 0;
			} else {
				goto fail;
			}
		}

		/* Delete attachments. */
		for (const QString &dir : deletedBaseDirPaths) {
			if (Q_UNLIKELY(!QDir(dir).removeRecursively())) {
				logErrorNL("Couldn't completely remove '%s'.",
				    dir.toUtf8().constData());
			}
		}

		commitTransaction();
		goto success;

fail:
		if (transaction) {
			rollbackTransaction();
		}
		return false;
	}

success:
	/* Signal must not be emitted when write lock is active. */
	if (!deletedDraftIds.isEmpty()) {
		Q_EMIT draftsDeleted(deletedDraftIds);
	}
	if (!readDraftCounts.isEmpty()) {
		Q_EMIT draftCountChanged(readDraftCounts);
	}
	return true;
}

bool DraftDb::getDraftListing(const Json::Int64StringList &ids,
    QList<DraftEntry> &draftEntries) const
{
	QList<DraftEntry> foundDraftEntries;

	QMutexLocker locker(&m_lock);
	QSqlQuery query(m_db);

	QString queryStr;
	{
		QString idListing = toListString(ids);
		if (Q_UNLIKELY(idListing.isEmpty())) {
			draftEntries.clear();
			return true;
		}

		/* There is no way how to use query.bind() to enter list values. */
		queryStr = QString("SELECT id, username, test_env, location_key, "
		    "creationTime, updateTime, jsonData FROM drafts "
		    "WHERE id IN (%1)")
		    .arg(idListing);
	}

	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				AcntId acntId(query.value(1).toString(), query.value(2).toBool());
				Json::DmDraft dmDraft;
				{
					const QString locationKey = query.value(3).toString();
					const QByteArray jsonData = query.value(6).toString().toUtf8();
					bool iOk = false;
					dmDraft = Json::DmDraft::fromJson(
					    jsonData, &iOk);
					if (Q_UNLIKELY(!iOk)) {
						logErrorNL("Cannot process JSON '%s'.",
						    jsonData.constData());
						goto fail;
					}

					bool allAbsolutised = absolutiseDraftAttachments(
					    dmDraft, assocFileDirPath(),
					    acntId, locationKey, ST_RELAXED);
					if (Q_UNLIKELY(!allAbsolutised)) {
						logErrorNL("%s",
						    "Cannot make all paths in attachment list absolute.");
						/* Don't fail. Allow reading some data. */
					}
				}

				foundDraftEntries.append(DraftEntry(
				    query.value(0).toLongLong(),
				    macroStdMove(acntId),
				    dateTimeFromDbFormat(query.value(4).toString()),
				    dateTimeFromDbFormat(query.value(5).toString()),
				    macroStdMove(dmDraft)
				    ));
				query.next();
			}
		} else {
			/* No data. Do nothing. */
		}
	}  else {
		logErrorNL("Cannot execute SQL query: %s; %s",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	draftEntries = macroStdMove(foundDraftEntries);
	return true;

fail:
	draftEntries.clear();
	return false;
}

bool DraftDb::getDraftListing(const QList<AcntId> &acntIds,
    QList<DraftEntry> &draftEntries) const
{
	QList<DraftEntry> foundDraftEntries;

	QMutexLocker locker(&m_lock);
	QSqlQuery query(m_db);

	QString queryStr;
	{
		QString conditionListing = toConditionExpr(acntIds);
		if (!acntIds.isEmpty()) {
			/* Return only if non-empty list requested. */
			if (Q_UNLIKELY(conditionListing.isEmpty())) {
				draftEntries.clear();
				return true;
			}
		}

		/* There is no way how to use query.bind() to enter list values. */
		queryStr = "SELECT id, username, test_env, location_key, "
		    "creationTime, updateTime, jsonData FROM drafts";
		if (!conditionListing.isEmpty()) {
			queryStr += QString(" WHERE %1").arg(conditionListing);
		}
	}

	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				AcntId acntId(query.value(1).toString(), query.value(2).toBool());
				Json::DmDraft dmDraft;
				{
					const QString locationKey = query.value(3).toString();
					const QByteArray jsonData = query.value(6).toString().toUtf8();
					bool iOk = false;
					dmDraft = Json::DmDraft::fromJson(
					    jsonData, &iOk);
					if (Q_UNLIKELY(!iOk)) {
						logErrorNL("Cannot process JSON '%s'.",
						    jsonData.constData());
						goto fail;
					}

					bool allAbsolutised = absolutiseDraftAttachments(
					    dmDraft, assocFileDirPath(),
					    acntId, locationKey, ST_RELAXED);
					if (Q_UNLIKELY(!allAbsolutised)) {
						logErrorNL("%s",
						    "Cannot make all paths in attachment list absolute.");
						/* Don't fail. Allow reading some data. */
					}
				}

				foundDraftEntries.append(DraftEntry(
				    query.value(0).toLongLong(),
				    macroStdMove(acntId),
				    dateTimeFromDbFormat(query.value(4).toString()),
				    dateTimeFromDbFormat(query.value(5).toString()),
				    macroStdMove(dmDraft)
				    ));
				query.next();
			}
		} else {
			/* No data. Do nothing. */
		}
	}  else {
		logErrorNL("Cannot execute SQL query: %s; %s",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}


	draftEntries = macroStdMove(foundDraftEntries);
	return true;

fail:
	draftEntries.clear();
	return false;
}

bool DraftDb::getDraftCount(const AcntId &acntId,
    qint64 &draftCount) const
{
	QMutexLocker locker(&m_lock);
	QSqlQuery query(m_db);

	enum ErrorCode ec = _getDraftCounts(query, acntId, draftCount);
	if (ec == EC_OK) {
		return true;
	} else if (ec == EC_NO_DATA) {
		draftCount = 0;
		return true;
	}

	return false;
}

bool DraftDb::getDraftCounts(const QList<AcntId> &acntIds,
    DraftCounts &draftCounts) const
{
	enum ErrorCode ec = EC_OK;

	if (Q_UNLIKELY(acntIds.isEmpty())) {
		draftCounts.clear();
		return false;
	}

	QMutexLocker locker(&m_lock);
	QSqlQuery query(m_db);

	DraftCounts readDraftCounts;

	for (const AcntId &key : acntIds) {
		qint64 draftCount = -1;
		ec = _getDraftCounts(query, key, draftCount);
		if (ec == EC_OK) {
			readDraftCounts[key] = draftCount;
		} else if (ec == EC_NO_DATA) {
			/* Return empty list. */
			readDraftCounts[key] = 0;
		} else {
			goto fail;
		}
	}

	draftCounts = macroStdMove(readDraftCounts);
	return true;

fail:
	draftCounts.clear();
	return false;
}

/*!
 * @brief Get the number of entries in the \a table.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in] db SQLite database.
 * @param[in] table Table to search in.
 * @param[out] The number of entries.
 * @return Error code.
 */
static
enum ErrorCode _tabEntryCount(const QSqlDatabase &db,
    class SQLiteTbl &table, qint64 &cnt)
{
	QSqlQuery query(db);

	enum ErrorCode ec = EC_OK;

	qint64 readVal = 0;

	QString queryStr = QString("SELECT COUNT(*) FROM %1")
	    .arg(table.tabName);
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	if (Q_UNLIKELY(!(query.exec() && query.isActive()))) {
		logErrorNL("Cannot execute SQL query: %s; %s",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	if (query.first() && query.isValid()) {
		readVal = query.value(0).toLongLong();
	} else {
		logWarningNL("Cannot read SQL data: %s; %s.",
		    query.lastQuery().toUtf8().constData(),
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	cnt = readVal;
	return ec;

fail:
	cnt = 0;
	return ec;
}

QString DraftDb::_assocFileDirPath(QString fileName)
{
	/* Remove trailing '.db' suffix from current database file path. */
	static const QRegularExpression re("\\.db$");

	if (SQLiteDb::memoryLocation == fileName) {
		/* Return empty string if database is held in memory. */
		return QString();
	}

	return fileName.replace(re, QString());
}

QList<class SQLiteTbl *> DraftDb::listOfTables(void) const
{
	QList<class SQLiteTbl *> tables;
	tables.append(&DraftDbTables::_dbInfoTbl);
	tables.append(&DraftDbTables::draftTbl);
	return tables;
}

bool DraftDb::assureConsistency(void)
{
	QMutexLocker locker(&m_lock);

	bool ret = true;

	/*
	 * Set database content version to current version when no version
	 * is available.
	 * Content version is the first entry in the database.
	 */
	{
		enum ErrorCode ec = EC_OK;
		qint64 entryCount = 0;
		ec = _tabEntryCount(m_db, DraftDbTables::_dbInfoTbl,
		    entryCount);
		if (Q_UNLIKELY(ec != EC_OK)) {
			ret = false;
			goto fail;
		}
		if (0 == entryCount) {
			Json::DbInfo info;
			info.setFormatVersionMajor(DB_VER_MAJOR);
			info.setFormatVersionMinor(DB_VER_MINOR);
			ret = updateDbInfo(info);
		}
	}

fail:
	return ret;
}

bool DraftDb::enableFunctionality(void)
{
	logInfoNL(
	    "Enabling SQLite foreign key functionality in database '%s'.",
	    fileName().toUtf8().constData());

	QMutexLocker locker(&m_lock);

	bool ret = DbHelper::enableForeignKeyFunctionality(m_db);
	if (Q_UNLIKELY(!ret)) {
		logErrorNL(
		    "Couldn't enable SQLite foreign key functionality in database '%s'.",
		    fileName().toUtf8().constData());;
	}
	return ret;
}
