// For license of this file, see <project-root-folder>/LICENSE.md.

#include "database/databasequeries.h"

#include "database/sqlquery.h"
#include "definitions/globals.h"
#include "exceptions/sqlexception.h"
#include "miscellaneous/application.h"
#include "miscellaneous/iconfactory.h"
#include "miscellaneous/settings.h"
#include "qtlinq/qtlinq.h"
#include "services/abstract/category.h"

#include <QSqlDriver>
#include <QUrl>
#include <QVariant>

QStringList initMessageTableAttributes() {
  QStringList field_names;

  field_names.append(QSL("Messages.id"));
  field_names.append(QSL("Messages.is_read"));
  field_names.append(QSL("Messages.is_important"));
  field_names.append(QSL("Messages.is_deleted"));
  field_names.append(QSL("Messages.is_pdeleted"));
  field_names.append(QSL("Messages.feed"));
  field_names.append(QSL("Messages.title"));
  field_names.append(QSL("Messages.url"));
  field_names.append(QSL("Messages.author"));
  field_names.append(QSL("Messages.date_created"));
  field_names.append(QSL("Messages.contents"));
  field_names.append(QSL("Messages.enclosures"));
  field_names.append(QSL("Messages.score"));
  field_names.append(QSL("Messages.account_id"));
  field_names.append(QSL("Messages.custom_id"));
  field_names.append(QSL("Messages.custom_data"));
  field_names.append(QSL("("
                         "SELECT GROUP_CONCAT(Labels.custom_id) "
                         "FROM LabelsInMessages lim "
                         "JOIN Labels ON lim.label = Labels.id "
                         "WHERE lim.message = Messages.id"
                         ") AS msg_labels"));

  return field_names;
}

QStringList DatabaseQueries::messageTableAttributes() {
  static QStringList field_names = initMessageTableAttributes();

  return field_names;
}

QString DatabaseQueries::serializeCustomData(const QVariantHash& data) {
  if (!data.isEmpty()) {
    return QString::fromUtf8(QJsonDocument::fromVariant(data).toJson(QJsonDocument::JsonFormat::Indented));
  }
  else {
    return QString();
  }
}

QVariantHash DatabaseQueries::deserializeCustomData(const QString& data) {
  if (data.isEmpty()) {
    return QVariantHash();
  }
  else {
    auto json = QJsonDocument::fromJson(data.toUtf8());

    return json.object().toVariantHash();
  }
}

void DatabaseQueries::storeCategoryCustomData(const QSqlDatabase& db, Category* category) {
  storeCustomData(db, QSL("Categories"), category->id(), category->customDatabaseData());
}

void DatabaseQueries::storeFeedCustomData(const QSqlDatabase& db, Feed* feed) {
  storeCustomData(db, QSL("Feeds"), feed->id(), feed->customDatabaseData());
}

QString DatabaseQueries::whereClauseBin(int account_id) {
  return QSL("Messages.account_id = %1 AND Messages.is_deleted = 1 AND Messages.is_pdeleted = 0").arg(account_id);
}

QString DatabaseQueries::whereClauseImportantArticles(int account_id) {
  return QSL("Messages.account_id = %1 AND "
             "Messages.is_deleted = 0 AND "
             "Messages.is_pdeleted = 0 AND "
             "Messages.is_important = 1")
    .arg(account_id);
}

QString DatabaseQueries::whereClauseUnreadArticles(int account_id) {
  return QSL("Messages.account_id = %1 AND "
             "Messages.is_deleted = 0 AND "
             "Messages.is_pdeleted = 0 AND "
             "Messages.is_read = 0")
    .arg(account_id);
}

QString DatabaseQueries::whereClauseProbe(Search* probe, int account_id) {
  if (probe->type() == Search::Type::Regex) {
    return QSL("Messages.account_id = %1 AND "
               "Messages.is_deleted = 0 AND "
               "Messages.is_pdeleted = 0 AND "
               "(Messages.title REGEXP '%2' OR Messages.contents REGEXP '%2')")
      .arg(QString::number(account_id), DatabaseFactory::escapeQuery(probe->filter()));
  }
  else {
    return QSL("Messages.account_id = %1 AND "
               "Messages.is_deleted = 0 AND "
               "Messages.is_pdeleted = 0 AND "
               "(%2)")
      .arg(QString::number(account_id), probe->filter());
  }
}

QString DatabaseQueries::whereClauseLabel(int label_id, int account_id) {
  return QSL("Messages.account_id = %1 AND "
             "Messages.is_deleted = 0 AND "
             "Messages.is_pdeleted = 0 AND "
             "EXISTS (SELECT 1 "
             "FROM LabelsInMessages "
             "WHERE "
             "  %2 "
             "  LabelsInMessages.message = Messages.id)")
    .arg(QString::number(account_id), label_id > 0 ? QSL("LabelsInMessages.label = %1 AND").arg(label_id) : QString());
}

QString DatabaseQueries::whereClauseLabels(int account_id) {
  return whereClauseLabel(0, account_id);
}

QString DatabaseQueries::whereClauseAccount(bool including_deleted, int account_id) {
  return QSL("Messages.account_id = %1 AND "
             "%2 "
             "Messages.is_pdeleted = 0")
    .arg(QString::number(account_id), including_deleted ? QString() : QSL("Messages.is_deleted = 0 AND"));
}

QString DatabaseQueries::whereClauseFeeds(const QStringList& feed_ids) {
  QString filter_clause;

  switch (feed_ids.size()) {
    case 0:
      filter_clause = QSL("Messages.feed IN (NULL)");
      break;

    case 1:
      filter_clause = QSL("Messages.feed = %1").arg(feed_ids.first());
      break;

    default:
      filter_clause = QSL("Messages.feed IN (%1)").arg(feed_ids.join(QSL(", ")));
      break;
  }

  return QSL("%1 AND "
             "Messages.is_deleted = 0 AND "
             "Messages.is_pdeleted = 0")
    .arg(filter_clause);
}

void DatabaseQueries::deassignLabelFromMessage(const QSqlDatabase& db, Label* label, const Message& msg) {
  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM LabelsInMessages "
                "WHERE "
                "  LabelsInMessages.label = :label AND "
                "  LabelsInMessages.message = :message;"));
  q.bindValue(QSL(":message"), msg.m_id);
  q.bindValue(QSL(":label"), label->id());
  q.exec();
}

void DatabaseQueries::assignLabelToMessage(const QSqlDatabase& db, Label* label, const Message& msg) {
  bool is_sqlite = db.driverName() == QSL(APP_DB_SQLITE_DRIVER);
  SqlQuery q(db);
  QString insert_cmd;

  if (is_sqlite) {
    insert_cmd = QSL("INSERT OR IGNORE");
  }
  else {
    insert_cmd = QSL("INSERT IGNORE");
  }

  q.prepare(QSL("%1 INTO LabelsInMessages (message, label) "
                "VALUES (:message, :label);")
              .arg(insert_cmd));

  q.bindValue(QSL(":message"), msg.m_id);
  q.bindValue(QSL(":label"), label->id());
  q.exec();
}

void DatabaseQueries::setLabelsForMessage(const QSqlDatabase& db, const QList<Label*>& labels, const Message& msg) {
  SqlQuery q(db);

  // Remove everything first.
  q.prepare(QSL("DELETE FROM LabelsInMessages "
                "WHERE LabelsInMessages.message = :message;"));
  q.bindValue(QSL(":message"), msg.m_id);
  q.exec();

  // Insert new label associations (if any).
  if (!labels.isEmpty()) {
    QString sql = QSL("INSERT INTO LabelsInMessages (message, label) VALUES ");
    QStringList values;

    values.reserve(labels.size());

    for (const Label* lbl : labels) {
      values << QSL("(%1, %2)").arg(msg.m_id).arg(lbl->id());
    }

    sql += values.join(QSL(", ")) + QL1C(';');

    q.exec(sql);
  }
}

QList<Label*> DatabaseQueries::getLabelsForAccount(const QSqlDatabase& db, int account_id) {
  QList<Label*> labels;
  SqlQuery q(db);

  q.prepare(QSL("SELECT * FROM Labels WHERE account_id = :account_id;"));
  q.bindValue(QSL(":account_id"), account_id);

  q.exec();

  while (q.next()) {
    Label* lbl =
      new Label(q.value(QSL("name")).toString(), qApp->icons()->fromByteArray(q.value(QSL("icon")).toByteArray()));

    lbl->setId(q.value(QSL("id")).toInt());
    lbl->setCustomId(q.value(QSL("custom_id")).toString());

    labels << lbl;
  }

  return labels;
}

void DatabaseQueries::updateLabel(const QSqlDatabase& db, Label* label) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE Labels "
                "SET name = :name, icon = :icon "
                "WHERE id = :id;"));
  q.bindValue(QSL(":name"), label->title());
  q.bindValue(QSL(":icon"), qApp->icons()->toByteArray(label->icon()));
  q.bindValue(QSL(":id"), label->id());

  q.exec();
}

void DatabaseQueries::deleteLabel(const QSqlDatabase& db, Label* label) {
  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM Labels WHERE id = :id;"));
  q.bindValue(QSL(":id"), label->id());

  q.exec();
}

void DatabaseQueries::createLabel(const QSqlDatabase& db, Label* label, int account_id, int new_label_id) {
  SqlQuery q(db);

  if (new_label_id > 0) {
    q.prepare(QSL("INSERT INTO Labels (id, name, icon, custom_id, account_id) "
                  "VALUES (:id, :name, :icon, :custom_id, :account_id);"));
    q.bindValue(QSL(":id"), new_label_id);
  }
  else {
    q.prepare(QSL("INSERT INTO Labels (name, icon, custom_id, account_id) "
                  "VALUES (:name, :icon, :custom_id, :account_id);"));
  }

  q.bindValue(QSL(":name"), label->title());
  q.bindValue(QSL(":icon"), qApp->icons()->toByteArray(label->icon()));
  q.bindValue(QSL(":custom_id"), label->customId());
  q.bindValue(QSL(":account_id"), account_id);
  q.exec();

  label->setId(q.lastInsertId().toInt());

  // NOTE: This custom ID in this object will be probably
  // overwritten in online-synchronized labels.
  if (label->customId().isEmpty()) {
    label->setCustomId(QString::number(label->id()));
  }

  // Fixup missing custom IDs.
  q.prepare(QSL("UPDATE Labels SET custom_id = id WHERE custom_id IS NULL OR custom_id = '';"));
  q.exec();
}

void DatabaseQueries::updateProbe(const QSqlDatabase& db, Search* probe) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE Probes SET name = :name, type = :type, fltr = :fltr, icon = :icon "
                "WHERE id = :id AND account_id = :account_id;"));
  q.bindValue(QSL(":name"), probe->title());
  q.bindValue(QSL(":type"), int(probe->type()));
  q.bindValue(QSL(":fltr"), probe->filter());
  q.bindValue(QSL(":icon"), IconFactory::toByteArray(probe->icon()));
  q.bindValue(QSL(":id"), probe->id());
  q.bindValue(QSL(":account_id"), probe->account()->accountId());

  q.exec();
}

void DatabaseQueries::createProbe(const QSqlDatabase& db, Search* probe, int account_id) {
  SqlQuery q(db);

  q.prepare(QSL("INSERT INTO Probes (name, type, icon, fltr, account_id) "
                "VALUES (:name, :type, :icon, :fltr, :account_id);"));
  q.bindValue(QSL(":name"), probe->title());
  q.bindValue(QSL(":type"), int(probe->type()));
  q.bindValue(QSL(":fltr"), probe->filter());
  q.bindValue(QSL(":icon"), IconFactory::toByteArray(probe->icon()));
  q.bindValue(QSL(":account_id"), account_id);

  q.exec();

  probe->setId(q.lastInsertId().toInt());
  probe->setCustomId(QString::number(probe->id()));
}

QList<Search*> DatabaseQueries::getProbesForAccount(const QSqlDatabase& db, int account_id) {
  QList<Search*> probes;
  SqlQuery q(db);

  q.prepare(QSL("SELECT * FROM Probes WHERE account_id = :account_id;"));
  q.bindValue(QSL(":account_id"), account_id);
  q.exec();

  while (q.next()) {
    Search* prob = new Search(q.value(QSL("name")).toString(),
                              Search::Type(q.value(QSL("type")).toInt()),
                              q.value(QSL("fltr")).toString(),
                              IconFactory::fromByteArray(q.value(QSL("icon")).toByteArray()));

    prob->setId(q.value(QSL("id")).toInt());
    prob->setCustomId(QString::number(prob->id()));

    probes << prob;
  }

  return probes;
}

void DatabaseQueries::deleteProbe(const QSqlDatabase& db, Search* probe) {
  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM Probes WHERE id = :id AND account_id = :account_id;"));
  q.bindValue(QSL(":id"), probe->id());
  q.bindValue(QSL(":account_id"), probe->account()->accountId());

  q.exec();
}

QStringList DatabaseQueries::customIdsOfMessagesByCondition(const QSqlDatabase& db,
                                                            const QString& where_clause,
                                                            const QVariantMap& bindings) {
  QString sql = QSL("SELECT custom_id FROM Messages WHERE %1;").arg(where_clause);
  SqlQuery q(db);

  q.prepare(sql);

  for (auto it = bindings.constBegin(); it != bindings.constEnd(); ++it) {
    q.bindValue(it.key(), it.value());
  }

  q.exec();

  QStringList ids;

  while (q.next()) {
    ids << q.value(0).toString();
  }

  return ids;
}

void DatabaseQueries::markMessagesByCondition(const QSqlDatabase& db,
                                              const QString& where_clause,
                                              RootItem::ReadStatus read) {
  SqlQuery q(db);

  QString sql = QSL("UPDATE Messages SET is_read = :read WHERE %1;").arg(where_clause);

  q.prepare(sql);
  q.bindValue(QSL(":read"), read == RootItem::ReadStatus::Read ? 1 : 0);
  q.exec();
}

void DatabaseQueries::markProbeReadUnread(const QSqlDatabase& db, Search* probe, RootItem::ReadStatus read) {
  markMessagesByCondition(db, whereClauseProbe(probe, probe->account()->accountId()), read);
}

void DatabaseQueries::markAllLabelledMessagesReadUnread(const QSqlDatabase& db,
                                                        int account_id,
                                                        RootItem::ReadStatus read) {
  markMessagesByCondition(db, whereClauseLabels(account_id), read);
}

void DatabaseQueries::markLabelledMessagesReadUnread(const QSqlDatabase& db, Label* label, RootItem::ReadStatus read) {
  markMessagesByCondition(db, whereClauseLabel(label->id(), label->account()->accountId()), read);
}

void DatabaseQueries::markImportantMessagesReadUnread(const QSqlDatabase& db,
                                                      int account_id,
                                                      RootItem::ReadStatus read) {
  markMessagesByCondition(db, whereClauseImportantArticles(account_id), read);
}

void DatabaseQueries::markUnreadMessagesRead(const QSqlDatabase& db, int account_id) {
  markMessagesByCondition(db, whereClauseUnreadArticles(account_id), RootItem::ReadStatus::Read);
}

void DatabaseQueries::markMessagesReadUnread(const QSqlDatabase& db,
                                             const QStringList& ids,
                                             RootItem::ReadStatus read) {
  markMessagesByCondition(db, QSL("id IN (%1)").arg(ids.join(QSL(", "))), read);
}

void DatabaseQueries::markFeedsReadUnread(const QSqlDatabase& db, const QStringList& ids, RootItem::ReadStatus read) {
  markMessagesByCondition(db, whereClauseFeeds(ids), read);
}

void DatabaseQueries::markBinReadUnread(const QSqlDatabase& db, int account_id, RootItem::ReadStatus read) {
  markMessagesByCondition(db, whereClauseBin(account_id), read);
}

void DatabaseQueries::markAccountReadUnread(const QSqlDatabase& db, int account_id, RootItem::ReadStatus read) {
  markMessagesByCondition(db, whereClauseAccount(true, account_id), read);
}

void DatabaseQueries::markMessageImportant(const QSqlDatabase& db, int id, RootItem::Importance importance) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE Messages SET is_important = :important WHERE id = :id;"));
  q.bindValue(QSL(":id"), id);
  q.bindValue(QSL(":important"), (int)importance);

  q.exec();
}

void DatabaseQueries::switchMessagesImportance(const QSqlDatabase& db, const QStringList& ids) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE Messages SET is_important = NOT is_important WHERE id IN (%1);").arg(ids.join(QSL(", "))));

  q.exec();
}

void DatabaseQueries::permanentlyDeleteMessages(const QSqlDatabase& db, const QStringList& ids) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE Messages SET is_pdeleted = 1 WHERE id IN (%1);").arg(ids.join(QSL(", "))));

  q.exec();
}

void DatabaseQueries::deleteOrRestoreMessagesToFromBin(const QSqlDatabase& db, const QStringList& ids, bool deleted) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE Messages SET is_deleted = %2, is_pdeleted = %3 WHERE id IN (%1);")
              .arg(ids.join(QSL(", ")), QString::number(deleted ? 1 : 0), QString::number(0)));

  q.exec();
}

void DatabaseQueries::restoreBin(const QSqlDatabase& db, int account_id) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE Messages SET is_deleted = 0 WHERE %1;").arg(whereClauseBin(account_id)));

  q.exec();
}

QList<int> DatabaseQueries::removeUnwantedArticlesFromFeed(const QSqlDatabase& db,
                                                           const Feed* feed,
                                                           const Feed::ArticleIgnoreLimit& feed_setup,
                                                           const Feed::ArticleIgnoreLimit& app_setup) {
  // Feed setup has higher preference.
  int amount_to_keep =
    feed_setup.m_customizeLimitting ? feed_setup.m_keepCountOfArticles : app_setup.m_keepCountOfArticles;
  bool dont_remove_unread =
    feed_setup.m_customizeLimitting ? feed_setup.m_doNotRemoveUnread : app_setup.m_doNotRemoveUnread;
  bool dont_remove_starred =
    feed_setup.m_customizeLimitting ? feed_setup.m_doNotRemoveStarred : app_setup.m_doNotRemoveStarred;
  bool recycle_dont_purge =
    feed_setup.m_customizeLimitting ? feed_setup.m_moveToBinDontPurge : app_setup.m_moveToBinDontPurge;

  if (amount_to_keep <= 0) {
    // No articles will be removed, quitting.
    return {};
  }

  SqlQuery q(db);
  QList<int> removed_ids;
  QStringList removed_ids_set;

  // First we get precise set of IDs of articles to be removed.
  if (recycle_dont_purge) {
    q.prepare(QSL("SELECT Messages.id "
                  "FROM Messages "
                  "WHERE "
                  "  Messages.feed = :feed AND "
                  "  Messages.is_deleted = 0 AND "
                  "  Messages.is_pdeleted = 0 AND "
                  "  Messages.is_important != :is_important AND "
                  "  Messages.is_read != :is_read "
                  "ORDER BY Messages.date_created DESC "
                  "LIMIT 2000000000 OFFSET :offset;"));
  }
  else {
    q.prepare(QSL("SELECT Messages.id "
                  "FROM Messages "
                  "WHERE "
                  "  Messages.feed = :feed AND "
                  "  (Messages.is_deleted = 1 OR Messages.is_important != :is_important) AND "
                  "  (Messages.is_deleted = 1 OR Messages.is_read != :is_read) "
                  "ORDER BY Messages.is_deleted ASC, Messages.date_created DESC "
                  "LIMIT 2000000000 OFFSET :offset;"));
  }

  q.bindValue(QSL(":feed"), feed->id());
  q.bindValue(QSL(":is_important"), dont_remove_starred ? 1 : 2);
  q.bindValue(QSL(":is_read"), dont_remove_unread ? 0 : 2);
  q.bindValue(QSL(":offset"), amount_to_keep);

  q.exec();

  while (q.next()) {
    removed_ids.append(q.value(0).toInt());
    removed_ids_set.append(q.value(0).toString());
  }

  q.finish();

  if (removed_ids.isEmpty()) {
    return removed_ids;
  }

  // Then we remove those articles (either purging them or just marking as deleted).
  if (recycle_dont_purge) {
    // We mark all older articles as deleted.
    q.prepare(QSL("UPDATE Messages "
                  "SET is_deleted = 1 "
                  "WHERE Messages.id IN (%1);")
                .arg(removed_ids_set.join(QSL(", "))));
  }
  else {
    // We purge all older articles.
    q.prepare(QSL("DELETE FROM Messages "
                  "WHERE Messages.id IN (%1);")
                .arg(removed_ids_set.join(QSL(", "))));
  }

  q.exec();

  int rows_deleted = q.numRowsAffected();

  qDebugNN << LOGSEC_DB << "Feed cleanup has recycled/purged" << NONQUOTE_W_SPACE(rows_deleted)
           << "old articles from feed" << QUOTE_W_SPACE_DOT(feed->customId());

  return removed_ids;
}

void DatabaseQueries::purgeFeedArticles(const QSqlDatabase& db, const QList<Feed*>& feeds) {
  SqlQuery q(db);

  if (feeds.isEmpty()) {
    return;
  }

  auto feeds_ids = feeds.first()->account()->textualFeedIds(feeds);

  q.exec(QSL("DELETE FROM Messages WHERE feed IN (%1) AND is_important = 0;").arg(feeds_ids.join(QSL(", "))));
}

void DatabaseQueries::purgeMessagesByCondition(const QSqlDatabase& db, const QString& where_clause) {
  SqlQuery q(db);

  QString sql = QSL("DELETE FROM Messages WHERE %1;").arg(where_clause);

  q.prepare(sql);
  q.exec();
}

void DatabaseQueries::purgeMessage(const QSqlDatabase& db, int message_id) {
  purgeMessagesByCondition(db, QSL("id = %1").arg(message_id));
}

void DatabaseQueries::purgeImportantMessages(const QSqlDatabase& db) {
  purgeMessagesByCondition(db, QSL("is_important = 1 AND is_deleted = 0"));
}

void DatabaseQueries::purgeReadMessages(const QSqlDatabase& db) {
  purgeMessagesByCondition(db, QSL("is_important = 0 AND is_deleted = 0 AND is_read = 1"));
}

void DatabaseQueries::purgeOldMessages(const QSqlDatabase& db, int older_than_days) {
  const qint64 since_epoch = older_than_days == 0
                               ? QDateTime::currentDateTimeUtc().addYears(10).toMSecsSinceEpoch()
                               : QDateTime::currentDateTimeUtc().addDays(-older_than_days).toMSecsSinceEpoch();

  purgeMessagesByCondition(db, QSL("is_important = 0 AND date_created < %1").arg(since_epoch));
}

void DatabaseQueries::purgeRecycleBin(const QSqlDatabase& db) {
  purgeMessagesByCondition(db, QSL("is_important = 0 AND is_deleted = 1"));
}

void DatabaseQueries::purgeLeftoverMessages(const QSqlDatabase& db, int account_id) {
  purgeMessagesByCondition(db,
                           QSL("feed NOT IN (SELECT id FROM Feeds WHERE account_id = %1) AND "
                               "account_id = %1")
                             .arg(account_id));
}

QMap<int, ArticleCounts> DatabaseQueries::getMessageCountsForFeeds(const QSqlDatabase& db, const QStringList& ids) {
  auto res = messageCountsByCondition(db, whereClauseFeeds(ids), true);
  return res;
}

QMap<int, ArticleCounts> DatabaseQueries::messageCountsByCondition(const QSqlDatabase& db,
                                                                   const QString& where_clause,
                                                                   bool group_by_feed,
                                                                   const QVariantMap& bindings) {
  SqlQuery q(db);

  q.prepare(QSL("SELECT feed, COUNT(*), SUM(NOT is_read) "
                "FROM Messages "
                "WHERE %1 %2;")
              .arg(where_clause, group_by_feed ? QSL("GROUP BY feed") : QString()));

  for (auto it = bindings.cbegin(); it != bindings.cend(); ++it) {
    q.bindValue(it.key(), it.value());
  }

  q.exec();

  QMap<int, ArticleCounts> res;

  while (q.next()) {
    ArticleCounts res_one;

    res_one.m_total = q.value(1).toInt();
    res_one.m_unread = q.value(2).toInt();

    res.insert(q.value(0).toInt(), res_one);
  }

  return res;
}

ArticleCounts DatabaseQueries::getMessageCountsForFeed(const QSqlDatabase& db, int feed_id) {
  auto res = messageCountsByCondition(db, whereClauseFeeds({QString::number(feed_id)}), false);

  return res.first();
}

ArticleCounts DatabaseQueries::getImportantMessageCounts(const QSqlDatabase& db, int account_id) {
  auto res = messageCountsByCondition(db, whereClauseImportantArticles(account_id), false);

  return res.first();
}

ArticleCounts DatabaseQueries::getUnreadMessageCounts(const QSqlDatabase& db, int account_id) {
  auto res = messageCountsByCondition(db, whereClauseUnreadArticles(account_id), false);

  return res.first();
}

ArticleCounts DatabaseQueries::getMessageCountsForBin(const QSqlDatabase& db, int account_id) {
  auto res = messageCountsByCondition(db, whereClauseBin(account_id), false);

  return res.first();
}

ArticleCounts DatabaseQueries::getMessageCountsForLabel(const QSqlDatabase& db, Label* label, int account_id) {
  auto res = messageCountsByCondition(db, whereClauseLabel(label->id(), account_id), false);

  return res.first();
}

QMap<int, ArticleCounts> DatabaseQueries::getMessageCountsForAllLabels(const QSqlDatabase& db, int account_id) {
  SqlQuery q(db);

  q.prepare(QSL("SELECT lim.label, COUNT(*), SUM(NOT m.is_read) "
                "FROM LabelsInMessages lim "
                "JOIN Messages m "
                "  ON m.id = lim.message "
                "WHERE"
                "  m.account_id = :account_id AND m.is_deleted = 0 AND m.is_pdeleted = 0 "
                "GROUP BY lim.label;"));

  q.bindValue(QSL(":account_id"), account_id);
  q.exec();

  QMap<int, ArticleCounts> results;

  while (q.next()) {
    int label_id = q.value(0).toInt();

    ArticleCounts ac;
    ac.m_total = q.value(1).toInt();
    ac.m_unread = q.value(2).toInt();

    results.insert(label_id, ac);
  }

  return results;
}

QList<Message> DatabaseQueries::getUndeletedMessagesForFeed(const QSqlDatabase& db,
                                                            int feed_id,
                                                            const QHash<QString, Label*>& labels) {
  QList<Message> messages;
  SqlQuery q(db);

  q.prepare(QSL("SELECT %1 "
                "FROM Messages "
                "WHERE %2;")
              .arg(messageTableAttributes().join(QSL(", ")), whereClauseFeeds({QString::number(feed_id)})));
  q.exec();

  while (q.next()) {
    Message message = Message::fromSqlQuery(q, labels);
    messages.append(message);
  }

  return messages;
}

QList<Message> DatabaseQueries::getUndeletedMessagesForAccount(const QSqlDatabase& db,
                                                               int account_id,
                                                               const QHash<QString, Label*>& labels) {
  QList<Message> messages;
  SqlQuery q(db);

  q.prepare(QSL("SELECT %1 "
                "FROM Messages "
                "WHERE %2;")
              .arg(messageTableAttributes().join(QSL(", ")), whereClauseAccount(false, account_id)));
  q.exec();

  while (q.next()) {
    Message message = Message::fromSqlQuery(q, labels);
    messages.append(message);
  }

  return messages;
}

int DatabaseQueries::highestPrimaryIdFeeds(const QSqlDatabase& db) {
  SqlQuery q(db);

  q.exec(QSL("SELECT MAX(id) FROM Feeds;"));
  q.next();

  return q.value(0).isNull() ? 0 : q.value(0).toInt();
}

int DatabaseQueries::highestPrimaryIdLabels(const QSqlDatabase& db) {
  SqlQuery q(db);

  q.exec(QSL("SELECT MAX(id) FROM Labels;"));
  q.next();

  return q.value(0).isNull() ? 0 : q.value(0).toInt();
}

QStringList DatabaseQueries::bagOfMessages(const QSqlDatabase& db, ServiceRoot::BagOfMessages bag, const Feed* feed) {
  QStringList ids;
  SqlQuery q(db);
  QString query;

  switch (bag) {
    case ServiceRoot::BagOfMessages::Unread:
      query = QSL("is_read = 0");
      break;

    case ServiceRoot::BagOfMessages::Starred:
      query = QSL("is_important = 1");
      break;

    case ServiceRoot::BagOfMessages::Read:
    default:
      query = QSL("is_read = 1");
      break;
  }

  q.prepare(QSL("SELECT custom_id "
                "FROM Messages "
                "WHERE feed = :feed AND %1;")
              .arg(query));

  q.bindValue(QSL(":feed"), feed->id());

  q.exec();

  while (q.next()) {
    ids.append(q.value(0).toString());
  }

  return ids;
}

QHash<QString, QStringList> DatabaseQueries::bagsOfMessages(const QSqlDatabase& db, const QList<Label*>& labels) {
  QHash<QString, QStringList> ids;

  for (Label* lbl : labels) {
    QStringList ids_one_label = customIdsOfMessagesFromLabel(db, lbl, RootItem::ReadStatus::Unknown);

    ids.insert(lbl->customId(), ids_one_label);
  }

  return ids;
}

struct UpdateMessageInfo {
    Message m_newMessage;
    bool m_existingMessageImportant;
};

UpdatedArticles DatabaseQueries::updateMessages(QList<Message>& messages,
                                                Feed* feed,
                                                bool force_update,
                                                bool force_insert) {
  if (messages.isEmpty()) {
    return {};
  }

  UpdatedArticles updated_messages;
  int account_id = feed->account()->accountId();
  auto feed_id = feed->id();
  QVector<Message*> msgs_to_insert;
  QVector<UpdateMessageInfo> msgs_to_update;

  if (!force_insert) {
    qApp->database()->worker()->read([&](const QSqlDatabase& db) {
      // Prepare queries.
      SqlQuery query_select_with_url(db);
      SqlQuery query_select_with_custom_id(db);
      SqlQuery query_select_with_custom_id_for_feed(db);
      SqlQuery query_select_with_id(db);

      // Here we have query which will check for existence of the "same" message in given feed.
      // The two message are the "same" if:
      //   1) they belong to the SAME FEED AND,
      //   2) they have same URL AND,
      //   3) they have same AUTHOR AND,
      //   4) they have same TITLE.
      // NOTE: This only applies to messages from standard RSS/ATOM/JSON feeds without ID/GUID.
      query_select_with_url.prepare(QSL("SELECT id, date_created, is_read, is_important, contents, feed FROM Messages "
                                        "WHERE feed = :feed AND title = :title AND url = :url AND author = :author;"));

      // When we have custom ID of the message which is service-specific (synchronized services).
      query_select_with_custom_id
        .prepare(QSL("SELECT id, date_created, is_read, is_important, contents, feed, title, author FROM Messages "
                     "WHERE custom_id = :custom_id AND account_id = :account_id;"));

      // We have custom ID of message, but it is feed-specific not service-specific (standard RSS/ATOM/JSON).
      query_select_with_custom_id_for_feed
        .prepare(QSL("SELECT id, date_created, is_read, is_important, contents, title, author FROM Messages "
                     "WHERE custom_id = :custom_id AND feed = :feed;"));

      // In some case, messages are already stored in the DB and they all have primary DB ID.
      // This is particularly the case when user runs some message filter manually on existing messages
      // of some feed.
      query_select_with_id
        .prepare(QSL("SELECT date_created, is_read, is_important, contents, feed, title, author FROM Messages "
                     "WHERE id = :id;"));

      for (Message& message : messages) {
        int id_existing_message = -1;
        qint64 date_existing_message = 0;
        bool is_read_existing_message = false;
        bool is_important_existing_message = false;
        QString contents_existing_message;
        int feed_id_existing_message;
        QString title_existing_message;
        QString author_existing_message;

        if (message.m_id > 0) {
          // We recognize directly existing message.
          // NOTE: Particularly for manual message filter execution.
          query_select_with_id.bindValue(QSL(":id"), message.m_id);

          qDebugNN << LOGSEC_DB << "Checking if message with primary ID" << QUOTE_W_SPACE(message.m_id)
                   << "is present in DB.";

          if (query_select_with_id.exec(false) && query_select_with_id.next()) {
            id_existing_message = message.m_id;
            date_existing_message = query_select_with_id.value(0).value<qint64>();
            is_read_existing_message = query_select_with_id.value(1).toBool();
            is_important_existing_message = query_select_with_id.value(2).toBool();
            contents_existing_message = query_select_with_id.value(3).toString();
            feed_id_existing_message = query_select_with_id.value(4).toInt();
            title_existing_message = query_select_with_id.value(5).toString();
            author_existing_message = query_select_with_id.value(6).toString();

            qDebugNN << LOGSEC_DB << "Message with direct DB ID is already present in DB and has DB ID"
                     << QUOTE_W_SPACE_DOT(id_existing_message);
          }
          else if (query_select_with_id.lastError().isValid()) {
            qWarningNN << LOGSEC_DB << "Failed to check for existing message in DB via primary ID:"
                       << QUOTE_W_SPACE_DOT(query_select_with_id.lastError().text());
          }

          query_select_with_id.finish();
        }
        else if (message.m_customId.isEmpty()) {
          // We need to recognize existing messages according to URL & AUTHOR & TITLE.
          // NOTE: This concerns articles from RSS/ATOM/JSON which do not
          // provide unique ID/GUID.
          query_select_with_url.bindValue(QSL(":feed"), feed_id);
          query_select_with_url.bindValue(QSL(":title"), unnulifyString(message.m_title));
          query_select_with_url.bindValue(QSL(":url"), unnulifyString(message.m_url));
          query_select_with_url.bindValue(QSL(":author"), unnulifyString(message.m_author));

          qDebugNN << LOGSEC_DB << "Checking if message with title " << QUOTE_NO_SPACE(message.m_title) << ", url"
                   << QUOTE_W_SPACE(message.m_url) << "and author" << QUOTE_W_SPACE(message.m_author)
                   << "is present in DB.";

          if (query_select_with_url.exec(false) && query_select_with_url.next()) {
            id_existing_message = query_select_with_url.value(0).toInt();
            date_existing_message = query_select_with_url.value(1).value<qint64>();
            is_read_existing_message = query_select_with_url.value(2).toBool();
            is_important_existing_message = query_select_with_url.value(3).toBool();
            contents_existing_message = query_select_with_url.value(4).toString();
            feed_id_existing_message = query_select_with_url.value(5).toInt();
            title_existing_message = unnulifyString(message.m_title);
            author_existing_message = unnulifyString(message.m_author);

            qDebugNN << LOGSEC_DB << "Message with these attributes is already present in DB and has DB ID"
                     << QUOTE_W_SPACE_DOT(id_existing_message);
          }
          else if (query_select_with_url.lastError().isValid()) {
            qWarningNN << LOGSEC_DB << "Failed to check for existing message in DB via URL/TITLE/AUTHOR:"
                       << QUOTE_W_SPACE_DOT(query_select_with_url.lastError().text());
          }

          query_select_with_url.finish();
        }
        else {
          // We can recognize existing messages via their custom ID.
          if (feed->account()->isSyncable()) {
            // Custom IDs are service-wide.
            // NOTE: This concerns messages from custom accounts, like TT-RSS or Nextcloud News.
            query_select_with_custom_id.bindValue(QSL(":account_id"), account_id);
            query_select_with_custom_id.bindValue(QSL(":custom_id"), unnulifyString(message.m_customId));

            qDebugNN << LOGSEC_DB << "Checking if message with service-specific custom ID"
                     << QUOTE_W_SPACE(message.m_customId) << "is present in DB.";

            if (query_select_with_custom_id.exec(false) && query_select_with_custom_id.next()) {
              id_existing_message = query_select_with_custom_id.value(0).toInt();
              date_existing_message = query_select_with_custom_id.value(1).value<qint64>();
              is_read_existing_message = query_select_with_custom_id.value(2).toBool();
              is_important_existing_message = query_select_with_custom_id.value(3).toBool();
              contents_existing_message = query_select_with_custom_id.value(4).toString();
              feed_id_existing_message = query_select_with_custom_id.value(5).toInt();
              title_existing_message = query_select_with_custom_id.value(6).toString();
              author_existing_message = query_select_with_custom_id.value(7).toString();

              qDebugNN << LOGSEC_DB << "Message with custom ID" << QUOTE_W_SPACE(message.m_customId)
                       << "is already present in DB and has DB ID '" << id_existing_message << "'.";
            }
            else if (query_select_with_custom_id.lastError().isValid()) {
              qWarningNN << LOGSEC_DB << "Failed to check for existing message in DB via ID:"
                         << QUOTE_W_SPACE_DOT(query_select_with_custom_id.lastError().text());
            }

            query_select_with_custom_id.finish();
          }
          else {
            // Custom IDs are feed-specific.
            // NOTE: This concerns articles with ID/GUID from standard RSS/ATOM/JSON feeds.
            query_select_with_custom_id_for_feed.bindValue(QSL(":feed"), feed_id);
            query_select_with_custom_id_for_feed.bindValue(QSL(":custom_id"), unnulifyString(message.m_customId));

            qDebugNN << LOGSEC_DB << "Checking if message with feed-specific custom ID"
                     << QUOTE_W_SPACE(message.m_customId) << "is present in DB.";

            if (query_select_with_custom_id_for_feed.exec(false) && query_select_with_custom_id_for_feed.next()) {
              id_existing_message = query_select_with_custom_id_for_feed.value(0).toInt();
              date_existing_message = query_select_with_custom_id_for_feed.value(1).value<qint64>();
              is_read_existing_message = query_select_with_custom_id_for_feed.value(2).toBool();
              is_important_existing_message = query_select_with_custom_id_for_feed.value(3).toBool();
              contents_existing_message = query_select_with_custom_id_for_feed.value(4).toString();
              feed_id_existing_message = feed_id;
              title_existing_message = query_select_with_custom_id_for_feed.value(5).toString();
              author_existing_message = query_select_with_custom_id_for_feed.value(6).toString();

              qDebugNN << LOGSEC_DB << "Message with custom ID" << QUOTE_W_SPACE(message.m_customId)
                       << "is already present in DB and has DB ID" << QUOTE_W_SPACE_DOT(id_existing_message);
            }
            else if (query_select_with_custom_id_for_feed.lastError().isValid()) {
              qWarningNN << LOGSEC_DB << "Failed to check for existing message in DB via ID:"
                         << QUOTE_W_SPACE_DOT(query_select_with_custom_id_for_feed.lastError().text());
            }

            query_select_with_custom_id_for_feed.finish();
          }
        }

        // Now, check if this message is already in the DB.
        if (id_existing_message >= 0) {
          message.m_id = id_existing_message;

          // Message is already in the DB.
          //
          // Now, we update it if at least one of next conditions is true:
          //   1) FOR SYNCHRONIZED SERVICES:
          //        Message has custom ID AND (its date OR read status OR starred status are changed
          //        or message was moved from other feed to current feed - this can particularly happen in Gmail feeds).
          //
          //   2) FOR NON-SYNCHRONIZED SERVICES (RSS/ATOM/JSON):
          //        Message has custom ID/GUID and its title or author or contents are changed.
          //
          //   3) FOR ALL SERVICES:
          //        Message has its date fetched from feed AND its date is different
          //        from date in DB or content is changed. Date/time is considered different
          //        when the difference is larger than MSG_DATETIME_DIFF_THRESSHOLD
          //
          //   4) FOR ALL SERVICES:
          //        Message update is forced, we want to overwrite message as some arbitrary atribute was changed,
          //        this particularly happens when manual message filter execution happens.
          bool ignore_contents_changes =
            qApp->settings()->value(GROUP(Messages), SETTING(Messages::IgnoreContentsChanges)).toBool();
          bool cond_1 =
            !message.m_customId.isEmpty() && feed->account()->isSyncable() &&
            (message.m_created.toMSecsSinceEpoch() != date_existing_message ||
             message.m_isRead != is_read_existing_message || message.m_isImportant != is_important_existing_message ||
             (message.m_feedId != feed_id_existing_message && message.m_feedId == feed_id) ||
             message.m_title != title_existing_message ||
             (!ignore_contents_changes && message.m_contents != contents_existing_message));
          bool cond_2 = !message.m_customId.isEmpty() && !feed->account()->isSyncable() &&
                        (message.m_title != title_existing_message || message.m_author != author_existing_message ||
                         (!ignore_contents_changes && message.m_contents != contents_existing_message));
          bool cond_3 = (message.m_createdFromFeed && std::abs(message.m_created.toMSecsSinceEpoch() -
                                                               date_existing_message) > MSG_DATETIME_DIFF_THRESSHOLD) ||
                        (!ignore_contents_changes && message.m_contents != contents_existing_message);

          if (cond_1 || cond_2 || cond_3 || force_update) {
            if (!feed->account()->isSyncable() &&
                !qApp->settings()->value(GROUP(Messages), SETTING(Messages::MarkUnreadOnUpdated)).toBool()) {
              // Feed is not syncable, thus we got RSS/JSON/whatever.
              // Article is only updated, so we now prefer to keep original read state
              // pretty much the same way starred state is kept.
              message.m_isRead = is_read_existing_message;
            }

            // Message exists and is changed, update it.
            UpdateMessageInfo x;

            x.m_existingMessageImportant = is_important_existing_message;
            x.m_newMessage = message;

            msgs_to_update.append(x);
          }
        }
        else {
          msgs_to_insert.append(&message);
        }
      }
    });
  }
  else {
    // We insert everything no matter what.
    for (Message& message : messages) {
      msgs_to_insert.append(&message);
    }
  }

  if (!msgs_to_update.isEmpty()) {
    qApp->database()->worker()->write([&](const QSqlDatabase& db) {
      SqlQuery query_update(db);

      // Used to update existing messages.
      query_update
        .prepare(QSL("UPDATE Messages "
                     "SET title = :title, is_read = :is_read, is_important = :is_important, is_deleted = "
                     ":is_deleted, url = :url, author = :author, score = :score, date_created = :date_created, "
                     "contents = :contents, enclosures = :enclosures, feed = :feed "
                     "WHERE id = :id;"));

      for (int i = 0; i < msgs_to_update.size(); i++) {
        auto message_update_info = msgs_to_update[i];
        auto message_update = message_update_info.m_newMessage;

        query_update.bindValue(QSL(":title"), unnulifyString(message_update.m_title));
        query_update.bindValue(QSL(":is_read"), int(message_update.m_isRead));
        query_update.bindValue(QSL(":is_important"),
                               (feed->account()->isSyncable() || message_update.m_isImportant)
                                 ? int(message_update.m_isImportant)
                                 : message_update_info.m_existingMessageImportant);
        query_update.bindValue(QSL(":is_deleted"), int(message_update.m_isDeleted));
        query_update.bindValue(QSL(":url"), unnulifyString(message_update.m_url));
        query_update.bindValue(QSL(":author"), unnulifyString(message_update.m_author));
        query_update.bindValue(QSL(":date_created"), message_update.m_created.toMSecsSinceEpoch());
        query_update.bindValue(QSL(":contents"), unnulifyString(message_update.m_contents));
        query_update.bindValue(QSL(":enclosures"), Enclosures::encodeEnclosuresToString(message_update.m_enclosures));
        query_update.bindValue(QSL(":feed"), message_update.m_feedId);
        query_update.bindValue(QSL(":score"), message_update.m_score);
        query_update.bindValue(QSL(":id"), message_update.m_id);

        if (query_update.exec(false)) {
          qDebugNN << LOGSEC_DB << "Overwriting message with title" << QUOTE_W_SPACE(message_update.m_title) << "URL"
                   << QUOTE_W_SPACE(message_update.m_url) << "in DB.";

          if (!message_update.m_isRead) {
            updated_messages.m_unread.append(message_update);
          }

          updated_messages.m_all.append(message_update);
          message_update.m_insertedUpdated = true;
        }
        else if (query_update.lastError().isValid()) {
          qCriticalNN << LOGSEC_DB
                      << "Failed to update message in DB:" << QUOTE_W_SPACE_DOT(query_update.lastError().text());
        }

        query_update.finish();
      }
    });
  }

  if (!msgs_to_insert.isEmpty()) {
    // QMutexLocker lck(db_mutex);

    /*if (!db.transaction()) {
      qFatal("transaction failed");
    }*/

    QString bulk_insert = QSL("INSERT INTO Messages "
                              "(feed, title, is_read, is_important, is_deleted, url, author, score, date_created, "
                              "contents, enclosures, custom_id, custom_data, account_id) "
                              "VALUES %1;");

    qApp->database()->worker()->write([&](const QSqlDatabase& db) {
      for (int i = 0; i < msgs_to_insert.size(); i += 1000) {
        QStringList vals;
        int batch_length = std::min(1000, int(msgs_to_insert.size()) - i);

        for (int l = i; l < (i + batch_length); l++) {
          Message* msg = msgs_to_insert[l];

          if (msg->m_title.isEmpty()) {
            qCriticalNN << LOGSEC_DB << "Message" << QUOTE_W_SPACE(msg->m_customId)
                        << "will not be inserted to DB because it does not meet DB constraints.";

            continue;
          }

          vals.append(QSL("\n(:feed, ':title', :is_read, :is_important, :is_deleted, "
                          "':url', ':author', :score, :date_created, ':contents', ':enclosures', "
                          "':custom_id', ':custom_data', :account_id)")
                        .replace(QSL(":feed"), QString::number(feed_id))
                        .replace(QSL(":title"), DatabaseFactory::escapeQuery(unnulifyString(msg->m_title)))
                        .replace(QSL(":is_read"), QString::number(int(msg->m_isRead)))
                        .replace(QSL(":is_important"), QString::number(int(msg->m_isImportant)))
                        .replace(QSL(":is_deleted"), QString::number(int(msg->m_isDeleted)))
                        .replace(QSL(":url"), DatabaseFactory::escapeQuery(unnulifyString(msg->m_url)))
                        .replace(QSL(":author"), DatabaseFactory::escapeQuery(unnulifyString(msg->m_author)))
                        .replace(QSL(":date_created"), QString::number(msg->m_created.toMSecsSinceEpoch()))
                        .replace(QSL(":contents"), DatabaseFactory::escapeQuery(unnulifyString(msg->m_contents)))
                        .replace(QSL(":enclosures"),
                                 DatabaseFactory::escapeQuery(Enclosures::encodeEnclosuresToString(msg->m_enclosures)))
                        .replace(QSL(":custom_id"), DatabaseFactory::escapeQuery(unnulifyString(msg->m_customId)))
                        .replace(QSL(":custom_data"), unnulifyString(msg->m_customData))
                        .replace(QSL(":score"), QString::number(msg->m_score))
                        .replace(QSL(":account_id"), QString::number(account_id)));
        }

        if (!vals.isEmpty()) {
          QString final_bulk = bulk_insert.arg(vals.join(QSL(", ")));
          SqlQuery bulk_query(db);

          bulk_query.exec(final_bulk, false);

          auto bulk_error = bulk_query.lastError();

          if (bulk_error.isValid()) {
            QString txt = bulk_error.text();

            // IOFactory::writeFile("a.sql", final_bulk.toUtf8());

            qCriticalNN << LOGSEC_DB << "Failed bulk insert of articles:" << QUOTE_W_SPACE_DOT(txt);
          }
          else {
            // OK, we bulk-inserted many messages but the thing is that they do not
            // have their DB IDs fetched in objects, therefore labels cannot be assigned etc.
            //
            // We can calculate real IDs because of how "auto-increment" algorithms work.
            //   https://www.sqlite.org/autoinc.html
            //   https://mariadb.com/kb/en/auto_increment
            int last_msg_id = bulk_query.lastInsertId().toInt();

            for (int l = i, c = 1; l < (i + batch_length); l++, c++) {
              Message* msg = msgs_to_insert[l];

              if (msg->m_title.isEmpty()) {
                // This article was not for sure inserted. Tweak
                // next ID calculation.
                c--;
                continue;
              }

              msg->m_insertedUpdated = true;
              msg->m_id = last_msg_id - batch_length + c;

              if (!msg->m_isRead) {
                updated_messages.m_unread.append(*msg);
              }

              updated_messages.m_all.append(*msg);
            }
          }

          bulk_query.finish();
        }
      }
    });

    /*
    if (!db.commit()) {
      qFatal("transaction failed");
    }*/
  }

  const bool uses_online_labels =
    Globals::hasFlag(feed->account()->supportedLabelOperations(), ServiceRoot::LabelOperation::Synchronised);

  for (Message& message : messages) {
    if (!message.m_customId.isEmpty() || message.m_id > 0) {
      // bool lbls_changed = false;

      if (uses_online_labels) {
        // Store all labels obtained from server.
        qApp->database()->worker()->write([&](const QSqlDatabase& db) {
          setLabelsForMessage(db, message.m_assignedLabels, message);
        });
        // lbls_changed = true;
      }

      // Adjust labels tweaked by filters.
      for (Label* assigned_by_filter : message.m_assignedLabelsByFilter) {
        assigned_by_filter->assignToMessage(message, false);
        // lbls_changed = true;
      }

      for (Label* removed_by_filter : message.m_deassignedLabelsByFilter) {
        removed_by_filter->deassignFromMessage(message, false);
        // lbls_changed = true;
      }

      // NOTE: This is likely not needed at all
      // as this feature was semi-broken anyway, it was trigerred
      // even when labels were added (via article filtering or on service server)
      // and the same labels were already assigned in local DB.
      // In other words, label assignment differences were not correctly
      // taken into account.
      /*
      if (lbls_changed && !message.m_insertedUpdated) {
        // This article was not inserted/updated in DB because its contents did not change
        // but its assigned labels were changed. Therefore we must count article
        // as updated.
        if (!message.m_isRead) {
          updated_messages.m_unread.append(message);
        }

        updated_messages.m_all.append(message);
      }
      */
    }
    else {
      qWarningNN << LOGSEC_DB << "Cannot set labels for message" << QUOTE_W_SPACE(message.m_title)
                 << "because we don't have ID or custom ID.";
    }
  }

  qApp->database()->worker()->write([&](const QSqlDatabase& db) {
    SqlQuery fixup_custom_ids_query(db);

    fixup_custom_ids_query.exec(QSL("UPDATE Messages "
                                    "SET custom_id = id "
                                    "WHERE custom_id IS NULL OR custom_id = '';"),
                                false);

    QSqlError fixup_custom_ids_error = fixup_custom_ids_query.lastError();

    if (fixup_custom_ids_error.isValid()) {
      qCriticalNN << LOGSEC_DB
                  << "Failed to set custom ID for all messages:" << QUOTE_W_SPACE_DOT(fixup_custom_ids_error.text());
    }

    fixup_custom_ids_query.finish();
  });

  return updated_messages;
}

void DatabaseQueries::cleanBin(const QSqlDatabase& db, bool clear_only_read, int account_id) {
  SqlQuery q(db);

  if (clear_only_read) {
    q.prepare(QSL("UPDATE Messages SET is_pdeleted = 1 WHERE %1 AND is_read = 1;").arg(whereClauseBin(account_id)));
  }
  else {
    q.prepare(QSL("UPDATE Messages SET is_pdeleted = 1 WHERE %1;").arg(whereClauseBin(account_id)));
  }

  q.exec();
}

void DatabaseQueries::deleteAccount(const QSqlDatabase& db, ServiceRoot* account) {
  moveItem(account, false, true, {}, db);

  SqlQuery q(db);

  QStringList queries;

  // NOTE: We manually remove only from Accounts table, everything else just cascades.
  queries << QSL("DELETE FROM Accounts WHERE id = :account_id;");

  for (const QString& q_str : std::as_const(queries)) {
    q.prepare(q_str);
    q.bindValue(QSL(":account_id"), account->accountId());

    q.exec();
    q.finish();
  }
}

void DatabaseQueries::deleteAccountData(const QSqlDatabase& db,
                                        int account_id,
                                        bool delete_messages_too,
                                        bool delete_labels_too) {
  SqlQuery q(db);

  if (delete_messages_too) {
    q.prepare(QSL("DELETE FROM Messages WHERE account_id = :account_id;"));
    q.bindValue(QSL(":account_id"), account_id);

    q.exec();
  }

  q.prepare(QSL("DELETE FROM Feeds WHERE account_id = :account_id;"));
  q.bindValue(QSL(":account_id"), account_id);

  q.exec();

  q.prepare(QSL("DELETE FROM Categories WHERE account_id = :account_id;"));
  q.bindValue(QSL(":account_id"), account_id);

  q.exec();

  if (delete_labels_too) {
    q.prepare(QSL("DELETE FROM Labels WHERE account_id = :account_id;"));
    q.bindValue(QSL(":account_id"), account_id);

    q.exec();
  }
}

void DatabaseQueries::cleanMessagesByCondition(const QSqlDatabase& db,
                                               const QString& where_clause,
                                               bool clean_read_only) {
  SqlQuery q(db);

  QString sql = QSL("UPDATE Messages SET is_deleted = 1 WHERE (%1)").arg(where_clause);

  if (clean_read_only) {
    sql += QSL(" AND is_read = 1");
  }

  sql += QL1C(';');

  q.prepare(sql);
  q.exec();
}

void DatabaseQueries::cleanImportantMessages(const QSqlDatabase& db, bool clean_read_only, int account_id) {
  cleanMessagesByCondition(db, whereClauseImportantArticles(account_id), clean_read_only);
}

void DatabaseQueries::cleanUnreadMessages(const QSqlDatabase& db, int account_id) {
  cleanMessagesByCondition(db, whereClauseUnreadArticles(account_id), false);
}

void DatabaseQueries::cleanFeeds(const QSqlDatabase& db, const QStringList& feed_ids, bool clean_read_only) {
  if (feed_ids.isEmpty()) {
    return;
  }

  cleanMessagesByCondition(db, whereClauseFeeds(feed_ids), clean_read_only);
}

void DatabaseQueries::cleanLabelledMessages(const QSqlDatabase& db, Label* label, bool clean_read_only) {
  cleanMessagesByCondition(db, whereClauseLabel(label->id(), label->account()->accountId()), clean_read_only);
}

void DatabaseQueries::cleanProbedMessages(const QSqlDatabase& db, Search* probe, bool clean_read_only) {
  cleanMessagesByCondition(db, whereClauseProbe(probe, probe->account()->accountId()), clean_read_only);
}

void DatabaseQueries::purgeLeftoverMessageFilterAssignments(const QSqlDatabase& db) {
  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM MessageFiltersInFeeds "
                "WHERE feed NOT IN (SELECT id FROM Feeds);"));
  q.exec();
}

void DatabaseQueries::purgeLeftoverLabelAssignments(const QSqlDatabase& db) {
  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM LabelsInMessages "
                "WHERE "
                "  label NOT IN (SELECT id FROM Labels) OR "
                "  message NOT IN (SELECT id FROM Messages);"));
  q.exec();
}

void DatabaseQueries::storeAccountTree(const QSqlDatabase& db,
                                       RootItem* tree_root,
                                       int next_feed_id,
                                       int next_label_id,
                                       int account_id) {
  // Iterate all children.
  auto str = tree_root->getSubTree<RootItem>();

  for (RootItem* child : std::as_const(str)) {
    if (child->kind() == RootItem::Kind::Category) {
      createOverwriteCategory(db, child->toCategory(), account_id, child->parent()->id());
    }
    else if (child->kind() == RootItem::Kind::Feed) {
      int new_feed_id = 0;

      if (child->id() > 0) {
        // NOTE: This item has specified physical database ID, we re-use it.
        new_feed_id = child->id();
        child->setId(0);
      }
      else {
        // We generate new non-colision ID.
        new_feed_id = next_feed_id++;
      }

      createOverwriteFeed(db, child->toFeed(), account_id, child->parent()->id(), new_feed_id);
    }
    else if (child->kind() == RootItem::Kind::Labels) {
      // Add all labels.
      auto ch = child->childItems();

      for (RootItem* lbl : std::as_const(ch)) {
        Label* label = lbl->toLabel();

        int new_lbl_id = 0;

        if (lbl->id() > 0) {
          // NOTE: This item has specified physical database ID, we re-use it.
          new_lbl_id = lbl->id();
          lbl->setId(0);
        }
        else {
          // We generate new non-colision ID.
          new_lbl_id = next_label_id++;
        }

        createLabel(db, label, account_id, new_lbl_id);
      }
    }
  }
}

QStringList DatabaseQueries::customIdsOfMessagesFromLabels(const QSqlDatabase& db,
                                                           RootItem::ReadStatus target_read,
                                                           int account_id) {
  QString cond = whereClauseLabel(0, account_id);
  QMap<QString, QVariant> bindings;

  if (target_read != RootItem::ReadStatus::Unknown) {
    cond += QSL(" AND Messages.is_read = :read");
    bindings[QSL(":read")] = target_read == RootItem::ReadStatus::Read ? 0 : 1;
  }

  return customIdsOfMessagesByCondition(db, cond, bindings);
}

QStringList DatabaseQueries::customIdsOfMessagesFromLabel(const QSqlDatabase& db,
                                                          Label* label,
                                                          RootItem::ReadStatus read) {
  QString cond = whereClauseLabel(label->id(), label->account()->accountId());
  QMap<QString, QVariant> bindings;

  if (read != RootItem::ReadStatus::Unknown) {
    cond += QSL(" AND Messages.is_read = :read");
    bindings[QSL(":read")] = read == RootItem::ReadStatus::Read ? 0 : 1;
  }

  return customIdsOfMessagesByCondition(db, cond, bindings);
}

QStringList DatabaseQueries::customIdsOfMessagesFromFeeds(const QSqlDatabase& db,
                                                          const QStringList& ids,
                                                          RootItem::ReadStatus read) {
  QString cond = QSL("%1 AND Messages.is_read = :read").arg(whereClauseFeeds(ids));

  return customIdsOfMessagesByCondition(db, cond, {{QSL(":read"), read == RootItem::ReadStatus::Read ? 0 : 1}});
}

QStringList DatabaseQueries::customIdsOfMessagesFromAccount(const QSqlDatabase& db,
                                                            RootItem::ReadStatus read,
                                                            int account_id) {
  QString cond = QSL("%1 AND Messages.is_read = :read").arg(whereClauseAccount(false, account_id));

  return customIdsOfMessagesByCondition(db, cond, {{QSL(":read"), read == RootItem::ReadStatus::Read ? 0 : 1}});
}

QStringList DatabaseQueries::customIdsOfImportantMessages(const QSqlDatabase& db,
                                                          RootItem::ReadStatus read,
                                                          int account_id) {
  QString cond = QSL("%1 AND Messages.is_read = :read").arg(whereClauseImportantArticles(account_id));

  return customIdsOfMessagesByCondition(db, cond, {{QSL(":read"), read == RootItem::ReadStatus::Read ? 0 : 1}});
}

QStringList DatabaseQueries::customIdsOfUnreadMessages(const QSqlDatabase& db, int account_id) {
  QString cond = whereClauseUnreadArticles(account_id);

  return customIdsOfMessagesByCondition(db, cond);
}

QStringList DatabaseQueries::customIdsOfMessagesFromBin(const QSqlDatabase& db,
                                                        RootItem::ReadStatus read,
                                                        int account_id) {
  QString cond = QSL("%1 AND Messages.is_read = :read").arg(whereClauseBin(account_id));

  return customIdsOfMessagesByCondition(db, cond, {{QSL(":read"), read == RootItem::ReadStatus::Read ? 0 : 1}});
}

QStringList DatabaseQueries::customIdsOfMessagesFromProbe(const QSqlDatabase& db,
                                                          Search* probe,
                                                          RootItem::ReadStatus read) {
  QString cond = QSL("%1 AND Messages.is_read = :read").arg(whereClauseProbe(probe, probe->account()->accountId()));

  return customIdsOfMessagesByCondition(db, cond, {{QSL(":read"), read == RootItem::ReadStatus::Read ? 0 : 1}});
}

void DatabaseQueries::createOverwriteCategory(const QSqlDatabase& db,
                                              Category* category,
                                              int account_id,
                                              int new_parent_id) {
  SqlQuery q(db);
  int next_sort_order;

  if (category->id() <= 0 || (category->parent() != nullptr && category->parent()->id() != new_parent_id)) {
    q.prepare(QSL("SELECT MAX(ordr) FROM Categories WHERE account_id = :account_id AND parent_id = :parent_id;"));
    q.bindValue(QSL(":account_id"), account_id);
    q.bindValue(QSL(":parent_id"), new_parent_id);

    q.exec();

    if (!q.next()) {
      THROW_EX(SqlException, q.lastError());
    }

    next_sort_order = (q.value(0).isNull() ? -1 : q.value(0).toInt()) + 1;
    q.finish();
  }
  else {
    next_sort_order = category->sortOrder();
  }

  if (category->id() <= 0) {
    // We need to insert category first.
    q.prepare(QSL("INSERT INTO "
                  "Categories (parent_id, ordr, title, date_created, account_id) "
                  "VALUES (0, 0, 'new', 0, %1);")
                .arg(QString::number(account_id)));

    q.exec();
    category->setId(q.lastInsertId().toInt());
  }
  else if (category->parent() != nullptr && category->parent()->id() != new_parent_id) {
    // Category is moving between parents.
    // 1. Move category to bottom of current parent.
    // 2. Assign proper new sort order.
    //
    // NOTE: The category will get reassigned to new parent usually after this method
    // completes by the caller.
    moveItem(category, false, true, {}, db);
  }

  // Restore to correct sort order.
  category->setSortOrder(next_sort_order);

  q.prepare("UPDATE Categories "
            "SET "
            "parent_id = :parent_id, "
            "ordr = :ordr, "
            "title = :title, "
            "description = :description, "
            "date_created = :date_created, "
            "icon = :icon, "
            "account_id = :account_id, "
            "custom_id = :custom_id, "
            "custom_data = :custom_data "
            "WHERE id = :id;");
  q.bindValue(QSL(":parent_id"), new_parent_id);
  q.bindValue(QSL(":title"), category->title());
  q.bindValue(QSL(":description"), category->description());
  q.bindValue(QSL(":date_created"), category->creationDate().toMSecsSinceEpoch());
  q.bindValue(QSL(":icon"), qApp->icons()->toByteArray(category->icon()));
  q.bindValue(QSL(":account_id"), account_id);
  q.bindValue(QSL(":custom_id"), category->customId());
  q.bindValue(QSL(":id"), category->id());
  q.bindValue(QSL(":ordr"), category->sortOrder());

  auto custom_data = category->customDatabaseData();
  QString serialized_custom_data = serializeCustomData(custom_data);

  q.bindValue(QSL(":custom_data"), serialized_custom_data);

  q.exec();
}

void DatabaseQueries::createOverwriteFeed(const QSqlDatabase& db,
                                          Feed* feed,
                                          int account_id,
                                          int new_parent_id,
                                          int new_feed_id) {
  SqlQuery q(db);
  int next_sort_order;

  if (feed->id() <= 0 || (feed->parent() != nullptr && feed->parent()->id() != new_parent_id)) {
    // We either insert completely new feed or we move feed
    // to new parent. Get new viable sort order.
    q.prepare(QSL("SELECT MAX(ordr) FROM Feeds WHERE account_id = :account_id AND category = :category;"));
    q.bindValue(QSL(":account_id"), account_id);
    q.bindValue(QSL(":category"), new_parent_id);

    q.exec();

    if (!q.next()) {
      THROW_EX(SqlException, q.lastError());
    }

    next_sort_order = (q.value(0).isNull() ? -1 : q.value(0).toInt()) + 1;
    q.finish();
  }
  else {
    next_sort_order = feed->sortOrder();
  }

  if (feed->id() <= 0) {
    // We need to insert feed first.
    if (new_feed_id > 0) {
      q.prepare(QSL("INSERT INTO "
                    "Feeds (id, title, ordr, date_created, category, update_type, update_interval, account_id, "
                    "custom_id) "
                    "VALUES (%2, 'new', 0, 0, 0, 0, 1, %1, 'new');")
                  .arg(QString::number(account_id), QString::number(new_feed_id)));
    }
    else {
      q.prepare(QSL("INSERT INTO "
                    "Feeds (title, ordr, date_created, category, update_type, update_interval, account_id, custom_id) "
                    "VALUES ('new', 0, 0, 0, 0, 1, %1, 'new');")
                  .arg(QString::number(account_id)));
    }

    q.exec();
    feed->setId(q.lastInsertId().toInt());

    if (feed->customId().isEmpty()) {
      feed->setCustomId(QString::number(feed->id()));
    }
  }
  else if (feed->parent() != nullptr && feed->parent()->id() != new_parent_id) {
    // Feed is moving between categories.
    // 1. Move feed to bottom of current category.
    // 2. Assign proper new sort order.
    //
    // NOTE: The feed will get reassigned to new parent usually after this method
    // completes by the caller.
    moveItem(feed, false, true, {}, db);
  }

  // Restore to correct sort order.
  feed->setSortOrder(next_sort_order);

  q.prepare("UPDATE Feeds "
            "SET "
            "title = :title, "
            "ordr = :ordr, "
            "description = :description, "
            "date_created = :date_created, "
            "icon = :icon, "
            "category = :category, "
            "source = :source, "
            "update_type = :update_type, "
            "update_interval = :update_interval, "
            "is_off = :is_off, "
            "is_quiet = :is_quiet, "
            "is_rtl = :is_rtl, "
            "add_any_datetime_articles = :add_any_datetime_articles, "
            "datetime_to_avoid = :datetime_to_avoid, "
            "keep_article_customize = :keep_article_customize, "
            "keep_article_count = :keep_article_count, "
            "keep_unread_articles = :keep_unread_articles, "
            "keep_starred_articles = :keep_starred_articles, "
            "recycle_articles = :recycle_articles, "
            "account_id = :account_id, "
            "custom_id = :custom_id, "
            "custom_data = :custom_data "
            "WHERE id = :id;");
  q.bindValue(QSL(":title"), feed->title());
  q.bindValue(QSL(":description"), feed->description());
  q.bindValue(QSL(":date_created"), feed->creationDate().toMSecsSinceEpoch());
  q.bindValue(QSL(":icon"), qApp->icons()->toByteArray(feed->icon()));
  q.bindValue(QSL(":category"), new_parent_id);
  q.bindValue(QSL(":source"), feed->source());
  q.bindValue(QSL(":update_type"), int(feed->autoUpdateType()));
  q.bindValue(QSL(":update_interval"), feed->autoUpdateInterval());
  q.bindValue(QSL(":account_id"), account_id);
  q.bindValue(QSL(":custom_id"), feed->customId());
  q.bindValue(QSL(":id"), feed->id());
  q.bindValue(QSL(":ordr"), feed->sortOrder());
  q.bindValue(QSL(":is_off"), feed->isSwitchedOff());
  q.bindValue(QSL(":is_quiet"), feed->isQuiet());
  q.bindValue(QSL(":is_rtl"), int(feed->rtlBehavior()));

  const Feed::ArticleIgnoreLimit art = feed->articleIgnoreLimit();

  q.bindValue(QSL(":add_any_datetime_articles"), art.m_addAnyArticlesToDb);
  q.bindValue(QSL(":datetime_to_avoid"),
              (art.m_dtToAvoid.isValid() && art.m_dtToAvoid.toMSecsSinceEpoch() > 0)
                ? art.m_dtToAvoid.toMSecsSinceEpoch()
                : art.m_hoursToAvoid);

  q.bindValue(QSL(":keep_article_customize"), art.m_customizeLimitting);
  q.bindValue(QSL(":keep_article_count"), art.m_keepCountOfArticles);
  q.bindValue(QSL(":keep_unread_articles"), art.m_doNotRemoveUnread);
  q.bindValue(QSL(":keep_starred_articles"), art.m_doNotRemoveStarred);
  q.bindValue(QSL(":recycle_articles"), art.m_moveToBinDontPurge);

  auto custom_data = feed->customDatabaseData();
  QString serialized_custom_data = serializeCustomData(custom_data);

  q.bindValue(QSL(":custom_data"), serialized_custom_data);

  q.exec();
}

void DatabaseQueries::storeCustomData(const QSqlDatabase& db,
                                      const QString& table,
                                      int item_id,
                                      const QVariantHash& data) {
  QString serialized_custom_data = serializeCustomData(data);
  SqlQuery q(db);

  q.prepare(QSL("UPDATE %1 "
                "SET custom_data = :custom_data "
                "WHERE id = :id;")
              .arg(table));
  q.bindValue(QSL(":id"), item_id);
  q.bindValue(QSL(":custom_data"), serialized_custom_data);
  q.exec();
}

void DatabaseQueries::createOverwriteAccount(const QSqlDatabase& db, ServiceRoot* account) {
  SqlQuery q(db);

  if (account->accountId() <= 0) {
    // We need to insert account and generate sort order first.
    if (account->sortOrder() < 0) {
      q.exec(QSL("SELECT MAX(ordr) FROM Accounts;"));
      q.next();

      int next_order = (q.value(0).isNull() ? -1 : q.value(0).toInt()) + 1;

      account->setSortOrder(next_order);
      q.finish();
    }

    q.prepare(QSL("INSERT INTO Accounts (ordr, type) "
                  "VALUES (0, :type);"));
    q.bindValue(QSL(":type"), account->code());

    q.exec();
    account->setAccountId(q.lastInsertId().toInt());
  }

  // Now we construct the SQL update query.
  auto proxy = account->networkProxy();

  q.prepare(QSL("UPDATE Accounts "
                "SET proxy_type = :proxy_type, proxy_host = :proxy_host, proxy_port = :proxy_port, "
                "    proxy_username = :proxy_username, proxy_password = :proxy_password, ordr = :ordr, "
                "    custom_data = :custom_data "
                "WHERE id = :id"));
  q.bindValue(QSL(":proxy_type"), proxy.type());
  q.bindValue(QSL(":proxy_host"), proxy.hostName());
  q.bindValue(QSL(":proxy_port"), proxy.port());
  q.bindValue(QSL(":proxy_username"), proxy.user());
  q.bindValue(QSL(":proxy_password"), TextFactory::encrypt(proxy.password()));
  q.bindValue(QSL(":id"), account->accountId());
  q.bindValue(QSL(":ordr"), account->sortOrder());

  auto custom_data = account->customDatabaseData();
  QString serialized_custom_data = serializeCustomData(custom_data);

  q.bindValue(QSL(":custom_data"), serialized_custom_data);

  q.exec();
}

void DatabaseQueries::deleteFeed(const QSqlDatabase& db, Feed* feed, int account_id) {
  moveItem(feed, false, true, {}, db);

  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM Feeds WHERE id = :feed;"));
  q.bindValue(QSL(":feed"), feed->id());

  q.exec();
}

void DatabaseQueries::deleteCategory(const QSqlDatabase& db, Category* category) {
  moveItem(category, false, true, {}, db);

  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM Categories WHERE id = :category;"));
  q.bindValue(QSL(":category"), category->id());

  q.exec();
}

void DatabaseQueries::moveItem(RootItem* item,
                               bool move_top,
                               bool move_bottom,
                               int move_index,
                               const QSqlDatabase& db) {
  if (item->kind() != RootItem::Kind::Feed && item->kind() != RootItem::Kind::Category &&
      item->kind() != RootItem::Kind::ServiceRoot) {
    return;
  }

  auto neighbors = item->parent()->childItems();
  int max_sort_order = qlinq::from(neighbors)
                         .max([=](RootItem* it) {
                           return it->kind() == item->kind() ? it->sortOrder() : 0;
                         })
                         .value_or(0);

  if ((!move_top && !move_bottom && item->sortOrder() == move_index) || /* Item is already sorted OK. */
      (!move_top && !move_bottom &&
       move_index < 0) || /* Order cannot be smaller than 0 if we do not move to begin/end. */
      (!move_top && !move_bottom && move_index > max_sort_order) || /* Cannot move past biggest sort order. */
      (move_top && item->sortOrder() == 0) ||                       /* Item is already on top. */
      (move_bottom && item->sortOrder() == max_sort_order) ||       /* Item is already on bottom. */
      max_sort_order <= 0) {                                        /* We only have 1 item, nothing to sort. */
    return;
  }

  SqlQuery q(db);

  if (move_top) {
    move_index = 0;
  }
  else if (move_bottom) {
    move_index = max_sort_order;
  }

  int move_low = qMin(move_index, item->sortOrder());
  int move_high = qMax(move_index, item->sortOrder());
  QString parent_field, table_name;

  switch (item->kind()) {
    case RootItem::Kind::Feed:
      parent_field = QSL("category");
      table_name = QSL("Feeds");
      break;

    case RootItem::Kind::Category:
      parent_field = QSL("parent_id");
      table_name = QSL("Categories");
      break;

    case RootItem::Kind::ServiceRoot:
      table_name = QSL("Accounts");
      break;

    default:
      throw ApplicationException(QObject::tr("cannot move item of kind %1").arg(int(item->kind())));
  }

  if (item->kind() == RootItem::Kind::ServiceRoot) {
    if (item->sortOrder() > move_index) {
      q.prepare(QSL("UPDATE Accounts SET ordr = ordr + 1 "
                    "WHERE ordr < :move_high AND ordr >= :move_low;"));
    }
    else {
      q.prepare(QSL("UPDATE Accounts SET ordr = ordr - 1 "
                    "WHERE ordr > :move_low AND ordr <= :move_high;"));
    }
  }
  else {
    if (item->sortOrder() > move_index) {
      q.prepare(QSL("UPDATE %1 SET ordr = ordr + 1 "
                    "WHERE account_id = :account_id AND %2 = :category AND ordr < :move_high AND ordr >= :move_low;")
                  .arg(table_name, parent_field));
    }
    else {
      q.prepare(QSL("UPDATE %1 SET ordr = ordr - 1 "
                    "WHERE account_id = :account_id AND %2 = :category AND ordr > :move_low AND ordr <= :move_high;")
                  .arg(table_name, parent_field));
    }

    q.bindValue(QSL(":account_id"), item->account()->accountId());
    q.bindValue(QSL(":category"), item->parent()->id());
  }

  q.bindValue(QSL(":move_low"), move_low);
  q.bindValue(QSL(":move_high"), move_high);
  q.exec();

  q.prepare(QSL("UPDATE %1 SET ordr = :ordr WHERE id = :id;").arg(table_name));
  q.bindValue(QSL(":id"),
              item->kind() == RootItem::Kind::ServiceRoot ? item->toServiceRoot()->accountId() : item->id());
  q.bindValue(QSL(":ordr"), move_index);
  q.exec();

  // Fix live sort orders.
  if (item->sortOrder() > move_index) {
    qlinq::from(neighbors)
      .where([=](RootItem* it) {
        return it->kind() == item->kind() && it->sortOrder() < move_high && it->sortOrder() >= move_low;
      })
      .for_each([](RootItem* it) {
        it->setSortOrder(it->sortOrder() + 1);
      });
  }
  else {
    qlinq::from(neighbors)
      .where([=](RootItem* it) {
        return it->kind() == item->kind() && it->sortOrder() > move_low && it->sortOrder() <= move_high;
      })
      .for_each([](RootItem* it) {
        it->setSortOrder(it->sortOrder() - 1);
      });
  }

  item->setSortOrder(move_index);
}

void DatabaseQueries::moveMessageFilter(const QList<MessageFilter*>& all_filters,
                                        MessageFilter* filter,
                                        bool move_top,
                                        bool move_bottom,
                                        int move_index,
                                        const QSqlDatabase& db) {
  int max_sort_order = qlinq::from(all_filters)
                         .max([=](MessageFilter* it) {
                           return it->sortOrder();
                         })
                         .value_or(0);

  if ((!move_top && !move_bottom && filter->sortOrder() == move_index) || /* Item is already sorted OK. */
      (!move_top && !move_bottom &&
       move_index < 0) || /* Order cannot be smaller than 0 if we do not move to begin/end. */
      (!move_top && !move_bottom && move_index > max_sort_order) || /* Cannot move past biggest sort order. */
      (move_top && filter->sortOrder() == 0) ||                     /* Item is already on top. */
      (move_bottom && filter->sortOrder() == max_sort_order) ||     /* Item is already on bottom. */
      max_sort_order <= 0) {                                        /* We only have 1 item, nothing to sort. */
    return;
  }

  SqlQuery q(db);

  if (move_top) {
    move_index = 0;
  }
  else if (move_bottom) {
    move_index = max_sort_order;
  }

  int move_low = qMin(move_index, filter->sortOrder());
  int move_high = qMax(move_index, filter->sortOrder());

  if (filter->sortOrder() > move_index) {
    q.prepare(QSL("UPDATE MessageFilters SET ordr = ordr + 1 "
                  "WHERE ordr < :move_high AND ordr >= :move_low;"));
  }
  else {
    q.prepare(QSL("UPDATE MessageFilters SET ordr = ordr - 1 "
                  "WHERE ordr > :move_low AND ordr <= :move_high;"));
  }

  q.bindValue(QSL(":move_low"), move_low);
  q.bindValue(QSL(":move_high"), move_high);

  q.exec();

  q.prepare(QSL("UPDATE MessageFilters SET ordr = :ordr WHERE id = :id;"));
  q.bindValue(QSL(":id"), filter->id());
  q.bindValue(QSL(":ordr"), move_index);

  q.exec();

  // Fix live sort orders.
  if (filter->sortOrder() > move_index) {
    qlinq::from(all_filters)
      .where([=](MessageFilter* it) {
        return it->sortOrder() < move_high && it->sortOrder() >= move_low;
      })
      .for_each([](MessageFilter* it) {
        it->setSortOrder(it->sortOrder() + 1);
      });
  }
  else {
    qlinq::from(all_filters)
      .where([=](MessageFilter* it) {
        return it->sortOrder() > move_low && it->sortOrder() <= move_high;
      })
      .for_each([](MessageFilter* it) {
        it->setSortOrder(it->sortOrder() - 1);
      });
  }

  filter->setSortOrder(move_index);
}

MessageFilter* DatabaseQueries::addMessageFilter(const QSqlDatabase& db, const QString& title, const QString& script) {
  if (!db.driver()->hasFeature(QSqlDriver::DriverFeature::LastInsertId)) {
    throw ApplicationException(QObject::tr("Cannot insert article filter, because current database cannot return last "
                                           "inserted row ID."));
  }

  SqlQuery q(db);

  q.exec(QSL("SELECT COUNT(*) FROM MessageFilters;"));
  q.next();

  int new_ordr = q.value(0).toInt();

  q.prepare(QSL("INSERT INTO MessageFilters (name, script, ordr) VALUES (:name, :script, :ordr);"));
  q.bindValue(QSL(":name"), title);
  q.bindValue(QSL(":script"), script);
  q.bindValue(QSL(":ordr"), new_ordr);

  q.exec();

  auto* fltr = new MessageFilter(q.lastInsertId().toInt());

  fltr->setName(title);
  fltr->setScript(script);
  fltr->setSortOrder(new_ordr);

  return fltr;
}

void DatabaseQueries::removeMessageFilter(const QSqlDatabase& db, int filter_id) {
  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM MessageFilters WHERE id = :id;"));
  q.bindValue(QSL(":id"), filter_id);
  q.exec();
}

QList<MessageFilter*> DatabaseQueries::getMessageFilters(const QSqlDatabase& db) {
  SqlQuery q(db);
  QList<MessageFilter*> filters;

  q.prepare(QSL("SELECT id, name, script, is_enabled, ordr FROM MessageFilters;"));
  q.exec();

  while (q.next()) {
    auto* filter = new MessageFilter(q.value(0).toInt());

    filter->setName(q.value(1).toString());
    filter->setScript(q.value(2).toString());
    filter->setEnabled(q.value(3).toBool());
    filter->setSortOrder(q.value(4).toInt());

    filters.append(filter);
  }

  return filters;
}

void DatabaseQueries::assignMessageFilterToFeed(const QSqlDatabase& db, int feed_id, int filter_id) {
  SqlQuery q(db);

  q.prepare(QSL("SELECT COUNT(*) FROM MessageFiltersInFeeds "
                "WHERE filter = :filter AND feed = :feed;"));

  q.bindValue(QSL(":filter"), filter_id);
  q.bindValue(QSL(":feed"), feed_id);

  if (q.exec() && q.next()) {
    auto already_included_count = q.value(0).toInt();

    if (already_included_count > 0) {
      return;
    }
  }
  else {
    THROW_EX(SqlException, q.lastError());
  }

  q.prepare(QSL("INSERT INTO MessageFiltersInFeeds (filter, feed) "
                "VALUES (:filter, :feed);"));

  q.bindValue(QSL(":filter"), filter_id);
  q.bindValue(QSL(":feed"), feed_id);

  q.exec();
}

void DatabaseQueries::updateMessageFilter(const QSqlDatabase& db, MessageFilter* filter) {
  SqlQuery q(db);

  q.prepare(QSL("UPDATE MessageFilters SET name = :name, script = :script, is_enabled = :is_enabled, ordr = :ordr "
                "WHERE id = :id;"));

  q.bindValue(QSL(":name"), filter->name());
  q.bindValue(QSL(":script"), filter->script());
  q.bindValue(QSL(":id"), filter->id());
  q.bindValue(QSL(":is_enabled"), filter->enabled() ? 1 : 0);
  q.bindValue(QSL(":ordr"), filter->sortOrder());

  q.exec();
}

void DatabaseQueries::removeMessageFilterFromFeed(const QSqlDatabase& db, int feed_id, int filter_id) {
  SqlQuery q(db);

  q.prepare(QSL("DELETE FROM MessageFiltersInFeeds "
                "WHERE filter = :filter AND feed = :feed;"));

  q.bindValue(QSL(":filter"), filter_id);
  q.bindValue(QSL(":feed"), feed_id);

  q.exec();
}

QMultiMap<int, int> DatabaseQueries::messageFiltersInFeeds(const QSqlDatabase& db, int account_id) {
  SqlQuery q(db);
  QMultiMap<int, int> filters_in_feeds;

  q.prepare(QSL("SELECT filter, feed "
                "FROM MessageFiltersInFeeds "
                "WHERE feed IN (SELECT id FROM Feeds WHERE account_id = :account_id);"));
  q.bindValue(QSL(":account_id"), account_id);
  q.exec();

  while (q.next()) {
    filters_in_feeds.insert(q.value(1).toInt(), q.value(0).toInt());
  }

  return filters_in_feeds;
}

void DatabaseQueries::storeNewOauthTokens(const QSqlDatabase& db, const QString& refresh_token, int account_id) {
  SqlQuery q(db);

  q.prepare(QSL("SELECT custom_data FROM Accounts WHERE id = :id;"));
  q.bindValue(QSL(":id"), account_id);

  q.exec();

  if (!q.next()) {
    THROW_EX(SqlException, q.lastError());
  }

  QVariantHash custom_data = deserializeCustomData(q.value(0).toString());

  custom_data[QSL("refresh_token")] = refresh_token;

  q.clear();
  q.prepare(QSL("UPDATE Accounts SET custom_data = :custom_data WHERE id = :id;"));
  q.bindValue(QSL(":custom_data"), serializeCustomData(custom_data));
  q.bindValue(QSL(":id"), account_id);

  q.exec();
}

QString DatabaseQueries::unnulifyString(const QString& str) {
  return str.isNull() ? QSL("") : str;
}
