/*
 * Copyright (C) 2014-2022 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 <cinttypes> /* PRId64 */
#include <QHash>
#include <QMutexLocker>
#include <QJsonObject>
#include <QSet>
#include <QString>
#include <QSqlDatabase>
#include <QSqlError>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QVariant>

#include "src/datovka_shared/compat_qt/variant.h" /* nullVariantWhenIsNull */
#include "src/datovka_shared/graphics/colour.h"
#include "src/datovka_shared/identifiers/account_id.h"
#include "src/datovka_shared/json/basic.h"
#include "src/datovka_shared/json/helper.h"
#include "src/datovka_shared/log/log.h"
#include "src/io/tag_db.h"
#include "src/io/tag_db_tables.h"
#include "src/json/tag_assignment.h"
#include "src/json/tag_assignment_hash.h"
#include "src/json/tag_entry.h"
#include "src/json/tag_message_id.h"
#include "src/json/tag_text_search_request.h"
#include "src/json/tag_text_search_request_hash.h"

#define nullInt 0

#ifdef Q_COMPILER_RVALUE_REFS
#  define macroStdMove(x) ::std::move(x)
#else /* Q_COMPILER_RVALUE_REFS */
#  define macroStdMove(x) (x)
#endif /* Q_COMPILER_RVALUE_REFS */

/*!
 * @brief PIMPL Json::TagDbInfo class.
 */
class Json::TagDbInfoPrivate {
public:
	TagDbInfoPrivate(void)
	    : m_formatVersionMajor(nullInt), m_formatVersionMinor(nullInt)
	{ }

	TagDbInfoPrivate &operator=(const TagDbInfoPrivate &other) Q_DECL_NOTHROW
	{
		m_formatVersionMajor = other.m_formatVersionMajor;
		m_formatVersionMinor = other.m_formatVersionMinor;

		return *this;
	}

	bool operator==(const TagDbInfoPrivate &other) const
	{
		return (m_formatVersionMajor == other.m_formatVersionMajor) &&
		    (m_formatVersionMinor == other.m_formatVersionMinor);
	}

	int m_formatVersionMajor;
	int m_formatVersionMinor;
};

Json::TagDbInfo::TagDbInfo(void)
    : Object(),
    d_ptr(Q_NULLPTR)
{
}

Json::TagDbInfo::TagDbInfo(const TagDbInfo &other)
    : Object(),
    d_ptr((other.d_func() != Q_NULLPTR) ? (new (::std::nothrow) TagDbInfoPrivate) : Q_NULLPTR)
{
	Q_D(TagDbInfo);
	if (d == Q_NULLPTR) {
		return;
	}

	*d = *other.d_func();
}

#ifdef Q_COMPILER_RVALUE_REFS
Json::TagDbInfo::TagDbInfo(TagDbInfo &&other) Q_DECL_NOEXCEPT
    : Object(),
#if (QT_VERSION >= QT_VERSION_CHECK(5, 12, 0))
    d_ptr(other.d_ptr.release()) //d_ptr(::std::move(other.d_ptr))
#else /* < Qt-5.12 */
    d_ptr(other.d_ptr.take())
#endif /* >= Qt-5.12 */
{
}
#endif /* Q_COMPILER_RVALUE_REFS */

Json::TagDbInfo::~TagDbInfo(void)
{
}

/*!
 * @brief Ensures TagDbInfoPrivate presence.
 *
 * @note Returns if TagDbInfoPrivate could not be allocated.
 */
#define ensureTagDbInfoPrivate(_x_) \
	do { \
		if (Q_UNLIKELY(d_ptr == Q_NULLPTR)) { \
			TagDbInfoPrivate *p = new (::std::nothrow) TagDbInfoPrivate; \
			if (Q_UNLIKELY(p == Q_NULLPTR)) { \
				Q_ASSERT(0); \
				return _x_; \
			} \
			d_ptr.reset(p); \
		} \
	} while (0)

Json::TagDbInfo &Json::TagDbInfo::operator=(const TagDbInfo &other) Q_DECL_NOTHROW
{
	if (other.d_func() == Q_NULLPTR) {
		d_ptr.reset(Q_NULLPTR);
		return *this;
	}
	ensureTagDbInfoPrivate(*this);
	Q_D(TagDbInfo);

	*d = *other.d_func();

	return *this;
}

#ifdef Q_COMPILER_RVALUE_REFS
Json::TagDbInfo &Json::TagDbInfo::operator=(TagDbInfo &&other) Q_DECL_NOTHROW
{
	swap(*this, other);
	return *this;
}
#endif /* Q_COMPILER_RVALUE_REFS */

bool Json::TagDbInfo::operator==(const TagDbInfo &other) const
{
	Q_D(const TagDbInfo);
	if ((d == Q_NULLPTR) && ((other.d_func() == Q_NULLPTR))) {
		return true;
	} else if ((d == Q_NULLPTR) || ((other.d_func() == Q_NULLPTR))) {
		return false;
	}

	return *d == *other.d_func();
}

bool Json::TagDbInfo::operator!=(const TagDbInfo &other) const
{
	return !operator==(other);
}

bool Json::TagDbInfo::isNull(void) const
{
	Q_D(const TagDbInfo);
	return d == Q_NULLPTR;
}

bool Json::TagDbInfo::isValid(void) const
{
	return !isNull();
}

int Json::TagDbInfo::formatVersionMajor(void) const
{
	Q_D(const TagDbInfo);
	if (Q_UNLIKELY(d == Q_NULLPTR)) {
		return nullInt;
	}

	return d->m_formatVersionMajor;
}

void Json::TagDbInfo::setFormatVersionMajor(int maj)
{
	ensureTagDbInfoPrivate();
	Q_D(TagDbInfo);
	d->m_formatVersionMajor = maj;
}

int Json::TagDbInfo::formatVersionMinor(void) const
{
	Q_D(const TagDbInfo);
	if (Q_UNLIKELY(d == Q_NULLPTR)) {
		return nullInt;
	}

	return d->m_formatVersionMinor;
}

void Json::TagDbInfo::setFormatVersionMinor(int min)
{
	ensureTagDbInfoPrivate();
	Q_D(TagDbInfo);
	d->m_formatVersionMinor = min;
}

Json::TagDbInfo Json::TagDbInfo::fromJson(const QByteArray &json, bool *ok)
{
	QJsonObject jsonObj;
	if (!Helper::readRootObject(json, jsonObj)) {
		if (ok != Q_NULLPTR) {
			*ok = false;
		}
		return TagDbInfo();
	}

	return fromJsonVal(jsonObj, ok);
}

static const QString keyFormatVersionMajor("formatVersionMajor");
static const QString keyFormatVersionMinor("formatVersionMinor");

Json::TagDbInfo Json::TagDbInfo::fromJsonVal(const QJsonValue &jsonVal, bool *ok)
{
	TagDbInfo tdi;

	if (Q_UNLIKELY(!jsonVal.isObject())) {
		goto fail;
	}

	{
		const QJsonObject jsonObj = jsonVal.toObject();
		{
			int valInt = 0;
			if (Q_UNLIKELY(!Helper::readInt(jsonObj,
			        keyFormatVersionMajor, valInt, Helper::ACCEPT_VALID))) {
				goto fail;
			}
			tdi.setFormatVersionMajor(valInt);
		}
		{
			int valInt = 0;
			if (Q_UNLIKELY(!Helper::readInt(jsonObj,
			        keyFormatVersionMinor, valInt, Helper::ACCEPT_VALID))) {
				goto fail;
			}
			tdi.setFormatVersionMinor(valInt);
		}
	}

	if (ok != Q_NULLPTR) {
		*ok = true;
	}
	return tdi;

fail:
	if (ok != Q_NULLPTR) {
		*ok = false;
	}
	return TagDbInfo();
}

bool Json::TagDbInfo::toJsonVal(QJsonValue &jsonVal) const
{
	QJsonObject jsonObj;

	jsonObj.insert(keyFormatVersionMajor, formatVersionMajor());
	jsonObj.insert(keyFormatVersionMinor, formatVersionMinor());

	jsonVal = jsonObj;
	return true;
}

void Json::swap(TagDbInfo &first, TagDbInfo &second) Q_DECL_NOTHROW
{
	using ::std::swap;
	swap(first.d_ptr, second.d_ptr);
}

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. */
};

static const QString dbInfoEntryName("db_info");

bool TagDb::getDbInfo(Json::TagDbInfo &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.",
		    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.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (query.first() && query.isValid()) {
		jsonStr = query.value(0).toString();
	} else {
		logWarningNL("Cannot read SQL data: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

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

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

	return true;

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

bool TagDb::updateDbInfo(const Json::TagDbInfo &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.",
		    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.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	return true;

fail:
	return false;
}

/*!
 * @brief This function adds the tag_description column into the tag table and
 *     converts the user_name column into test_env in the message_tags table.
 *     It also adds some named constraints.
 *
 * @param[in,out] db SQL database connection.
 * @return True in success.
 */
static
bool ensureTestingColumnInTagTable(const QSqlDatabase &db,
    QHash<QString, bool> &usernamesToTesting)
{
	QSqlQuery query(db);
	QString queryStr;
	QString createTableSql;

	queryStr = "SELECT sql FROM sqlite_master "
	    "WHERE (type = :type) and (name = :name)";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	query.bindValue(":type", QString("table"));
	query.bindValue(":name", QString("tag")); /* Old 'tag' table. */
	if (Q_UNLIKELY(!(query.exec() && query.isActive()))) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (query.first() && query.isValid()) {
		createTableSql = query.value(0).toString();
	}

	if (Q_UNLIKELY(createTableSql.isEmpty())) {
		/* Old 'tag' table has been already dropped. */
		return true;
	}

	/*
	 * Table tag exists therefore database has not been converted.
	 */

	/* Rename existing tables. */
	queryStr = "ALTER TABLE tag RENAME TO _tag";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	queryStr = "ALTER TABLE message_tags RENAME TO _message_tags";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	/* Drop 'tags' table if it was already created. */
	queryStr = "DROP TABLE IF EXISTS tags";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	if (Q_UNLIKELY(!tagsTbl.createEmpty(db))) {
		goto fail;
	}
	if (Q_UNLIKELY(!msgtagsTbl.createEmpty(db))) {
		goto fail;
	}

	/* Copy table content. */
	queryStr = "INSERT OR REPLACE INTO tags (id, tag_name, tag_color) "
	    "SELECT id, tag_name, tag_color FROM _tag";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	/* Convert and insert data into new table. */
	queryStr = "SELECT id, user_name, message_id, tag_id FROM _message_tags";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (query.exec() && query.isActive()) {
		QSqlQuery subQuery(db);

		const QString subQuerySelectStr =
		    "SELECT COUNT(id) FROM message_tags "
		    "WHERE test_env = :test_env AND message_id = :message_id "
		    "AND tag_id = :tag_id";
		const QString subQueryInsertStr =
		    "INSERT INTO message_tags (id, test_env, message_id, tag_id) "
		    "VALUES (:id, :test_env, :message_id, :tag_id)";

		query.first();
		while (query.isValid()) {
			const qint64 id = query.value(0).toLongLong();
			const QString username = query.value(1).toString();
			const qint64 msgId = query.value(2).toLongLong();
			const qint64 tagId = query.value(3).toLongLong();

			if (Q_UNLIKELY(!usernamesToTesting.contains(username))) {
				logWarningNL(
				    "Cannot assign tag id '%" PRId64 "' to message id '%" PRId64 "'. "
				    "Cannot convert username '%s' to test environment flag.",
				    UGLY_QINT64_CAST tagId,
				    UGLY_QINT64_CAST msgId,
				    username.toUtf8().constData());

				/*
				 * Couldn't get account information.
				 * Continue with next entry.
				 */
				query.next();
				continue;
			}

			const bool testEnv = usernamesToTesting[username];

			/* Check whether particular tag-message assignment exist in new table. */
			{
				if (Q_UNLIKELY(!subQuery.prepare(subQuerySelectStr))) {
					logErrorNL("Cannot prepare SQL query: %s.",
					    subQuery.lastError().text().toUtf8().constData());
					goto fail;
				}

				subQuery.bindValue(":test_env", testEnv);
				subQuery.bindValue(":message_id", msgId);
				subQuery.bindValue(":tag_id", tagId);

				if (subQuery.exec() && subQuery.isActive() &&
				    subQuery.first() && subQuery.isValid()) {
					if (Q_UNLIKELY(subQuery.value(0).toInt() > 0)) {
						logInfoNL(
						    "Tag id '%" PRId64 "' already assigned to message id '%" PRId64 "'-'%d'.",
						    UGLY_QINT64_CAST tagId,
						    UGLY_QINT64_CAST msgId,
						    testEnv);

						/*
						 * Already associated.
						 * Continue with next entry.
						 */
						query.next();
						continue;
					}
				} else {
					logErrorNL("Cannot execute SQL query and/or read SQL data: %s.",
					    subQuery.lastError().text().toUtf8().constData());
					goto fail;
				}
			}

			/* Insert new tag-message assignment. */
			{
				if (Q_UNLIKELY(!subQuery.prepare(subQueryInsertStr))) {
					logErrorNL("Cannot prepare SQL query: %s.",
					    subQuery.lastError().text().toUtf8().constData());
					goto fail;
				}

				subQuery.bindValue(":id", id);
				subQuery.bindValue(":test_env", testEnv);
				subQuery.bindValue(":message_id", msgId);
				subQuery.bindValue(":tag_id", tagId);

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

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

	/* Delete old table. */
	queryStr = "DROP TABLE _message_tags";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	queryStr = "DROP TABLE _tag";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	return true;

fail:
	return false;
}

bool TagDb::convertAccountIdentifiersToTestEnvFlag(
    const QList<AcntId> &regularAcntIds)
{
	logInfoNL(
	    "Assuring tag_description column in tag table in database '%s'.",
	    fileName().toUtf8().constData());

	QHash<QString, bool> usernamesToTesting;
	for (const AcntId &acntId : regularAcntIds) {
		usernamesToTesting[acntId.username()] = acntId.testing();
	}
	if (Q_UNLIKELY(usernamesToTesting.isEmpty())) {
		logErrorNL("%s",
		    "No account data skipping tag database conversion.");
		return false;
	}

	QMutexLocker locker(&m_lock);

	bool transaction = beginTransaction();
	if (Q_UNLIKELY(!transaction)) {
		logErrorNL("%s", "Cannot begin transaction.");
		goto fail;
	}

	if (Q_UNLIKELY(!ensureTestingColumnInTagTable(m_db, usernamesToTesting))) {
		logErrorNL(
		    "Couldn't add tag_description column in tag table in database '%s'.",
		    fileName().toUtf8().constData());;
		goto fail;
	}

	/* Update tag database content version to 1.0. */
	{
		Json::TagDbInfo info;
		getDbInfo(info);
		info.setFormatVersionMajor(1);
		info.setFormatVersionMinor(0);
		updateDbInfo(info);
	}

	if (transaction) {
		commitTransaction();
	}
	return true;

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

/*!
 * @brief Insert new tag into database file.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in,out] entry Id value is ignored and set to proper value upon
 *                      successful insertion.
 * @return Error code.
 */
static
enum ErrorCode _insertTag(QSqlQuery &query, Json::TagEntry &entry)
{
	if (Q_UNLIKELY(entry.name().isEmpty())) {
		logErrorNL("%s", "Cannot create tag with empty name.");
		return EC_INPUT;
	}
	if (Q_UNLIKELY(!Colour::isValidColourStr(entry.colour()))) {
		logErrorNL("Cannot create tag with invalid colour '%s'.",
		    entry.colour().toUtf8().constData());
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	QString queryStr = "SELECT id FROM tags WHERE tag_name = :tag_name";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":tag_name", entry.name());
	if (query.exec() && query.isActive()) {
		query.first();
		if (Q_UNLIKELY(query.isValid())) {
			/* Already exists. */
			ec = EC_EXISTS;
			goto fail;
		}
	} else {
		/* Cannot execute and EC_DB? */
	}

	/* Insert if not present. */
	queryStr = "INSERT INTO tags (tag_name, tag_color, tag_description) "
	    "VALUES (:tag_name, :tag_color, :tag_description)";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":tag_name", entry.name());
	query.bindValue(":tag_color", nullVariantWhenIsNull(entry.colour()));
	query.bindValue(":tag_description", nullVariantWhenIsNull(entry.description()));
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	/* Read inserted value. */
	queryStr = "SELECT id FROM tags WHERE tag_name = :tag_name";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":tag_name", entry.name());
	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			entry.setId(query.value(0).toLongLong());
		} else {
			ec = EC_NO_DATA;
			goto fail;
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

fail:
	return ec;
}

bool TagDb::insertTags(const Json::TagEntryList &entries)
{
	enum ErrorCode ec = EC_OK;
	bool transaction = false;

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

	Json::TagEntryList listCopy = entries;

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

	for (Json::TagEntry &entry : listCopy) {
		ec = _insertTag(query, entry);
		if (Q_UNLIKELY(ec != EC_OK)) {
			goto fail;
		}
	}

	commitTransaction();

	if (!listCopy.isEmpty()) {
		emit tagsInserted(listCopy);
	}

	return true;

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

/*!
 * @brief Get tag data from database.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     id Tag identifier.
 * @param[out]    entry Entry data.
 * @return Error code.
 */
static
enum ErrorCode _getTagEntry(QSqlQuery &query, qint64 id, Json::TagEntry &entry)
{
	enum ErrorCode ec = EC_OK;

	QString queryStr =
	    "SELECT tag_name, tag_color, tag_description FROM tags WHERE id = :id";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":id", id);
	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			entry = Json::TagEntry(id, query.value(0).toString(),
			    query.value(1).toString(), query.value(2).toString());
		} else {
			ec = EC_NO_DATA;
			goto fail;
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

fail:
	return ec;
}

/*!
 * @brief Update tag in database file.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     entry Id values must be set to proper value.
 * @return Error code.
 */
static
enum ErrorCode _updateTag(QSqlQuery &query, const Json::TagEntry &entry)
{
	if (Q_UNLIKELY(entry.name().isEmpty())) {
		logErrorNL("%s", "Cannot change tag name to empty string.");
		return EC_INPUT;
	}
	if (Q_UNLIKELY(!Colour::isValidColourStr(entry.colour()))) {
		logErrorNL("Cannot change tag colour to invalid value '%s'.",
		    entry.colour().toUtf8().constData());
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	{
		Json::TagEntry dbEntry;
		ec = _getTagEntry(query, entry.id(), dbEntry);
		if (Q_UNLIKELY(ec != EC_OK)) {
			logErrorNL(
			    "Cannot update non-existent tags with id '%" PRId64 "'.",
			    UGLY_QINT64_CAST entry.id());
			return ec;
		}
		if (Q_UNLIKELY(dbEntry == entry)) {
			/* No change. */
			return EC_NO_CHANGE;
		}
	}

	QString queryStr =
	    "UPDATE tags SET tag_name = :tag_name, tag_color = :tag_color, "
	    "tag_description = :tag_description WHERE id = :id";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":tag_name", entry.name());
	query.bindValue(":tag_color", nullVariantWhenIsNull(entry.colour()));
	query.bindValue(":tag_description", nullVariantWhenIsNull(entry.description()));
	query.bindValue(":id", entry.id());
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

fail:
	return ec;
}

bool TagDb::updateTags(const Json::TagEntryList &entries)
{
	enum ErrorCode ec = EC_OK;
	bool transaction = false;

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

	Json::TagEntryList updatedList;

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

	for (const Json::TagEntry &entry : entries) {
		ec = _updateTag(query, entry);
		if (Q_UNLIKELY(ec == EC_NO_CHANGE)) {
			/* Just continue. */
			ec = EC_OK;
			continue;
		} else if (Q_UNLIKELY(ec != EC_OK)) {
			goto fail;
		}

		updatedList.append(entry);
	}

	commitTransaction();

	if (!updatedList.isEmpty()) {
		emit tagsUpdated(updatedList);
	}

	return true;

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

/*!
 * @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 Filter out identifiers not existing in the database.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in,out] ids Tag 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 tags 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.",
		    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.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	ids = macroStdMove(result);

fail:
	return ec;
}

bool TagDb::deleteTags(Json::Int64StringList ids)
{
	QMutexLocker locker(&m_lock);
	QSqlQuery query(m_db);

	enum ErrorCode ec = EC_OK;
	bool transaction = false;
	QString queryStr;
	QString idListing;

	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;
	}

	queryStr = QString("DELETE FROM message_tags WHERE tag_id IN (%1)").arg(idListing);
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

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

	commitTransaction();

	if (!ids.isEmpty()) {
		emit tagsDeleted(ids);
	}

	return true;

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

bool TagDb::getTagData(const Json::Int64StringList &ids,
    Json::TagEntryList &entries) const
{
	enum ErrorCode ec = EC_OK;

	if (Q_UNLIKELY(ids.isEmpty())) {
		entries.clear();
		return false;
	}

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

	Json::TagEntryList readEntries;

	for (qint64 id : ids) {
		Json::TagEntry entry;
		ec = _getTagEntry(query, id, entry);
		if (Q_UNLIKELY(ec != EC_OK)) {
			goto fail;
		}
		readEntries.append(entry);
	}

	entries = macroStdMove(readEntries);
	return true;

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

bool TagDb::getAllTags(Json::TagEntryList &entries) const
{
	QMutexLocker locker(&m_lock);
	QSqlQuery query(m_db);
	Json::TagEntryList readEntries;

	QString queryStr =
	    "SELECT id, tag_name, tag_color, tag_description FROM tags "
	    "ORDER BY tag_name ASC";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	if (query.exec() && query.isActive()) {
		query.first();
		while (query.isValid()) {
			readEntries.append(Json::TagEntry(
			    query.value(0).toLongLong(), query.value(1).toString(),
			    query.value(2).toString(), query.value(3).toString()));
			query.next();
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	entries = macroStdMove(readEntries);
	return true;

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

bool TagDb::getTagAssignmentCounts(const Json::Int64StringList &ids,
    Json::TagIdToAssignmentCountHash &assignmentCounts) const
{
	QString queryStr;
	{
		QString idListing = toListString(ids);
		if (Q_UNLIKELY(idListing.isEmpty())) {
			assignmentCounts.clear();
			return true;
		}

		/* There is no way how to use query.bind() to enter list values. */
		queryStr = QString("SELECT tag_id, COUNT(*) AS entries FROM message_tags "
		    "WHERE tag_id IN (%1) GROUP BY tag_id").arg(idListing);
	}

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

	Json::TagIdToAssignmentCountHash readAssignmentCounts;

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

	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				readAssignmentCounts[query.value(0).toLongLong()] =
				    query.value(1).toLongLong();

				query.next();
			}
		} else {
			/* No data. Do nothing. */
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	assignmentCounts = macroStdMove(readAssignmentCounts);
	return true;

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

bool TagDb::getTagMessages(const Json::Int64StringList &ids,
    Json::TagIdToMsgIdListHash &assignments) const
{
	QString queryStr;
	{
		QString idListing = toListString(ids);
		if (Q_UNLIKELY(idListing.isEmpty())) {
			assignments.clear();
			return true;
		}

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

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

	Json::TagIdToMsgIdListHash readAssignments;

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

	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				readAssignments[query.value(2).toLongLong()].append(
				    Json::TagMsgId(
				        query.value(0).toBool() ? Isds::Type::BOOL_TRUE : Isds::Type::BOOL_FALSE,
				        query.value(1).toLongLong()));

				query.next();
			}
		} else {
			/* No data. Do nothing. */
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	assignments = macroStdMove(readAssignments);
	return true;

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

/*!
 * @brief Get all tags related to given message.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     msgId Message identifier.
 *                      Use BOOL_TRUE for test environment, BOOL_NULL for any.
 * @param[out]    entries List of entries.
 * @return Error code.
 */
static
enum ErrorCode _getMessageTags(QSqlQuery &query, const Json::TagMsgId &msgId,
    Json::TagEntryList &entries)
{
	if (Q_UNLIKELY(msgId.dmId() < 0)) {
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	Json::TagEntryList readEntries;

	QString queryStr =
	    "SELECT t.id, t.tag_name, t.tag_color, t.tag_description "
	    "FROM tags AS t "
	    "LEFT JOIN message_tags AS m "
	    "ON (t.id = m.tag_id) "
	    "WHERE m.message_id  = :msgId "
	    + QString((msgId.testEnv() != Isds::Type::BOOL_NULL) ? "AND m.test_env = :testEnv " : "")
	    + "ORDER BY t.tag_name ASC";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":msgId", msgId.dmId());
	if (msgId.testEnv() != Isds::Type::BOOL_NULL) {
		query.bindValue(":testEnv", (msgId.testEnv() == Isds::Type::BOOL_TRUE));
	}

	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				readEntries.append(Json::TagEntry(query.value(0).toLongLong(),
				    query.value(1).toString(), query.value(2).toString(),
				    query.value(3).toString()));
				query.next();
			}
		} else {
			ec = EC_NO_DATA;
			goto fail;
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	entries = macroStdMove(readEntries);
	return ec;

fail:
	entries.clear();
	return ec;
}

bool TagDb::getMessageTags(const Json::TagMsgIdList &msgIds,
    Json::TagAssignmentHash &assignments) const
{
	enum ErrorCode ec = EC_OK;

	if (Q_UNLIKELY(msgIds.isEmpty())) {
		assignments.clear();
		return false;
	}

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

	Json::TagAssignmentHash readAssignments;

	for (const Json::TagMsgId &key : msgIds) {
		Json::TagEntryList entries;
		ec = _getMessageTags(query, key, entries);
		if (ec == EC_OK) {
			readAssignments[key] = macroStdMove(entries);
		} else if (ec == EC_NO_DATA) {
			/* Return empty list. */
			readAssignments[key] = Json::TagEntryList();
		} else {
			goto fail;
		}
	}

	assignments = macroStdMove(readAssignments);
	return true;

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

/*!
 *@brief Delete all tags for message identifier in message_tags table.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     msgId Message identifier. TestEnv must not be BOOL_NULL.
 * @param[out]    modifiedAssignment New tag assignment.
 * @return Error code.
 */
static
enum ErrorCode _removeAllTagsFromMsg(QSqlQuery &query,
    const Json::TagMsgId &msgId, Json::TagAssignment &modifiedAssignment)
{
	if (Q_UNLIKELY(msgId.testEnv() == Isds::Type::BOOL_NULL)) {
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	QString queryStr = "SELECT COUNT(*) AS nrRecords FROM message_tags WHERE "
	    "test_env = :test_env AND message_id = :message_id";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":test_env", (msgId.testEnv() == Isds::Type::BOOL_TRUE));
	query.bindValue(":message_id", msgId.dmId());
	if (query.exec() && query.isActive() &&
	    query.first() && query.isValid()) {
		if (Q_UNLIKELY(query.value(0).toInt() <= 0)) {
			/* No entry to be deleted found. */
			ec = EC_NO_DATA;
			goto fail;
		}
	} else {
		logErrorNL("Cannot execute SQL query and/or read SQL data: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	queryStr = "DELETE FROM message_tags WHERE "
	    "test_env = :test_env AND message_id = :message_id";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":test_env", (msgId.testEnv() == Isds::Type::BOOL_TRUE));
	query.bindValue(":message_id", msgId.dmId());
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	modifiedAssignment = Json::TagAssignment(
	    msgId.testEnv(), msgId.dmId(), Json::TagEntryList());

	return ec;

fail:
	modifiedAssignment = Json::TagAssignment();
	return ec;
}

bool TagDb::removeAllTagsFromMsgs(const Json::TagMsgIdList &msgIds)
{
	enum ErrorCode ec = EC_OK;
	bool transaction = false;

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

	Json::TagAssignmentList assignmentList;

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

	for (const Json::TagMsgId &msgId : msgIds) {
		Json::TagAssignment modifiedAssignment;
		ec = _removeAllTagsFromMsg(query, msgId, modifiedAssignment);
		if (ec == EC_OK) {
			assignmentList.append(modifiedAssignment);
		} else if (ec == EC_NO_DATA) {
			/* No change. */
		} else {
			goto fail;
		}
	}

	commitTransaction();

	if (!assignmentList.isEmpty()) {
		emit tagAssignmentChanged(assignmentList);
	}

	return true;

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

/*!
 * @brief Get set of tag identifiers from supplied list which are assigned
 *     to given message.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     msgId Message identifier.
 *                      Use BOOL_TRUE for test environment, BOOL_FALSE for production.
 * @param[in]     tagIds List to take the identifiers from.
 * @param[out]    assignedIds Set of identifiers from the list assigned
 *                            to the message, empty set on error.
 * @return Error code.
 */
static
enum ErrorCode _assignedTags(QSqlQuery &query, const Json::TagMsgId &msgId,
    const Json::Int64StringList &tagIds, QSet<qint64> &assignedIds)
{
	if (Q_UNLIKELY(msgId.testEnv() == Isds::Type::BOOL_NULL)) {
		assignedIds.clear();
		return EC_INPUT;
	}

	if (Q_UNLIKELY(msgId.dmId() < 0)) {
		assignedIds.clear();
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	QSet<qint64> result;
	QString queryStr;

	{
		QString idListing = toListString(tagIds);
		if (Q_UNLIKELY(idListing.isEmpty())) {
			assignedIds.clear();
			return EC_DB;
		}

		/* There is no way how to use query.bind() to enter list values. */
		queryStr = QString("SELECT tag_id FROM message_tags WHERE "
		    "test_env = :test_env AND message_id = :message_id "
		    "AND tag_id IN (%1)").arg(idListing);
	}

	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":test_env", (msgId.testEnv() == Isds::Type::BOOL_TRUE));
	query.bindValue(":message_id", msgId.dmId());
	if (query.exec() && query.isActive()) {
		if (query.first() && query.isValid()) {
			while (query.isValid()) {
				result.insert(query.value(0).toLongLong());

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

	assignedIds = macroStdMove(result);
	return ec;

fail:
	assignedIds.clear();
	return ec;
}

/*!
 * @brief Subtract the content of the set from the list.
 *
 * @param[in] fromList List of values from which to subtract.
 * @param[in] whatSet Set of values that should be subtracted from the list.
 * @return List of values remaining in the list.
 */
static
Json::Int64StringList subtractTagIds(const Json::Int64StringList &fromList,
    const QSet<qint64> &whatSet)
{
#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
	QSet<qint64> fromSet(fromList.begin(), fromList.end());
#else /* < Qt-5.14.0 */
	QSet<qint64> fromSet = fromList.toSet();
#endif /* >= Qt-5.14.0 */

	fromSet -= whatSet;

#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
	return QList<qint64>(fromSet.begin(), fromSet.end());
#else /* < Qt-5.14.0 */
	return fromSet.toList();
#endif /* >= Qt-5.14.0 */
}

/*!
 * @brief Assign existing tags to message.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     msgId Message identifier. TestEnv must not be BOOL_NULL.
 * @param[in]     tagIds Tag identifiers to assign to the message.
 * @param[out]    modifiedAssignment New tag assignment.
 * @return Error code.
 */
static
enum ErrorCode _assignTagsToMsg(QSqlQuery &query,
    const Json::TagMsgId &msgId, Json::Int64StringList tagIds,
    Json::TagAssignment &modifiedAssignment)
{
	if (Q_UNLIKELY(msgId.testEnv() == Isds::Type::BOOL_NULL)) {
		return EC_INPUT;
	}

	if (Q_UNLIKELY(msgId.dmId() < 0)) {
		return EC_INPUT;
	}

	if (Q_UNLIKELY(tagIds.empty())) {
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	QString queryStr;
	Json::TagEntryList tagEntries;

	/* Remove tags already assigned to the message. */
	{
		QSet<qint64> assignedTagSet;
		ec = _assignedTags(query, msgId, tagIds, assignedTagSet);
		if (Q_UNLIKELY((ec != EC_OK) && (ec != EC_NO_DATA))) {
			goto fail;
		}

		tagIds = subtractTagIds(tagIds, assignedTagSet);
		if (Q_UNLIKELY(tagIds.empty())) {
			ec = EC_EXISTS;
			goto fail;
		}
	}

	/* Assign new tags. */
	for (qint64 tagId : tagIds) {
		queryStr = "INSERT INTO message_tags (test_env, message_id, tag_id) "
		    "VALUES (:test_env, :message_id, :tag_id)";
		if (Q_UNLIKELY(!query.prepare(queryStr))) {
			logErrorNL("Cannot prepare SQL query: %s.",
			    query.lastError().text().toUtf8().constData());
			ec = EC_DB;
			goto fail;
		}
		query.bindValue(":test_env", (msgId.testEnv() == Isds::Type::BOOL_TRUE));
		query.bindValue(":message_id", msgId.dmId());
		query.bindValue(":tag_id", tagId);
		if (Q_UNLIKELY(!query.exec())) {
			logErrorNL("Cannot execute SQL query: %s.",
			    query.lastError().text().toUtf8().constData());
			ec = EC_DB;
			goto fail;
		}
	}

	ec = _getMessageTags(query, msgId, tagEntries);
	if (Q_UNLIKELY(ec != EC_OK)) {
		goto fail;
	}

	modifiedAssignment = Json::TagAssignment(
	    msgId.testEnv(), msgId.dmId(), macroStdMove(tagEntries));

	return ec;

fail:
	modifiedAssignment = Json::TagAssignment();
	return ec;
}

bool TagDb::assignTagsToMsgs(const Json::TagAssignmentCommand &assignments)
{
	enum ErrorCode ec = EC_OK;
	bool transaction = false;

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

	Json::TagAssignmentList assignmentList;

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

	for (const Json::TagMsgId &msgId : assignments.keys()) {
		Json::TagAssignment modifiedAssignment;
		ec = _assignTagsToMsg(query, msgId, assignments[msgId], modifiedAssignment);
		if (ec == EC_OK) {
			assignmentList.append(modifiedAssignment);
		} else if (ec == EC_EXISTS) {
			/* No change. */
		} else {
			goto fail;
		}
	}

	commitTransaction();

	if (!assignmentList.isEmpty()) {
		emit tagAssignmentChanged(assignmentList);
	}

	return true;

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

/*!
 * @brief Remove tags from message.
 *
 * @note This function doesn't lock the database lock.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     msgId Message identifier. TestEnv must not be BOOL_NULL.
 * @param[in]     tagIds Tag identifiers to remove from the message.
 * @param[out]    modifiedAssignment New tag assignment.
 * @return Error code.
 */
static
enum ErrorCode _removeTagsFromMsg(QSqlQuery &query,
    const Json::TagMsgId &msgId, Json::Int64StringList tagIds,
    Json::TagAssignment &modifiedAssignment)
{
	if (Q_UNLIKELY(msgId.testEnv() == Isds::Type::BOOL_NULL)) {
		return EC_INPUT;
	}

	if (Q_UNLIKELY(msgId.dmId() < 0)) {
		return EC_INPUT;
	}

	if (Q_UNLIKELY(tagIds.empty())) {
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	QString queryStr;
	Json::TagEntryList tagEntries;

	/* Remove tags not assigned to the message. */
	{
		QSet<qint64> assignedTagSet;
		ec = _assignedTags(query, msgId, tagIds, assignedTagSet);
		if (Q_UNLIKELY(ec != EC_OK)) {
			goto fail;
		}

#if (QT_VERSION >= QT_VERSION_CHECK(5, 14, 0))
		tagIds = Json::Int64StringList(assignedTagSet.begin(), assignedTagSet.end());
#else /* < Qt-5.14.0 */
		tagIds = assignedTagSet.toList();
#endif /* >= Qt-5.14.0 */
		if (Q_UNLIKELY(tagIds.empty())) {
			ec = EC_NO_DATA;
			goto fail;
		}
	}

	{
		QString idListing = toListString(tagIds);
		if (Q_UNLIKELY(idListing.isEmpty())) {
			ec = EC_DB;
			goto fail;
		}

		queryStr = QString("DELETE FROM message_tags WHERE "
		    "test_env = :test_env AND message_id = :message_id AND tag_id IN (%1)")
		    .arg(idListing);
	}
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":test_env", (msgId.testEnv() == Isds::Type::BOOL_TRUE));
	query.bindValue(":message_id", msgId.dmId());
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	ec = _getMessageTags(query, msgId, tagEntries);
	if (ec == EC_NO_DATA) {
		/* Can have no data now. */
		ec = EC_OK;
	}
	if (Q_UNLIKELY(ec != EC_OK)) {
		goto fail;
	}

	modifiedAssignment = Json::TagAssignment(
	    msgId.testEnv(), msgId.dmId(), macroStdMove(tagEntries));

	return ec;

fail:
	modifiedAssignment = Json::TagAssignment();
	return ec;
}

bool TagDb::removeTagsFromMsgs(const Json::TagAssignmentCommand &assignments)
{
	enum ErrorCode ec = EC_OK;
	bool transaction = false;

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

	Json::TagAssignmentList assignmentList;

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

	for (const Json::TagMsgId &msgId : assignments.keys()) {
		Json::TagAssignment modifiedAssignment;
		ec = _removeTagsFromMsg(query, msgId, assignments[msgId], modifiedAssignment);
		if (ec == EC_OK) {
			assignmentList.append(modifiedAssignment);
		} else if (ec == EC_NO_DATA) {
			/* No change. */
		} else {
			goto fail;
		}
	}

	commitTransaction();

	if (!assignmentList.isEmpty()) {
		emit tagAssignmentChanged(assignmentList);
	}

	return true;

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

/*!
 * @brief Get message IDs related to tags containing sought text.
 *
 * @param[in,out] query SQL query to work with.
 * @param[in]     request Sought text and match type.
 * @param[out]    ids List of associated message identifiers.
 * @return Error code.
 */
static
enum ErrorCode _getMsgIdsContainSearchTagText(QSqlQuery &query,
    const Json::TagTextSearchRequest &request, Json::Int64StringList &ids)
{
	if (Q_UNLIKELY(request.text().isEmpty())) {
		logErrorNL("%s", "Cannot search for empty string.");
		ids.clear();
		return EC_INPUT;
	}

	enum ErrorCode ec = EC_OK;

	Json::Int64StringList result;

	QString queryStr = "SELECT m.message_id FROM message_tags AS m "
	    "LEFT JOIN tags AS t ON (m.tag_id = t.id) ";
	switch (request.type()) {
	case Json::TagTextSearchRequest::ST_SUBSTRING:
		queryStr += "WHERE t.tag_name LIKE '%'||:text||'%'";
		break;
	case Json::TagTextSearchRequest::ST_FULL:
		queryStr += "WHERE (t.tag_name = :text)";
		break;
	case Json::TagTextSearchRequest::ST_FULL_CASE_INSENSITIVE:
		queryStr += "WHERE (t.tag_name = :text COLLATE NOCASE)";
		break;
	default:
		logErrorNL("Unknown request type %d.", request.type());
		ids.clear();
		return EC_INPUT;
		break;
	}

	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}
	query.bindValue(":text", request.text());
	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.",
		    query.lastError().text().toUtf8().constData());
		ec = EC_DB;
		goto fail;
	}

	ids = macroStdMove(result);
	return ec;

fail:
	ids.clear();
	return ec;
}

bool TagDb::getMsgIdsContainSearchTagText(
    const Json::TagTextSearchRequestList &requests,
    Json::TagTextSearchRequestToIdenfifierHash &responses) const
{
	QMutexLocker locker(&m_lock);
	QSqlQuery query(m_db);

	enum ErrorCode ec = EC_OK;

	Json::TagTextSearchRequestToIdenfifierHash readResponses;

	for (const Json::TagTextSearchRequest &request : requests) {
		Json::Int64StringList ids;
		ec = _getMsgIdsContainSearchTagText(query, request, ids);
		if (ec == EC_OK) {
			readResponses[request] = macroStdMove(ids);
		} else if (ec == EC_NO_DATA) {
			readResponses[request] = Json::Int64StringList();
		} else {
			goto fail;
		}
	}

	responses = macroStdMove(readResponses);
	return true;

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

/*!
 * @brief This method ensures that the foreign key functionality is enabled
 *     at runtime.
 *
 * @return True on success.
 */
static
bool enableForeignKeyFunctionality(const QSqlDatabase &db)
{
	QSqlQuery query(db);
	bool ret = false;

	/* Set value. */
	QString queryStr = "PRAGMA foreign_keys=ON";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (Q_UNLIKELY(!query.exec())) {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	/* Check set value. */
	queryStr = "PRAGMA foreign_keys";
	if (Q_UNLIKELY(!query.prepare(queryStr))) {
		logErrorNL("Cannot prepare SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}
	if (query.exec() && query.isActive()) {
		query.first();
		if (query.isValid()) {
			ret = query.value(0).toBool();
		} else {
			ret = false;
		}
	} else {
		logErrorNL("Cannot execute SQL query: %s.",
		    query.lastError().text().toUtf8().constData());
		goto fail;
	}

	return ret;

fail:
	return false;
}

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

	QMutexLocker locker(&m_lock);

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

QList<class SQLiteTbl *> TagDb::listOfTables(void) const
{
	QList<class SQLiteTbl *> tables;
	tables.append(&_dbInfoTbl);
	tables.append(&tagsTbl);
	tables.append(&msgtagsTbl);
	return tables;
}
