/*
 * 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 <QJsonValue>
#include <QNetworkAccessManager>
#include <QNetworkCookie>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStringBuilder>
#include <QTimer>
#include <QUrl>
#if defined(HAVE_WEBSOCKETS)
#  include <QWebSocket>
#endif /* defined(HAVE_WEBSOCKETS) */

#include "src/datovka_shared/compat_qt/misc.h" /* qsizetype */
#include "src/datovka_shared/json/basic.h"
#include "src/datovka_shared/log/log.h"
#include "src/io/tag_client.h"
#include "src/json/message_id.h"
#include "src/json/srvr_profile.h"
#include "src/json/srvr_req_envelope.h"
#include "src/json/srvr_resp_envelope.h"
#include "src/json/tag_assignment.h"
#include "src/json/tag_assignment_hash.h"
#include "src/json/tag_entry.h"
#include "src/json/tag_text_search_request.h"
#include "src/json/tag_text_search_request_hash.h"

#define SESSION_COOKIE_NAME "SESSION-COOKIE"

#if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0))
#  define TAG_TIMEOUT_MS QNetworkRequest::DefaultTransferTimeoutConstant
#else /* < Qt-5.15 */
#  define TAG_TIMEOUT_MS 30000 /* 30 seconds */
#endif /* >= Qt-5.15 */

#define TIMER_PERIOD_MS 60000 /* 60 seconds */

#define SERVICE_LOGIN "login"
#define SERVICE_LOGOUT "logout"
#define SERVICE_DUMMY "dummy"
#define SERVICE_PROFILE "profile"
#define SERVICE_TAG "tag"
#define SERVICE_WEBSOCKET "ws"

static
bool ignoreSslErrors = false;

static const QString listProfilesOp("ListProfiles");
static const QString listProfilesResponseType("ListProfilesResponse");

static const QString selectProfileOp("SelectProfile");

static const QString insertTagsOp("InsertTags");
static const QString updateTagsOp("UpdateTags");
static const QString deleteTagsOp("DeleteTags");
static const QString getTagDataOp("GetTagData");
static const QString getTagDataResponseType("GetTagDataResponse");
static const QString getAllTagsOp("GetAllTags");
static const QString getAllTagsResponseType("GetAllTagsResponse");
static const QString getTagAssignmentCountsOp("GetTagAssignmentCounts");
static const QString getTagAssignmentCountsResponseType("GetTagAssignmentCountsResponse");
static const QString getMessageTagsOp("GetMessageTags");
static const QString getMessageTagsResponseType("GetMessageTagsResponse");
static const QString removeAllTagsFromMessagesOp("RemoveAllTagsFromMessages");
static const QString assignTagsToMessagesOp("AssignTagsToMessages");
static const QString removeTagsFromMessagesOp("RemoveTagsFromMessages");
static const QString getMessageIdsAssociatedToTagSearchTextOp("GetMessageIdsAssociatedToTagSearchText");
static const QString getMessageIdsAssociatedToTagSearchTextResponseType("GetMessageIdsAssociatedToTagSearchTextResponse");

static const QString sessionCookieIdentificationOp("SessionCookieIdentification");

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief PIMPL tag client class.
 */
class TagClientPrivate {
	//Q_DISABLE_COPY(TagClientPrivate)
public:
	TagClientPrivate(void)
	    : m_baseUrl(), m_webSockUrl(), m_username(), m_pwd(),
	    m_sessionCookie(), m_nam(), m_caCertificate(),
	    m_communicationTimeout(TAG_TIMEOUT_MS),
	    m_webSocket(QString(), QWebSocketProtocol::Version0),
	    m_requestedConnection(false), m_selectedProfileId(-1),
	    m_keepAliveTimer(), m_reconnectTimer(),
	    m_reconnectDelay(TIMER_PERIOD_MS), m_reconnectPeriod(TIMER_PERIOD_MS),
	    m_reconnectPeriodMultiplied(false), m_reconnectPeriodMultipliers()
	{ }

	QUrl m_baseUrl;
	QUrl m_webSockUrl;

	QString m_username;
	QString m_pwd;

	QString m_sessionCookie;

	QNetworkAccessManager m_nam;
	QSslCertificate m_caCertificate;

	qint64 m_communicationTimeout;

	QWebSocket m_webSocket;

	bool m_requestedConnection; /*!< Set to true on manual connection, set to false on manual disconnection. */
	qint64 m_selectedProfileId;
	QTimer m_keepAliveTimer;
	QTimer m_reconnectTimer;

	int m_reconnectDelay;
	int m_reconnectPeriod;
	bool m_reconnectPeriodMultiplied;
	QList<float> m_reconnectPeriodMultipliers;
};
#else /* !defined(HAVE_WEBSOCKETS) */
class TagClientPrivate {
	/* Empty class. */
#  warning "Compiling without WebSocket support. Tag client won't be functional."
};
#endif /* defined(HAVE_WEBSOCKETS) */

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

TagClient::TagClient(void)
    : QObject(),
    d_ptr(Q_NULLPTR)
#if defined(HAVE_WEBSOCKETS)
{
	ensureTagClientPrivate();

	Q_D(TagClient);

	connect(&d->m_nam, SIGNAL(sslErrors(QNetworkReply *, const QList<QSslError>)),
	    this, SLOT(handleSslErrors(QNetworkReply *, const QList<QSslError>)));

	connect(&d->m_webSocket, SIGNAL(connected()),
	    this, SLOT(wsConnected()));
	connect(&d->m_webSocket, SIGNAL(sslErrors(const QList<QSslError>)),
	    this, SLOT(wsSslErrors(const QList<QSslError>)));
	connect(&d->m_webSocket, SIGNAL(disconnected()),
	    this, SLOT(wsDisconnected()));
	connect(&d->m_webSocket, SIGNAL(textMessageReceived(const QString)),
	    this, SLOT(wsTextMessageReceived(const QString)));

	connect(&d->m_keepAliveTimer, SIGNAL(timeout()),
	    this, SLOT(keepAlive()));
	connect(&d->m_reconnectTimer, SIGNAL(timeout()),
	    this, SLOT(reconnect()));

	if (d->m_reconnectDelay > 0) {
		d->m_reconnectTimer.setInterval(d->m_reconnectDelay);
	}
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
}
#endif /* defined(HAVE_WEBSOCKETS) */

TagClient::~TagClient(void)
#if defined(HAVE_WEBSOCKETS)
{
	if (isConnected()) {
		netDisconnect();
	}
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::setBaseUrl(const QUrl &url)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	d->m_baseUrl = url;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(url);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::setWebSockUrl(const QUrl &url)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	d->m_webSockUrl = url;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(url);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

/* https://www.hobrasoft.cz/en/blog/bravenec/httpd-ssl-1 */
void TagClient::setCaCertificate(const QSslCertificate &cert)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	d->m_caCertificate = cert;

	/* Set certificate for websocket if set. */
	QSslConfiguration configuration =
	    QSslConfiguration::defaultConfiguration();
	if (!d->m_caCertificate.isNull()) {
		configuration.setCaCertificates(
		    QList<QSslCertificate>() << d->m_caCertificate);
	}

	d->m_webSocket.setSslConfiguration(configuration);
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(cert);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::setCommunicationTimeout(int msec)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (msec > 0) {
		d->m_communicationTimeout = msec;
	}
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(msec);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::setKeepAlivePeriod(int msec)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (msec > 0) {
		d->m_keepAliveTimer.setInterval(msec);
		if (d->m_webSocket.state() == QAbstractSocket::ConnectedState) {
			d->m_keepAliveTimer.start();
		}
	} else {
		d->m_keepAliveTimer.stop();
		d->m_keepAliveTimer.setInterval(0);
	}
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(msec);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::setReconnectDelay(int msec)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY(msec <= 0)) {
		d->m_reconnectTimer.stop();
	}
	d->m_reconnectDelay = (msec > 0) ? msec : 0;
	d->m_reconnectTimer.setInterval(
	    (d->m_reconnectDelay > 0) ? d->m_reconnectDelay : 0);
	/* Started in wsDisconnected(). */
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(msec);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::setReconnectPeriod(int msec)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	d->m_reconnectPeriod = (msec > 0) ? msec : 0;
	d->m_reconnectTimer.setInterval(
	    (d->m_reconnectDelay > 0) ? d->m_reconnectDelay : 0);
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(msec);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
static const QByteArray minimalJson("{}");
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::netConnectUsernamePwd(const QString &username, const QString &pwd)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(username.isEmpty() || pwd.isEmpty())) {
		return false;
	}

	d->m_username = username;
	d->m_pwd = pwd;

	bool connected = netConnect();
	if (connected) {
		if (d->m_keepAliveTimer.interval() > 0) {
			d->m_keepAliveTimer.start();
		}
		d->m_requestedConnection = true;
		d->m_reconnectTimer.stop();
	}
	return connected;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(username);
	Q_UNUSED(pwd);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::isConnected(void)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_DUMMY,
	    minimalJson, response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_DUMMY);
		return false;
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::netDisconnect(void)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	d->m_requestedConnection = false;
	d->m_keepAliveTimer.stop();
	d->m_reconnectTimer.stop();

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_LOGOUT,
	    minimalJson, response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_LOGOUT);
		return false;
	}

	/* Clear session cookie. */
	d->m_sessionCookie.clear();

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::listProfiles(Json::SrvrProfileList &profiles)
#if defined(HAVE_WEBSOCKETS)
{
#  define RESP_NAME listProfilesResponseType
	Q_D(TagClient);

	profiles.clear();

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(listProfilesOp);

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_PROFILE,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}

		if (Q_UNLIKELY(resp.jsonValueName() != RESP_NAME)) {
			logErrorNL("Unexpected response type '%s', expected '%s'.",
			    resp.jsonValueName().toUtf8().constData(),
			    RESP_NAME.toUtf8().constData());
			return false;
		}
		profiles = Json::SrvrProfileList::fromJsonVal(resp.jsonValue(),
		    &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("%s", "Cannot parse response content.");
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_PROFILE);
		return false;
	}

	return true;
#  undef RESP_NAME
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(profiles);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::selectProfile(qint64 profileId)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(selectProfileOp);
	request.setJsonValue(QJsonValue(QString::number(profileId)));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_PROFILE,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_PROFILE);
		return false;
	}

	d->m_selectedProfileId = profileId; /* Store selected profile ID. */

	emit reset();

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(profileId);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::insertTags(const Json::TagEntryList &entries)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!entries.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(insertTagsOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(entries);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::updateTags(const Json::TagEntryList &entries)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!entries.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(updateTagsOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(entries);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::deleteTags(const Json::Int64StringList &ids)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!ids.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(deleteTagsOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(ids);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::getTagData(const Json::Int64StringList &ids,
    Json::TagEntryList &entries)
#if defined(HAVE_WEBSOCKETS)
{
#  define RESP_NAME getTagDataResponseType
	Q_D(TagClient);

	entries.clear();

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!ids.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(getTagDataOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}

		if (Q_UNLIKELY(resp.jsonValueName() != RESP_NAME)) {
			logErrorNL("Unexpected response type '%s', expected '%s'.",
			    resp.jsonValueName().toUtf8().constData(),
			    RESP_NAME.toUtf8().constData());
			return false;
		}
		entries = Json::TagEntryList::fromJsonVal(resp.jsonValue(),
		    &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("%s", "Cannot parse response content.");
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
#  undef RESP_NAME
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(ids);
	Q_UNUSED(entries);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::getAllTags(Json::TagEntryList &entries)
#if defined(HAVE_WEBSOCKETS)
{
#  define RESP_NAME getAllTagsResponseType
	Q_D(TagClient);

	entries.clear();

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(getAllTagsOp);

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}

		if (Q_UNLIKELY(resp.jsonValueName() != RESP_NAME)) {
			logErrorNL("Unexpected response type '%s', expected '%s'.",
			    resp.jsonValueName().toUtf8().constData(),
			    RESP_NAME.toUtf8().constData());
			return false;
		}
		entries = Json::TagEntryList::fromJsonVal(resp.jsonValue(),
		    &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("%s", "Cannot parse response content.");
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
#  undef RESP_NAME
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(entries);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::getTagAssignmentCounts(const Json::Int64StringList &ids,
    Json::TagIdToAssignmentCountHash &assignmentCounts)
#if defined(HAVE_WEBSOCKETS)
{
#  define RESP_NAME getTagAssignmentCountsResponseType
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!ids.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(getTagAssignmentCountsOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}

		if (Q_UNLIKELY(resp.jsonValueName() != RESP_NAME)) {
			logErrorNL("Unexpected response type '%s', expected '%s'.",
			    resp.jsonValueName().toUtf8().constData(),
			    RESP_NAME.toUtf8().constData());
			return false;
		}
		assignmentCounts = Json::TagIdToAssignmentCountHash::fromJsonVal(
		    resp.jsonValue(), &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("%s", "Cannot parse response content.");
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
#  undef RESP_NAME
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(ids);
	Q_UNUSED(assignmentCounts);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::getMessageTags(const Json::MsgId1List &msgIds,
    Json::TagAssignmentHash &assignments)
#if defined(HAVE_WEBSOCKETS)
{
#  define RESP_NAME getMessageTagsResponseType
	Q_D(TagClient);

	assignments.clear();

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!msgIds.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(getMessageTagsOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}

		if (Q_UNLIKELY(resp.jsonValueName() != RESP_NAME)) {
			logErrorNL("Unexpected response type '%s', expected '%s'.",
			    resp.jsonValueName().toUtf8().constData(),
			    RESP_NAME.toUtf8().constData());
			return false;
		}
		assignments = Json::TagAssignmentHash::fromJsonVal(
		    resp.jsonValue(), &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("%s", "Cannot parse response content.");
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
#  undef RESP_NAME
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(msgIds);
	Q_UNUSED(assignments);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::removeAllTagsFromMsgs(const Json::MsgId1List &msgIds)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!msgIds.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(removeAllTagsFromMessagesOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(msgIds);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::assignTagsToMsgs(
    const Json::TagAssignmentCommand &assignments)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!assignments.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(assignTagsToMessagesOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(assignments);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::removeTagsFromMsgs(
    const Json::TagAssignmentCommand &assignments)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!assignments.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(removeTagsFromMessagesOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(assignments);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::getMsgIdsContainSearchTagText(
    const Json::TagTextSearchRequestList &requests,
    Json::TagTextSearchRequestToIdenfifierHash &responses)
#if defined(HAVE_WEBSOCKETS)
{
#  define RESP_NAME getMessageIdsAssociatedToTagSearchTextResponseType
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (!d->m_baseUrl.isValid()))) {
		return false;
	}

	if (Q_UNLIKELY(d->m_sessionCookie.isEmpty())) {
		return false;
	}

	QJsonValue jsonVal;
	if (Q_UNLIKELY(!requests.toJsonVal(jsonVal))) {
		return false;
	}

	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(getMessageIdsAssociatedToTagSearchTextOp);
	request.setJsonValue(::std::move(jsonVal));

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_TAG,
	    request.toJsonData(), response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}

		if (Q_UNLIKELY(resp.jsonValueName() != RESP_NAME)) {
			logErrorNL("Unexpected response type '%s', expected '%s'.",
			    resp.jsonValueName().toUtf8().constData(),
			    RESP_NAME.toUtf8().constData());
			return false;
		}
		responses = Json::TagTextSearchRequestToIdenfifierHash::fromJsonVal(
		    resp.jsonValue(), &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("%s", "Cannot parse response content.");
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_TAG);
		return false;
	}

	return true;
#  undef RESP_NAME
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(requests);
	Q_UNUSED(responses);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

/*!
 * @brief Build SSL error description string.
 *
 * @param[in] errors List of errors.
 * @return Error message.
 */
static
QString buildSslErrorMessage(const QList<QSslError> &errors)
{
	QString errMsg("Unspecified SSL error.");

	if (!errors.isEmpty()) {
		QStringList errList;
		foreach (const QSslError &error, errors) {
			errList.append(error.errorString());
		}
		errMsg = errList.join(QStringLiteral("; "));
	}

	return errMsg;
}

void TagClient::handleSslErrors(QNetworkReply *reply,
    const QList<QSslError> &errors)
{
	const QString errMsg = buildSslErrorMessage(errors);
	logErrorNL("%s", errMsg.toUtf8().constData());
	emit connectionError(errMsg);

	if (ignoreSslErrors) {
		logWarningNL("%s", "Ignoring obtained SSL errors.");
		emit connectionError(QStringLiteral("Ignoring obtained SSL errors."));

		if (reply != Q_NULLPTR) {
			reply->ignoreSslErrors();
		}
	}
}

void TagClient::wsConnected(void)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (Q_UNLIKELY((d == Q_NULLPTR)|| (d->m_sessionCookie.isEmpty()))) {
		return;
	}

	QWebSocket *webSocket = qobject_cast<QWebSocket *>(QObject::sender());
	if (Q_UNLIKELY(webSocket == Q_NULLPTR)) {
		logErrorNL("%s", "Unexpected signal sender.");
		return;
	}

	/* Send session cookie. */
	Json::SrvrRequestEnvelope request;
	request.setJsonValueName(sessionCookieIdentificationOp);
	request.setJsonValue(d->m_sessionCookie);

	webSocket->sendTextMessage(request.toJsonData());

	emit connected();
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::wsSslErrors(const QList<QSslError> &errors)
#if defined(HAVE_WEBSOCKETS)
{
	const QString errMsg = buildSslErrorMessage(errors);
	logErrorNL("%s", errMsg.toUtf8().constData());
	emit connectionError(errMsg);

	QWebSocket *webSocket = qobject_cast<QWebSocket *>(QObject::sender());
	if (Q_UNLIKELY(webSocket == Q_NULLPTR)) {
		logErrorNL("%s", "Unexpected SSL error sender.");
		return;
	}

	if (ignoreSslErrors) {
		logWarningNL("%s", "Ignoring obtained SSL errors.");
		emit connectionError(QStringLiteral("Ignoring obtained SSL errors."));

		webSocket->ignoreSslErrors();
	}
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(errors);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

void TagClient::wsDisconnected(void)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	/* Clear session cookie. */
	d->m_sessionCookie.clear();

	if (d->m_requestedConnection) {
		if (d->m_reconnectDelay > 0) {
			d->m_reconnectTimer.setInterval(d->m_reconnectDelay);
			d->m_reconnectPeriodMultiplied = false;
			d->m_reconnectPeriodMultipliers = {1.0, 1.0, 1.0, 1.0, 1.0, 10.0};
			d->m_reconnectTimer.start();
		}
	}

	emit disconnected();
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

static const QString resetType("Reset");
static const QString tagsInsertedType("TagsInserted");
static const QString tagsUpdatedType("TagsUpdated");
static const QString tagsDeletedType("TagsDeleted");
static const QString tagAssignmentChangedType("TagAssignmentChanged");

void TagClient::wsTextMessageReceived(const QString &message)
{
	bool ok = false;
	Json::SrvrRequestEnvelope env =
	    Json::SrvrRequestEnvelope::fromJson(message.toUtf8(), &ok);
	if (Q_UNLIKELY(!ok)) {
		logErrorNL("%s", "Cannot parse server websocket response.");
		return;
	}

	if (resetType == env.jsonValueName()) {
		emit reset();
		logDebugLv0NL("Signalling '%s'.",
		    message.toUtf8().constData());
	} else if (tagsInsertedType == env.jsonValueName()) {
		Json::TagEntryList entries =
		    Json::TagEntryList::fromJsonVal(env.jsonValue(), &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL(
			    "Cannot parse server websocket response '%s' content.",
			    tagsInsertedType.toUtf8().constData());
			return;
		}
		if (!entries.isEmpty()) {
			emit tagsInserted(entries);
			logDebugLv0NL("Signalling '%s'.",
			    message.toUtf8().constData());
		}
	} else if (tagsUpdatedType == env.jsonValueName()) {
		Json::TagEntryList entries =
		    Json::TagEntryList::fromJsonVal(env.jsonValue(), &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL(
			    "Cannot parse server websocket response '%s' content.",
			    tagsInsertedType.toUtf8().constData());
			return;
		}
		if (!entries.isEmpty()) {
			emit tagsUpdated(entries);
			logDebugLv0NL("Signalling '%s'.",
			    message.toUtf8().constData());
		}
	} else if (tagsDeletedType == env.jsonValueName()) {
		Json::Int64StringList ids =
		    Json::Int64StringList::fromJsonVal(env.jsonValue(), &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL(
			    "Cannot parse server websocket response '%s' content.",
			    tagsInsertedType.toUtf8().constData());
			return;
		}
		if (!ids.isEmpty()) {
			emit tagsDeleted(ids);
			logDebugLv0NL("Signalling '%s'.",
			    message.toUtf8().constData());
		}
	} else if (tagAssignmentChangedType == env.jsonValueName()) {
		Json::TagAssignmentList assignments =
		    Json::TagAssignmentList::fromJsonVal(env.jsonValue(), &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL(
			    "Cannot parse server websocket response '%s' content.",
			    tagsInsertedType.toUtf8().constData());
			return;
		}
		if (!assignments.isEmpty()) {
			emit tagAssignmentChanged(assignments);
			logDebugLv0NL("Signalling '%s'.",
			    message.toUtf8().constData());
		}
	} else {
		logErrorNL("Unknown server websocket response '%s'.",
		    env.jsonValueName().toUtf8().constData());
	}
}

void TagClient::keepAlive(void)
{
	isConnected();
}

void TagClient::reconnect(void)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	if (d->m_requestedConnection) {
		bool connected = netConnect();
		if (connected) {
			if (d->m_selectedProfileId > 0) {
				/* Use last profile. */
				selectProfile(d->m_selectedProfileId);
			}
			if (d->m_keepAliveTimer.interval() > 0) {
				d->m_keepAliveTimer.start();
			}
			d->m_requestedConnection = true;
			d->m_reconnectTimer.stop();
		} else {
			/* Use reconnection period. */
			if (d->m_reconnectPeriod > 0) {
				if (!d->m_reconnectPeriodMultiplied) {
					/* Set default period. */
					d->m_reconnectTimer.setInterval(d->m_reconnectPeriod);
					d->m_reconnectPeriodMultiplied = (d->m_reconnectPeriodMultipliers.size() > 0);
				}
				/* Have multipliers. */
				if (d->m_reconnectPeriodMultipliers.size() > 0) {
					float factor = d->m_reconnectPeriodMultipliers.takeFirst();
					qint64 period = d->m_reconnectPeriod * factor;
					d->m_reconnectTimer.setInterval(period);
				} else {
					/* Leave period unchanged. */
				}
			} else {
				d->m_reconnectTimer.stop();
			}
		}
	} else {
		d->m_reconnectTimer.stop();
	}
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Create authorisation Base64 token.
 *
 * @param[in] username Username string.
 * @param[in] pwd Password string.
 * @return Authorisation string in Base64.
 */
static
QByteArray authorisationBase64(const QString &username, const QString &password)
{
	return (username % QStringLiteral(":") % password).toUtf8().toBase64();
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Create a new request and fill header fields.
 *
 * @param[in] client Client information.
 * @param[in] endPoint End point name.
 * @param[in] contentLength Content length in bytes.
 * @return Request with filled headers.
 */
static
QNetworkRequest createRequest(const TagClientPrivate &client,
    const QString &endPoint, qsizetype contentLength)
{
	QUrl url(client.m_baseUrl);
	if (!endPoint.isEmpty()) {
		url.setUrl(client.m_baseUrl.toString() % QStringLiteral("/") % endPoint);
	}

	QNetworkRequest request(url);

	/* Set certificate if set. */
	/* https://www.hobrasoft.cz/en/blog/bravenec/httpd-ssl-1 */
	if (!client.m_caCertificate.isNull()) {
		QSslConfiguration configuration =
		    QSslConfiguration::defaultConfiguration();

		configuration.setCaCertificates(
		    QList<QSslCertificate>() << client.m_caCertificate);

		request.setSslConfiguration(configuration);
	}

	/* Set required request headers */
	request.setHeader(QNetworkRequest::UserAgentHeader, QString(APP_NAME));
	request.setHeader(QNetworkRequest::ContentTypeHeader,
	    "application/json; charset=UTF-8");
	request.setHeader(QNetworkRequest::ContentLengthHeader, contentLength);
	request.setRawHeader("Accept", "application/json");
	request.setRawHeader("Host", client.m_baseUrl.host().toUtf8());

	/* Try disabling the cache. */
	request.setRawHeader("Cache-Control", "no-store, no-cache, max-age=0");
	request.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
	    QNetworkRequest::AlwaysNetwork);
	request.setAttribute(QNetworkRequest::CacheSaveControlAttribute,
	    false);

	/* Set cookies. */
	if (!client.m_sessionCookie.isEmpty()) {
		QList<QNetworkCookie> list;
		list.append(QNetworkCookie(SESSION_COOKIE_NAME, client.m_sessionCookie.toUtf8()));
		request.setHeader(QNetworkRequest::CookieHeader,
		    QVariant::fromValue< QList<QNetworkCookie> >(list));
	} else {
		/* Set user credentials to request. */
		if ((!client.m_username.isEmpty()) && (!client.m_pwd.isEmpty())) {
			QByteArray authorization("Basic ");
			authorization.append(
			    authorisationBase64(client.m_username, client.m_pwd));
			request.setRawHeader("Authorization", authorization);
		}
	}

	return request;
}
#endif /* defined(HAVE_WEBSOCKETS) */

/* 1 = request data will be printed to console or log. 0 = Disable
 * Enable only for debugging.
 */
#define PRINT_REQUEST_DATA 0

/* 1 = reply data will be printed to console or log. 0 = Disable
 * Enable only for debugging.
*/
#define PRINT_RESPONSE_DATA 0

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Constructs a header listing.
 *
 * @param[in] request Network request.
 * @return Raw header listing.
 */
static
QByteArray requestHeaderListing(const QNetworkRequest &request)
{
	QByteArray listing;

	foreach (const QByteArray &reqName, request.rawHeaderList()) {
		if (reqName == "Authorization" || reqName == "Cookie") {
			listing += reqName + ": "
			    + QString::number(request.rawHeader(reqName).length()).toUtf8() + "\n";
		} else {
			listing += reqName + ": " + request.rawHeader(reqName) + "\n";
		}
	}

	return listing;
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Logs request header and its data.
 *
 * @param[in] request Request object.
 * @param[in] data Request data.
 */
static
void printRequest(const QNetworkRequest &request, const QByteArray &data)
{
	logDebugLv3NL("\n"
	    "====================REQUEST=========================\n"
	    "URL: %s\n"
	    "%s"
	    "--------------------Content-------------------------\n"
	    "%s\n"
	    "====================================================\n"
	    , request.url().toString().toUtf8().constData(),
	    requestHeaderListing(request).constData(),
	    (PRINT_REQUEST_DATA) ? data.constData() : "hidden"
	);
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Constructs a header listing.
 *
 * @param[in] reply Network reply.
 * @return Raw header listing.
 */
static
QByteArray replyHeaderListing(const QNetworkReply *reply)
{
	if (Q_UNLIKELY(reply == Q_NULLPTR)) {
		Q_ASSERT(0);
		return "\n";
	}

	QByteArray listing;

	foreach (const QNetworkReply::RawHeaderPair &pair, reply->rawHeaderPairs()) {
		listing += pair.first + ": " + pair.second + "\n";
	}

	return listing;
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Logs reply header and its data.
 *
 * @param[in] request Reply object.
 * @param[in] data Reply data.
 */
static
void printReply(const QNetworkReply *reply, const QByteArray &data)
{
	int rCode =
	    reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();

	logDebugLv3NL("\n"
	    "====================REPLY===========================\n"
	    "URL: %s\n"
	    "CODE: %d\n"
	    "ERROR: %s\n"
	    "REASON: %s\n"
	    "--------------------Headers-------------------------\n"
	    "%s"
	    "--------------------Content-------------------------\n"
	    "%s\n"
	    "====================================================\n"
	    , reply->url().toString().toUtf8().constData(), rCode,
	    (reply->error() == QNetworkReply::NoError) ? "" : reply->errorString().toUtf8().constData(),
	    reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString().toUtf8().constData(),
	    replyHeaderListing(reply).constData(),
	    (PRINT_RESPONSE_DATA || 401 == rCode) ? data.constData() : "hidden");
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Send request.
 *
 * @param[in,out] nam Network access manager.
 * @param[in] request Network request.
 * @param[in] data Data to be sent along with the request.
 * @return Null pointer on failure.
 */
static
QNetworkReply *sendRequest(QNetworkAccessManager &nam,
    const QNetworkRequest &request, const QByteArray &data)
{
	printRequest(request, data);

	QNetworkReply *reply = Q_NULLPTR;
	if (data.isEmpty()) {
		reply = nam.get(request);
	} else {
		reply = nam.post(request, data);
	}
	return reply;
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Blocks until all data are sent and received or until timed out.
 *
 * @param[in,out] reply Communication context.
 * @param[in] timeout Timeout in milliseconds;
 * @return Error code.
 */
static
enum TagClient::ErrCode waitReplyFinished(QNetworkReply *reply, qint64 timeout)
{
	if (Q_UNLIKELY(reply == Q_NULLPTR)) {
		Q_ASSERT(0);
		return TagClient::ERR_REPLY;
	}

	/* Set timeout timer */
	QTimer timer;
	QEventLoop eventLoop;
	QObject::connect(&timer, SIGNAL(timeout()), &eventLoop, SLOT(quit()));
	QObject::connect(reply, SIGNAL(finished()), &eventLoop, SLOT(quit()));

	{
		timer.start(timeout);
		if (timer.isActive() && reply->isRunning()) {
			eventLoop.exec();
		}
	}

	timer.disconnect(SIGNAL(timeout()), &eventLoop, SLOT(quit()));
	reply->disconnect(SIGNAL(finished()), &eventLoop, SLOT(quit()));

	if (reply->isFinished()) {
		timer.stop();
	} else {
		logErrorNL("Connection timed out. Check your internet connection. %s",
		    reply->errorString().toUtf8().constData());
		reply->abort();
		return TagClient::ERR_TIMEOUT;
	}

	return TagClient::ERR_NO_ERROR;
}
#endif /* defined(HAVE_WEBSOCKETS) */

#if defined(HAVE_WEBSOCKETS)
/*!
 * @brief Process reply data.
 *
 * @param[in,out] reply Obtained reply.
 * @param[out] replyData Obtained reply data (may be empty).
 * @param[out] redirUrl Redirection URL (may be empty).
 * @param[out] newCookies Cookie list (may be empty).
 * @return Error code.
 */
static
enum TagClient::ErrCode processReply(QNetworkReply *reply,
    QByteArray &replyData, QUrl &redirUrl, QList<QNetworkCookie> &newCookies)
{
	enum TagClient::ErrCode errCode = TagClient::ERR_NO_ERROR;

	if (Q_UNLIKELY(reply == Q_NULLPTR)) {
		Q_ASSERT(0);
		return TagClient::ERR_REPLY;
	}

	/* Read reply data */
	replyData.clear();
	replyData = reply->readAll();

	printReply(reply, replyData);

	/* Response status code */
	int statusCode = reply->attribute(
	    QNetworkRequest::HttpStatusCodeAttribute).toInt();

	switch (statusCode) {
	case 200: /* HTTP 200 OK */
		errCode = TagClient::ERR_NO_ERROR;
		{
			/* Store cookies */
			QVariant variantCookies =
			    reply->header(QNetworkRequest::SetCookieHeader);
			newCookies =
			    qvariant_cast< QList<QNetworkCookie> >(variantCookies);
		}
		break;
	case 301: /* HTTP 301 Moved Permanently - redirect */
	case 302: /* HTTP 302 Found - redirect */
	case 303: /* HTTP 303 See Other - redirect */
	case 307: /* HTTP 307 Temporary Redirect - redirect */
		{
			if (!reply->rawHeader("X-Response-message-code").isEmpty()) {
//				QString errTxt(mepErrorDescription(
//				    reply->rawHeader("X-Response-message-code")));
//				logDebugLv3NL("MEP authorization info: %s",
//				    errTxt.toUtf8().constData());
			}

			/* Store location URL for redirection. */
			QVariant possibleRedirectUrl(reply->attribute(
			    QNetworkRequest::RedirectionTargetAttribute));
			redirUrl = possibleRedirectUrl.toUrl();

			/* Store cookies */
			QVariant variantCookies =
			    reply->header(QNetworkRequest::SetCookieHeader);
			newCookies =
			    qvariant_cast< QList<QNetworkCookie> >(variantCookies);
			errCode = TagClient::ERR_NO_ERROR;
		}
		break;
	case 400: /* HTTP 400 Bad Request */
		{
			logErrorNL("Reply error: %s",
			    reply->errorString().toUtf8().constData());
			errCode = TagClient::ERR_BAD_REQUEST;
		}
		break;
	case 401: /* HTTP 401 Unauthorized */
		{
			/* Test if some OTP error */
//			QString otpType(reply->rawHeader("WWW-Authenticate"));
//			QString errTxt(otpErrorDescription(reply->rawHeader("X-Response-message-code")));
//			if (otpType == "hotp") {
//				logErrorNL("HOTP authorization error: %s", errTxt.toUtf8().constData());
//				errCode = TagClient::ERR_UNAUTHORIZED_HOTP;
//			} else if (otpType == "totp") {
//				logErrorNL("TOTP authorization error: %s", errTxt.toUtf8().constData());
//				errCode = TagClient::ERR_UNAUTHORIZED_TOTP;
//			} else if (otpType == "totpsendsms") {
//				logErrorNL("SMS send error: %s", errTxt.toUtf8().constData());
//				errCode = TagClient::ERR_UNAUTHORIZED_TOTP_SMS;
//			} else {
//				logErrorNL("%s", "Unauthorized: Wrong login credentials.");
//				errCode = TagClient::ERR_UNAUTHORIZED;
//			}
		}
		break;
	case 503: /* HTTP 503 Service Unavailable */
		logErrorNL("%s", "Service Unavailable: Server is out of service. Scheduled maintenance in progress.");
		errCode = TagClient::ERR_SERVER_UNAVAILABLE;
		break;
	default: /* Any other error. */
		logErrorNL("Reply error: %s", reply->errorString().toUtf8().constData());
		errCode = TagClient::ERR_UNSPECIFIED;
		break;
	}

	reply->deleteLater(); reply = Q_NULLPTR;

	return errCode;
}
#endif /* defined(HAVE_WEBSOCKETS) */

bool TagClient::netConnect(void)
#if defined(HAVE_WEBSOCKETS)
{
	Q_D(TagClient);

	QByteArray response;
	enum ErrCode errCode = communicate(SERVICE_LOGIN,
	    minimalJson, response);
	if (Q_UNLIKELY(errCode != ERR_NO_ERROR)) {
		return false;
	}

	if (!response.isEmpty()) {
		bool ok = false;
		Json::SrvrResponseEnvelope resp =
		    Json::SrvrResponseEnvelope::fromJson(response, &ok);
		if (Q_UNLIKELY(!ok)) {
			logErrorNL("Invalid response '%s'.", response.constData());
			return false;
		}
		if (Q_UNLIKELY(!resp.statusOk())) {
			logErrorNL("Received status '%s': '%s'.",
			    resp.statusCode().toUtf8().constData(),
			    resp.statusMessage().toUtf8().constData());
			return false;
		}
	} else {
		logErrorNL(
		    "Communication error when attempting to access '%s' service.",
		    SERVICE_LOGIN);
		return false;
	}

	{
		d->m_webSocket.open(QUrl(d->m_webSockUrl.toString() % QStringLiteral("/") % SERVICE_WEBSOCKET));
	}

	return true;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return false;
}
#endif /* defined(HAVE_WEBSOCKETS) */

enum TagClient::ErrCode TagClient::communicate(const QString &endPoint,
    const QByteArray &requestData, QByteArray &replyData)
#if defined(HAVE_WEBSOCKETS)
{
//	QNetworkAccessManager nam;
	Q_D(TagClient);
	if (Q_UNLIKELY(d == Q_NULLPTR)) {
		Q_ASSERT(0);
		return ERR_UNSPECIFIED;
	}

#  if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0))
	/*
	 * TODO
	 * QNetworkAccessManager::networkAccessible() is obsolete in Qt-5.15.0
	 * and was removed from Qt-6 without replacement.
	 * https://doc.qt.io/qt-5/qnetworkaccessmanager-obsolete.html
	 * Have to look for a replacement or workaround.
	 */
	switch (d->m_nam.networkAccessible()) {
	case QNetworkAccessManager::UnknownAccessibility:
	case QNetworkAccessManager::NotAccessible:
		logErrorNL("%s", "Internet connection is probably not available.");
		return ERR_NO_CONNECTION;
		break;
	default:
		break;
	}
#  endif /* < Qt-6.0 */

	/* Create request and fill headers. */
	QNetworkRequest request(createRequest(*d, endPoint, requestData.length()));

#  if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0))
	request.setTransferTimeout(d->m_communicationTimeout);
#  endif /* >= Qt-5.15 */

	/* Send request. */
	QNetworkReply *reply = sendRequest(d->m_nam, request, requestData);
	if (Q_UNLIKELY(reply == Q_NULLPTR)) {
		logErrorNL("%s", "No reply.");
		return ERR_REPLY;
	}

	/* Wait for reply */
	enum ErrCode errCode = waitReplyFinished(reply, d->m_communicationTimeout);
	if (Q_UNLIKELY(ERR_NO_ERROR != errCode)) {
		return errCode;
	}

	/* Process reply */
	QUrl redirUrl;
	QList<QNetworkCookie> newCookies;
	errCode = processReply(reply, replyData, redirUrl, newCookies);

	/* Store new session cookie if such obtained. */
	for (const QNetworkCookie &cookie : newCookies) {
		if (cookie.name() == SESSION_COOKIE_NAME) {
			d->m_sessionCookie = cookie.value();
		}
	}

	/* TODO -- Reply processed, save cookies and new URL if were sent. */
//	if (!redirUrl.isEmpty()) {
//		session.ctx()->setUrl(macroStdMove(redirUrl));
//	}
//	if (!newCookies.isEmpty()) {
//		session.ctx()->setCookies(macroStdMove(newCookies));
//	}

//	session.ctx()->setLastIsdsMsg(errorDescription(errCode));

	return errCode;
}
#else /* !defined(HAVE_WEBSOCKETS) */
{
	Q_UNUSED(endPoint);
	Q_UNUSED(requestData);
	Q_UNUSED(replyData);
	logWarningNL("%s not implemented.", __PRETTY_FUNCTION__);
	return ERR_UNSPECIFIED;
}
#endif /* defined(HAVE_WEBSOCKETS) */
