// SPDX-FileCopyrightText: 2017 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT

#include "messages/MessageBuilder.hpp"

#include "Application.hpp"
#include "common/LinkParser.hpp"
#include "common/Literals.hpp"
#include "common/QLogging.hpp"
#include "controllers/accounts/AccountController.hpp"
#include "controllers/emotes/EmoteController.hpp"
#include "controllers/highlights/HighlightController.hpp"
#include "controllers/highlights/HighlightResult.hpp"
#include "controllers/ignores/IgnoreController.hpp"
#include "controllers/ignores/IgnorePhrase.hpp"
#include "controllers/userdata/UserDataController.hpp"
#include "messages/Emote.hpp"
#include "messages/Image.hpp"
#include "messages/Message.hpp"
#include "messages/MessageColor.hpp"
#include "messages/MessageElement.hpp"
#include "messages/MessageThread.hpp"
#include "providers/bttv/BttvBadges.hpp"
#include "providers/bttv/BttvEmotes.hpp"
#include "providers/chatterino/ChatterinoBadges.hpp"
#include "providers/colors/ColorProvider.hpp"
#include "providers/emoji/Emojis.hpp"
#include "providers/ffz/FfzBadges.hpp"
#include "providers/ffz/FfzEmotes.hpp"
#include "providers/links/LinkResolver.hpp"
#include "providers/seventv/SeventvBadges.hpp"
#include "providers/seventv/SeventvEmotes.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/ChannelPointReward.hpp"
#include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchBadge.hpp"
#include "providers/twitch/TwitchBadges.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchIrc.hpp"
#include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchUsers.hpp"
#include "providers/twitch/UserColor.hpp"
#include "singletons/Resources.hpp"
#include "singletons/Settings.hpp"
#include "singletons/StreamerMode.hpp"
#include "singletons/Theme.hpp"
#include "singletons/WindowManager.hpp"
#include "util/FormatTime.hpp"
#include "util/Helpers.hpp"
#include "util/IrcHelpers.hpp"
#include "util/QStringHash.hpp"
#include "util/Variant.hpp"
#include "widgets/Window.hpp"

#include <QApplication>
#include <QColor>
#include <QDateTime>
#include <QDebug>
#include <QFileInfo>
#include <QStringBuilder>
#include <QTimeZone>

#include <algorithm>
#include <chrono>
#include <unordered_set>

using namespace chatterino::literals;

namespace {

using namespace chatterino;
using namespace std::chrono_literals;

const QColor AUTOMOD_USER_COLOR{"blue"};

const QString regexHelpString("(\\w+)[.,!?;:]*?$");

// matches a mention with punctuation at the end, like "@username," or "@username!!!" where capture group would return "username"
const QRegularExpression mentionRegex("^@" + regexHelpString);

// if findAllUsernames setting is enabled, matches strings like in the examples above, but without @ symbol at the beginning
const QRegularExpression allUsernamesMentionRegex("^" + regexHelpString);

const QRegularExpression SPACE_REGEX("\\s");

constexpr QStringView ANONYMOUS_GIFTER_ID = u"274598607";

struct HypeChatPaidLevel {
    std::chrono::seconds duration;
    uint8_t numeric;
};

const std::unordered_map<QString, HypeChatPaidLevel> HYPE_CHAT_PAID_LEVEL{
    {u"ONE"_s, {30s, 1}},    {u"TWO"_s, {2min + 30s, 2}},
    {u"THREE"_s, {5min, 3}}, {u"FOUR"_s, {10min, 4}},
    {u"FIVE"_s, {30min, 5}}, {u"SIX"_s, {1h, 6}},
    {u"SEVEN"_s, {2h, 7}},   {u"EIGHT"_s, {3h, 8}},
    {u"NINE"_s, {4h, 9}},    {u"TEN"_s, {5h, 10}},
};

/// MessageFlag::Subscription message types
const QSet<QString> SUB_MESSAGE_TYPES{
    "sub",      //
    "subgift",  //
    "resub",    // resub messages
};

QString formatUpdatedEmoteList(const QString &platform,
                               const std::vector<QString> &emoteNames,
                               bool isAdd, bool isFirstWord)
{
    QString text = "";
    if (isAdd)
    {
        text += isFirstWord ? "Added" : "added";
    }
    else
    {
        text += isFirstWord ? "Removed" : "removed";
    }

    if (emoteNames.size() == 1)
    {
        text += QString(" %1 emote ").arg(platform);
    }
    else
    {
        text += QString(" %1 %2 emotes ").arg(emoteNames.size()).arg(platform);
    }

    size_t i = 0;
    for (const auto &emoteName : emoteNames)
    {
        i++;
        if (i > 1)
        {
            text += i == emoteNames.size() ? " and " : ", ";
        }
        text += emoteName;
    }

    text += ".";

    return text;
}

/**
 * Gets the default sound url if the user set one,
 * or the chatterino default ping sound if no url is set.
 */
QUrl getFallbackHighlightSound()
{
    QString path = getSettings()->pathHighlightSound;
    bool fileExists =
        !path.isEmpty() && QFileInfo::exists(path) && QFileInfo(path).isFile();

    if (fileExists)
    {
        return QUrl::fromLocalFile(path);
    }

    return QUrl("qrc:/sounds/ping2.wav");
}

void actuallyTriggerHighlights(const QString &channelName, bool playSound,
                               const QUrl &customSoundUrl, bool windowAlert)
{
    if (getApp()->getStreamerMode()->isEnabled() &&
        getSettings()->streamerModeMuteMentions)
    {
        // We are in streamer mode with muting mention sounds enabled. Do nothing.
        return;
    }

    if (getSettings()->isMutedChannel(channelName))
    {
        // Do nothing. Pings are muted in this channel.
        return;
    }

    const bool hasFocus = (QApplication::focusWidget() != nullptr);
    const bool resolveFocus =
        !hasFocus || getSettings()->highlightAlwaysPlaySound;

    if (playSound && resolveFocus)
    {
        QUrl soundUrl = customSoundUrl;
        if (soundUrl.isEmpty())
        {
            soundUrl = getFallbackHighlightSound();
        }
        getApp()->getSound()->play(soundUrl);
    }

    if (windowAlert)
    {
        getApp()->getWindows()->sendAlert();
    }
}

QString stylizeUsername(const QString &username, const Message &message)
{
    const QString &localizedName = message.localizedName;
    bool hasLocalizedName = !localizedName.isEmpty();

    // The full string that will be rendered in the chat widget
    QString usernameText;

    switch (getSettings()->usernameDisplayMode.getValue())
    {
        case UsernameDisplayMode::Username: {
            usernameText = username;
        }
        break;

        case UsernameDisplayMode::LocalizedName: {
            if (hasLocalizedName)
            {
                usernameText = localizedName;
            }
            else
            {
                usernameText = username;
            }
        }
        break;

        default:
        case UsernameDisplayMode::UsernameAndLocalizedName: {
            if (hasLocalizedName)
            {
                usernameText = username + "(" + localizedName + ")";
            }
            else
            {
                usernameText = username;
            }
        }
        break;
    }

    if (auto nicknameText = getSettings()->matchNickname(usernameText))
    {
        usernameText = *nicknameText;
    }

    return usernameText;
}

std::optional<EmotePtr> getTwitchBadge(const TwitchBadge &badge,
                                       const TwitchChannel *twitchChannel)
{
    if (auto channelBadge =
            twitchChannel->twitchBadge(badge.key_, badge.value_))
    {
        return channelBadge;
    }

    if (auto globalBadge =
            getApp()->getTwitchBadges()->badge(badge.key_, badge.value_))
    {
        return globalBadge;
    }

    return std::nullopt;
}

void appendBadges(MessageBuilder *builder,
                  const std::vector<TwitchBadge> &badges,
                  const std::unordered_map<QString, QString> &badgeInfos,
                  const TwitchChannel *twitchChannel)
{
    if (twitchChannel == nullptr)
    {
        return;
    }

    for (const auto &badge : badges)
    {
        auto badgeEmote = getTwitchBadge(badge, twitchChannel);
        if (!badgeEmote)
        {
            continue;
        }
        auto tooltip = (*badgeEmote)->tooltip.string;

        if (badge.key_ == "bits")
        {
            const auto &cheerAmount = badge.value_;
            tooltip = QString("Twitch cheer %0").arg(cheerAmount);
        }
        else if (badge.key_ == "moderator" &&
                 getSettings()->useCustomFfzModeratorBadges)
        {
            if (auto customModBadge = twitchChannel->ffzCustomModBadge())
            {
                builder
                    ->emplace<ModBadgeElement>(
                        *customModBadge,
                        MessageElementFlag::BadgeChannelAuthority)
                    ->setTooltip((*customModBadge)->tooltip.string);
                // early out, since we have to add a custom badge element here
                continue;
            }
        }
        else if (badge.key_ == "vip" && getSettings()->useCustomFfzVipBadges)
        {
            if (auto customVipBadge = twitchChannel->ffzCustomVipBadge())
            {
                builder
                    ->emplace<VipBadgeElement>(
                        *customVipBadge,
                        MessageElementFlag::BadgeChannelAuthority)
                    ->setTooltip((*customVipBadge)->tooltip.string);
                // early out, since we have to add a custom badge element here
                continue;
            }
        }
        else if (badge.flag_ == MessageElementFlag::BadgeSubscription)
        {
            auto badgeInfoIt = badgeInfos.find(badge.key_);
            if (badgeInfoIt != badgeInfos.end())
            {
                // badge.value_ is 4 chars long if user is subbed on higher tier
                // (tier + amount of months with leading zero if less than 100)
                // e.g. 3054 - tier 3 4,5-year sub. 2108 - tier 2 9-year sub
                const auto &subTier =
                    badge.value_.length() > 3 ? badge.value_.at(0) : '1';
                const auto &subMonths = badgeInfoIt->second;
                tooltip +=
                    QString(" (%1%2 months)")
                        .arg(subTier != '1' ? QString("Tier %1, ").arg(subTier)
                                            : "")
                        .arg(subMonths);
            }
        }
        else if (badge.flag_ == MessageElementFlag::BadgePredictions)
        {
            auto badgeInfoIt = badgeInfos.find(badge.key_);
            if (badgeInfoIt != badgeInfos.end())
            {
                auto infoValue = badgeInfoIt->second;
                auto predictionText =
                    parseTagString(infoValue).replace("⸝", ",");
                // Twitch's comma escape. Careful, the first character is RIGHT LOW PARAPHRASE BRACKET or U+2E1D, which just looks like a comma

                tooltip = QString("Predicted %1").arg(predictionText);
            }
        }

        builder->emplace<BadgeElement>(*badgeEmote, badge.flag_)
            ->setTooltip(tooltip);
    }

    builder->message().twitchBadges = badges;
    builder->message().twitchBadgeInfos = badgeInfos;
}

std::vector<TwitchBadge> appendSharedChatBadges(
    MessageBuilder *builder, const std::vector<TwitchBadge> &sharedBadges,
    const QString &sharedChannelName, const TwitchChannel *twitchChannel)
{
    auto appendedBadges = std::vector<TwitchBadge>{};
    for (const auto &badge : sharedBadges)
    {
        if (badge.key_ != "moderator" && badge.key_ != "vip" &&
            badge.key_ != "lead_moderator")
        {
            continue;
        }

        auto badgeEmote = getTwitchBadge(badge, twitchChannel);
        if (!badgeEmote)
        {
            continue;
        }

        auto tooltip = (*badgeEmote)->tooltip.string;
        if (sharedChannelName != "")
        {
            tooltip = QString("%1 (%2)").arg(tooltip, sharedChannelName);
        }

        builder->emplace<BadgeElement>(*badgeEmote, badge.flag_)
            ->setTooltip(tooltip);
        appendedBadges.push_back(badge);
    }

    return appendedBadges;
}

bool doesWordContainATwitchEmote(
    int cursor, const QString &word,
    const std::vector<TwitchEmoteOccurrence> &twitchEmotes,
    std::vector<TwitchEmoteOccurrence>::const_iterator &currentTwitchEmoteIt)
{
    if (currentTwitchEmoteIt == twitchEmotes.end())
    {
        // No emote to add!
        return false;
    }

    const auto &currentTwitchEmote = *currentTwitchEmoteIt;

    auto wordEnd = cursor + word.length();

    // Check if this emote fits within the word boundaries
    if (currentTwitchEmote.start < cursor || currentTwitchEmote.end > wordEnd)
    {
        // this emote does not fit xd
        return false;
    }

    return true;
}

EmotePtr makeSharedChatBadge(const QString &sourceName,
                             const QString &sourceProfileURL,
                             const QString &sourceLogin)
{
    if (!sourceProfileURL.isEmpty())
    {
        auto [urlBegin, urlEnd] = splitOnce(sourceProfileURL, u"300x300");
        QString url28px = urlBegin % u"28x28" % urlEnd;
        QString url70px = urlBegin % u"70x70" % urlEnd;
        QString url150px = urlBegin % u"150x150" % urlEnd;

        auto badgeLink = [&] {
            if (sourceLogin.isEmpty())
            {
                return Url{"https://link.twitch.tv/SharedChatViewer"};
            }

            return Url{u"https://www.twitch.tv/%1"_s.arg(sourceLogin)};
        }();

        return std::make_shared<Emote>(Emote{
            .name = EmoteName{},
            .images =
                ImageSet{
                    // The images should be displayed like an 18x18 image
                    Image::fromUrl({url28px}, 18.F / 28.F),
                    Image::fromUrl({url70px}, 18.F / 70.F),
                    Image::fromUrl({url150px}, 18.F / 150.F),
                },
            .tooltip =
                Tooltip{"Shared Message" +
                        (sourceName.isEmpty() ? "" : " from " + sourceName)},
            .homePage = badgeLink,
        });
    }

    return std::make_shared<Emote>(Emote{
        .name = EmoteName{},
        .images = ImageSet{Image::fromResourcePixmap(
            getResources().twitch.sharedChat, 0.25)},
        .tooltip = Tooltip{"Shared Message" +
                           (sourceName.isEmpty() ? "" : " from " + sourceName)},
        .homePage = Url{"https://link.twitch.tv/SharedChatViewer"},
    });
}

EmotePtr parseEmote(TwitchChannel *twitchChannel, const EmoteName &name)
{
    // Emote order:
    //  - FrankerFaceZ Channel
    //  - BetterTTV Channel
    //  - 7TV Channel
    //  - FrankerFaceZ Global
    //  - BetterTTV Global
    //  - 7TV Global

    const auto *globalFfzEmotes = getApp()->getFfzEmotes();
    const auto *globalBttvEmotes = getApp()->getBttvEmotes();
    const auto *globalSeventvEmotes = getApp()->getSeventvEmotes();

    std::optional<EmotePtr> emote{};

    if (twitchChannel != nullptr)
    {
        // Check for channel emotes

        emote = twitchChannel->ffzEmote(name);
        if (emote)
        {
            return *emote;
        }

        emote = twitchChannel->bttvEmote(name);
        if (emote)
        {
            return *emote;
        }

        emote = twitchChannel->seventvEmote(name);
        if (emote)
        {
            return *emote;
        }
    }

    // Check for global emotes

    emote = globalFfzEmotes->emote(name);
    if (emote)
    {
        return *emote;
    }

    emote = globalBttvEmotes->emote(name);
    if (emote)
    {
        return *emote;
    }

    emote = globalSeventvEmotes->globalEmote(name);
    if (emote)
    {
        return *emote;
    }

    return {};
}

std::pair<QString, bool> parseMessageType(const QVariantMap &tags)
{
    auto msgId = tags.value("msg-id").toString();

    bool mirrored = msgId == "sharedchatnotice";

    if (mirrored)
    {
        msgId = tags.value("source-msg-id").toString();
    }

    // TODO: room-id & source-room-id comparison?

    return {msgId, mirrored};
}

}  // namespace

namespace chatterino {

MessagePtr makeSystemMessage(const QString &text)
{
    return MessageBuilder(systemMessage, text).release();
}

MessagePtr makeSystemMessage(const QString &text, const QTime &time)
{
    return MessageBuilder(systemMessage, text, time).release();
}

MessageBuilder::MessageBuilder()
    : message_(std::make_shared<Message>())
{
}

MessageBuilder::MessageBuilder(SystemMessageTag, const QString &text,
                               const QTime &time)
    : MessageBuilder()
{
    this->emplace<TimestampElement>(time);

    // check system message for links
    // (e.g. needed for sub ticket message in sub only mode)
    const QStringList textFragments =
        text.split(SPACE_REGEX, Qt::SkipEmptyParts);
    for (const auto &word : textFragments)
    {
        auto link = linkparser::parse(word);
        if (link)
        {
            this->addLink(*link, word);
            continue;
        }

        this->appendOrEmplaceText(word, MessageColor::System);
    }
    this->message().flags.set(MessageFlag::System);
    this->message().flags.set(MessageFlag::DoNotTriggerNotification);
    this->message().messageText = text;
    this->message().searchText = text;
}

MessagePtrMut MessageBuilder::makeSystemMessageWithUser(
    const QString &text, const QString &loginName, const QString &displayName,
    const MessageColor &userColor, const QTime &time,
    const Communi::IrcMessage &ircMessage)
{
    MessageBuilder builder;
    builder.emplace<TimestampElement>(time);

    const auto textFragments = text.split(SPACE_REGEX, Qt::SkipEmptyParts);
    for (const auto &word : textFragments)
    {
        if (word == displayName)
        {
            builder.emplace<MentionElement>(displayName, loginName,
                                            MessageColor::System, userColor);
            continue;
        }

        builder.appendOrEmplaceText(word, MessageColor::System);
    }

    builder->flags.set(MessageFlag::System);
    builder->flags.set(MessageFlag::DoNotTriggerNotification);
    builder->messageText = text;
    builder->searchText = text;

    auto tags = ircMessage.tags();

    builder.parseMessageTags(tags);

    return builder.release();
}

MessagePtrMut MessageBuilder::makeSubgiftMessage(const QVariantMap &tags,
                                                 const QTime &time,
                                                 TwitchChannel *channel)
{
    auto text = parseTagString(tags.value("system-msg").toString());

    if (auto monthsIt = tags.find("msg-param-gift-months");
        monthsIt != tags.end())
    {
        int months = monthsIt.value().toInt();
        if (months > 1)
        {
            auto plan = tags.value("msg-param-sub-plan").toString();
            QString name =
                ANONYMOUS_GIFTER_ID == tags.value("user-id").toString()
                    ? "An anonymous user"
                    : tags.value("display-name").toString();
            text = QString("%1 gifted %2 months of a Tier %3 sub to %4!")
                       .arg(name, QString::number(months),
                            plan.isEmpty() ? '1' : plan.at(0),
                            tags.value("msg-param-recipient-display-name")
                                .toString());

            if (auto countIt = tags.find("msg-param-sender-count");
                countIt != tags.end())
            {
                int count = countIt.value().toInt();
                if (count > months)
                {
                    text += QString(" They've gifted %1 months in the channel.")
                                .arg(QString::number(count));
                }
            }
        }
    }

    const auto *userDataController = getApp()->getUserData();
    assert(userDataController != nullptr);

    MessageBuilder builder;
    builder.emplace<TimestampElement>(time);

    auto gifterLogin = tags.value("login").toString();
    auto gifterDisplayName = tags.value("display-name").toString();
    if (gifterDisplayName.isEmpty())
    {
        gifterDisplayName = gifterLogin;
    }

    auto gifterColor =
        twitch::getUserColor({
                                 .userLogin = gifterLogin,
                                 .userID = tags.value("user-id").toString(),
                                 .userDataController = userDataController,
                                 .channelChatters = channel,
                                 .color = tags.value("color").value<QColor>(),
                             })
            .value_or(MessageColor::System);

    auto recipientLogin =
        tags.value("msg-param-recipient-user-name").toString();
    if (recipientLogin.isEmpty())
    {
        recipientLogin = tags.value("msg-param-recipient-name").toString();
    }
    auto recipientDisplayName =
        tags.value("msg-param-recipient-display-name").toString();
    if (recipientDisplayName.isEmpty())
    {
        recipientDisplayName = recipientLogin;
    }

    auto recipientColor =
        twitch::getUserColor(
            {
                .userLogin = recipientLogin,
                .userID = tags.value("msg-param-recipient-id").toString(),

                .userDataController = userDataController,
                .channelChatters = channel,
            })
            .value_or(MessageColor::System);

    const auto textFragments = text.split(SPACE_REGEX, Qt::SkipEmptyParts);
    for (const auto &word : textFragments)
    {
        if (word == gifterDisplayName)
        {
            builder.emplace<MentionElement>(gifterDisplayName, gifterLogin,
                                            MessageColor::System, gifterColor);
            continue;
        }
        if (word.endsWith('!') &&
            word.size() == recipientDisplayName.size() + 1 &&
            word.startsWith(recipientDisplayName))
        {
            builder
                .emplace<MentionElement>(recipientDisplayName, recipientLogin,
                                         MessageColor::System, recipientColor)
                ->setTrailingSpace(false);
            builder.emplace<TextElement>(u"!"_s, MessageElementFlag::Text,
                                         MessageColor::System);
            continue;
        }

        builder.appendOrEmplaceText(word, MessageColor::System);
    }

    builder->flags.set(MessageFlag::System);
    builder->flags.set(MessageFlag::DoNotTriggerNotification);
    builder->messageText = text;
    builder->searchText = text;

    builder.parseMessageTags(tags);

    return builder.release();
}

MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &timeoutUser,
                               const QString &sourceUser,
                               const QString &channel,
                               const QString &systemMessageText, uint32_t times,
                               const QDateTime &time)
    : MessageBuilder()
{
    QString usernameText = systemMessageText.split(" ").at(0);
    QString remainder = systemMessageText.mid(usernameText.length() + 1);
    bool timeoutUserIsFirst =
        usernameText == "You" || timeoutUser == usernameText;
    QString messageText;

    this->emplace<TimestampElement>(time.time());
    this->emplaceSystemTextAndUpdate(usernameText, messageText)
        ->setLink(
            {Link::UserInfo, timeoutUserIsFirst ? timeoutUser : sourceUser});

    auto appendUser = [&](const QString &name) {
        auto pos = remainder.indexOf(name);
        if (pos > 0)
        {
            QString start = remainder.mid(0, pos - 1);
            remainder = remainder.mid(pos + name.length());

            this->emplaceSystemTextAndUpdate(start, messageText);
            auto *el = this->emplaceSystemTextAndUpdate(name, messageText)
                           ->setLink({Link::UserInfo, name});
            if (remainder.startsWith(' '))
            {
                removeFirstQS(remainder);
            }
            else
            {
                assert(messageText.endsWith(' '));
                removeLastQS(messageText);
                el->setTrailingSpace(false);
            }
        }
    };

    if (!sourceUser.isEmpty())
    {
        // the second username in the message
        appendUser(timeoutUserIsFirst ? sourceUser : timeoutUser);
    }

    if (!channel.isEmpty())
    {
        appendUser(channel);
    }

    this->emplaceSystemTextAndUpdate(
        QString("%1 (%2 times)").arg(remainder.trimmed()).arg(times),
        messageText);

    this->message().messageText = messageText;
    this->message().searchText = messageText;
    this->message().serverReceivedTime = time;
}

MessageBuilder::MessageBuilder(TimeoutMessageTag, const QString &username,
                               const QString &durationInSeconds,
                               bool multipleTimes, const QDateTime &time)
    : MessageBuilder()
{
    QString fullText;
    QString text;

    this->emplace<TimestampElement>(time.time());
    this->emplaceSystemTextAndUpdate(username, fullText)
        ->setLink({Link::UserInfo, username});

    if (!durationInSeconds.isEmpty())
    {
        text.append("has been timed out");

        // TODO: Implement who timed the user out

        text.append(" for ");
        bool ok = true;
        int timeoutSeconds = durationInSeconds.toInt(&ok);
        if (ok)
        {
            text.append(formatTime(timeoutSeconds));
        }
    }
    else
    {
        text.append("has been permanently banned");
    }

    text.append(".");

    if (multipleTimes)
    {
        text.append(" (multiple times)");
    }

    this->message().flags.set(MessageFlag::System);
    this->message().flags.set(MessageFlag::Timeout);
    this->message().flags.set(MessageFlag::ModerationAction);
    this->message().flags.set(MessageFlag::DoNotTriggerNotification);
    this->message().timeoutUser = username;

    this->emplaceSystemTextAndUpdate(text, fullText);
    this->message().messageText = fullText;
    this->message().searchText = fullText;
    this->message().serverReceivedTime = time;
}

MessageBuilder::MessageBuilder(LiveUpdatesAddEmoteMessageTag /*unused*/,
                               const QString &platform, const QString &actor,
                               const std::vector<QString> &emoteNames)
    : MessageBuilder()
{
    auto text =
        formatUpdatedEmoteList(platform, emoteNames, true, actor.isEmpty());

    this->emplace<TimestampElement>();
    if (!actor.isEmpty())
    {
        this->emplace<TextElement>(actor, MessageElementFlag::Username,
                                   MessageColor::System)
            ->setLink({Link::UserInfo, actor});
    }
    this->emplace<TextElement>(text, MessageElementFlag::Text,
                               MessageColor::System);

    QString finalText;
    if (actor.isEmpty())
    {
        finalText = text;
    }
    else
    {
        finalText = QString("%1 %2").arg(actor, text);
    }

    this->message().loginName = actor;
    this->message().messageText = finalText;
    this->message().searchText = finalText;

    this->message().flags.set(MessageFlag::System);
    this->message().flags.set(MessageFlag::LiveUpdatesAdd);
    this->message().flags.set(MessageFlag::DoNotTriggerNotification);
}

MessageBuilder::MessageBuilder(LiveUpdatesRemoveEmoteMessageTag /*unused*/,
                               const QString &platform, const QString &actor,
                               const std::vector<QString> &emoteNames)
    : MessageBuilder()
{
    auto text =
        formatUpdatedEmoteList(platform, emoteNames, false, actor.isEmpty());

    this->emplace<TimestampElement>();
    if (!actor.isEmpty())
    {
        this->emplace<TextElement>(actor, MessageElementFlag::Username,
                                   MessageColor::System)
            ->setLink({Link::UserInfo, actor});
    }
    this->emplace<TextElement>(text, MessageElementFlag::Text,
                               MessageColor::System);

    QString finalText;
    if (actor.isEmpty())
    {
        finalText = text;
    }
    else
    {
        finalText = QString("%1 %2").arg(actor, text);
    }

    this->message().loginName = actor;
    this->message().messageText = finalText;
    this->message().searchText = finalText;

    this->message().flags.set(MessageFlag::System);
    this->message().flags.set(MessageFlag::LiveUpdatesRemove);
    this->message().flags.set(MessageFlag::DoNotTriggerNotification);
}

MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteMessageTag /*unused*/,
                               const QString &platform, const QString &actor,
                               const QString &emoteName,
                               const QString &oldEmoteName)
    : MessageBuilder()
{
    QString text;
    if (actor.isEmpty())
    {
        text = "Renamed";
    }
    else
    {
        text = "renamed";
    }
    text +=
        QString(" %1 emote %2 to %3.").arg(platform, oldEmoteName, emoteName);

    this->emplace<TimestampElement>();
    if (!actor.isEmpty())
    {
        this->emplace<TextElement>(actor, MessageElementFlag::Username,
                                   MessageColor::System)
            ->setLink({Link::UserInfo, actor});
    }
    this->emplace<TextElement>(text, MessageElementFlag::Text,
                               MessageColor::System);

    QString finalText;
    if (actor.isEmpty())
    {
        finalText = text;
    }
    else
    {
        finalText = QString("%1 %2").arg(actor, text);
    }

    this->message().loginName = actor;
    this->message().messageText = finalText;
    this->message().searchText = finalText;

    this->message().flags.set(MessageFlag::System);
    this->message().flags.set(MessageFlag::LiveUpdatesUpdate);
    this->message().flags.set(MessageFlag::DoNotTriggerNotification);
}

MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag /*unused*/,
                               const QString &platform, const QString &actor,
                               const QString &emoteSetName)
    : MessageBuilder()
{
    auto text = QString("switched the active %1 Emote Set to \"%2\".")
                    .arg(platform, emoteSetName);

    this->emplace<TimestampElement>();
    this->emplace<TextElement>(actor, MessageElementFlag::Username,
                               MessageColor::System)
        ->setLink({Link::UserInfo, actor});
    this->emplace<TextElement>(text, MessageElementFlag::Text,
                               MessageColor::System);

    auto finalText = QString("%1 %2").arg(actor, text);

    this->message().loginName = actor;
    this->message().messageText = finalText;
    this->message().searchText = finalText;

    this->message().flags.set(MessageFlag::System);
    this->message().flags.set(MessageFlag::LiveUpdatesUpdate);
    this->message().flags.set(MessageFlag::DoNotTriggerNotification);
}

MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/,
                               const QString &imageLink,
                               const QString &deletionLink,
                               size_t imagesStillQueued, size_t secondsLeft)
    : MessageBuilder()
{
    this->message().flags.set(MessageFlag::System);
    this->message().flags.set(MessageFlag::DoNotTriggerNotification);

    this->emplace<TimestampElement>();

    using MEF = MessageElementFlag;
    auto addText = [this](QString text,
                          MessageColor color =
                              MessageColor::System) -> TextElement * {
        this->message().searchText += text;
        this->message().messageText += text;
        return this->emplace<TextElement>(text, MEF::Text, color);
    };

    addText("Your image has been uploaded to");

    // ASSUMPTION: the user gave this uploader configuration to the program
    // therefore they trust that the host is not wrong/malicious. This doesn't obey getSettings()->lowercaseDomains.
    // This also ensures that the LinkResolver doesn't get these links.
    addText(imageLink, MessageColor::Link)
        ->setLink({Link::Url, imageLink})
        ->setTrailingSpace(!deletionLink.isEmpty());

    if (!deletionLink.isEmpty())
    {
        addText("(Deletion link:");
        addText(deletionLink, MessageColor::Link)
            ->setLink({Link::Url, deletionLink})
            ->setTrailingSpace(false);
        addText(")")->setTrailingSpace(false);
    }
    addText(".");

    if (imagesStillQueued == 0)
    {
        return;
    }

    addText(QString("%1 left. Please wait until all of them are uploaded. "
                    "About %2 seconds left.")
                .arg(imagesStillQueued)
                .arg(secondsLeft));
}

Message *MessageBuilder::operator->()
{
    return this->message_.get();
}

Message &MessageBuilder::message()
{
    return *this->message_;
}

MessagePtrMut MessageBuilder::release()
{
    std::shared_ptr<Message> ptr;
    this->message_.swap(ptr);
    return ptr;
}

std::weak_ptr<const Message> MessageBuilder::weakOf()
{
    return this->message_;
}

void MessageBuilder::append(std::unique_ptr<MessageElement> element)
{
    this->message().elements.push_back(std::move(element));
}

void MessageBuilder::addLink(const linkparser::Parsed &parsedLink,
                             QStringView source)
{
    QString lowercaseLinkString;
    QString origLink = parsedLink.link.toString();
    QString fullUrl;

    if (parsedLink.protocol.isNull())
    {
        fullUrl = QStringLiteral("http://") + origLink;
    }
    else
    {
        lowercaseLinkString += parsedLink.protocol;
        fullUrl = origLink;
    }

    lowercaseLinkString += parsedLink.host.toString().toLower();
    lowercaseLinkString += parsedLink.rest;

    auto textColor = MessageColor(MessageColor::Link);

    if (parsedLink.hasPrefix(source))
    {
        this->emplace<TextElement>(parsedLink.prefix(source).toString(),
                                   MessageElementFlag::Text, this->textColor_)
            ->setTrailingSpace(false);
    }
    auto *el = this->emplace<LinkElement>(
        LinkElement::Parsed{.lowercase = lowercaseLinkString,
                            .original = origLink},
        fullUrl, MessageElementFlag::Text, textColor);
    if (parsedLink.hasSuffix(source))
    {
        el->setTrailingSpace(false);
        this->emplace<TextElement>(parsedLink.suffix(source).toString(),
                                   MessageElementFlag::Text, this->textColor_);
    }

    getApp()->getLinkResolver()->resolve(el->linkInfo());
}

bool MessageBuilder::isIgnored(const QString &originalMessage,
                               const QString &userID, const Channel *channel)
{
    return isIgnoredMessage({
        .message = originalMessage,
        .twitchUserID = userID,
        .isMod = channel->isMod(),
        .isBroadcaster = channel->isBroadcaster(),
    });
}

void MessageBuilder::appendOrEmplaceText(const QString &text,
                                         MessageColor color)
{
    auto fallback = [&] {
        this->emplace<TextElement>(text, MessageElementFlag::Text, color);
    };
    if (this->message_->elements.empty())
    {
        fallback();
        return;
    }

    auto *back =
        dynamic_cast<TextElement *>(this->message_->elements.back().get());
    if (!back ||                                         //
        dynamic_cast<MentionElement *>(back) ||          //
        dynamic_cast<LinkElement *>(back) ||             //
        !back->hasTrailingSpace() ||                     //
        back->getFlags() != MessageElementFlag::Text ||  //
        back->color() != color)
    {
        fallback();
        return;
    }

    back->appendText(text);
}

void MessageBuilder::appendOrEmplaceSystemTextAndUpdate(const QString &text,
                                                        QString &toUpdate)
{
    toUpdate.append(text);
    toUpdate.append(' ');
    this->appendOrEmplaceText(text, MessageColor::System);
}

void MessageBuilder::triggerHighlights(const Channel *channel,
                                       const HighlightAlert &alert)
{
    if (!alert.windowAlert && !alert.playSound)
    {
        return;
    }
    actuallyTriggerHighlights(channel->getName(), alert.playSound,
                              alert.customSound, alert.windowAlert);
}

void MessageBuilder::appendChannelPointRewardMessage(
    const ChannelPointReward &reward, bool isMod, bool isBroadcaster)
{
    if (isIgnoredMessage({
            .message = {},
            .twitchUserID = reward.user.id,
            .isMod = isMod,
            .isBroadcaster = isBroadcaster,
        }))
    {
        return;
    }

    this->emplace<TimestampElement>();
    QString redeemed = "Redeemed";
    QStringList textList;
    if (!reward.isUserInputRequired)
    {
        this->emplace<TextElement>(
                reward.user.login, MessageElementFlag::ChannelPointReward,
                MessageColor::Text, FontStyle::ChatMediumBold)
            ->setLink({Link::UserInfo, reward.user.login});
        redeemed = "redeemed";
        textList.append(reward.user.login);
    }
    this->emplace<TextElement>(redeemed,
                               MessageElementFlag::ChannelPointReward);
    if (reward.id == "CELEBRATION")
    {
        const auto emotePtr =
            getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
                EmoteId{reward.emoteId}, EmoteName{reward.emoteName});
        this->emplace<EmoteElement>(emotePtr,
                                    MessageElementFlag::ChannelPointReward,
                                    MessageColor::Text);
    }
    this->emplace<TextElement>(reward.title,
                               MessageElementFlag::ChannelPointReward,
                               MessageColor::Text, FontStyle::ChatMediumBold);
    this->emplace<ScalingImageElement>(
        reward.image, MessageElementFlag::ChannelPointRewardImage);
    this->emplace<TextElement>(QString::number(reward.cost),
                               MessageElementFlag::ChannelPointReward,
                               MessageColor::Text, FontStyle::ChatMediumBold);
    if (reward.isBits)
    {
        this->emplace<TextElement>(
            "bits", MessageElementFlag::ChannelPointReward, MessageColor::Text,
            FontStyle::ChatMediumBold);
    }
    if (reward.isUserInputRequired)
    {
        this->emplace<LinebreakElement>(MessageElementFlag::ChannelPointReward);
    }

    this->message().flags.set(MessageFlag::RedeemedChannelPointReward);

    textList.append({redeemed, reward.title, QString::number(reward.cost)});
    this->message().messageText = textList.join(" ");
    this->message().searchText = textList.join(" ");
    if (!reward.user.login.isEmpty())
    {
        this->message().loginName = reward.user.login;
    }

    this->message().reward = std::make_shared<ChannelPointReward>(reward);
}

MessagePtr MessageBuilder::makeChannelPointRewardMessage(
    const ChannelPointReward &reward, bool isMod, bool isBroadcaster)
{
    MessageBuilder builder;

    builder.appendChannelPointRewardMessage(reward, isMod, isBroadcaster);

    return builder.release();
}

MessagePtr MessageBuilder::makeLiveMessage(const QString &channelName,
                                           const QString &channelID,
                                           const QString &title,
                                           MessageFlags extraFlags)
{
    MessageBuilder builder;

    builder.emplace<TimestampElement>();
    builder
        .emplace<TextElement>(channelName, MessageElementFlag::Username,
                              MessageColor::Text, FontStyle::ChatMediumBold)
        ->setLink({Link::UserInfo, channelName});

    QString text;
    if (getSettings()->showTitleInLiveMessage)
    {
        text = QString("%1 is live: %2").arg(channelName, title);
        builder.emplace<TextElement>("is live:", MessageElementFlag::Text,
                                     MessageColor::Text);
        builder.emplace<TextElement>(title, MessageElementFlag::Text,
                                     MessageColor::Text);
    }
    else
    {
        text = QString("%1 is live!").arg(channelName);
        builder.emplace<TextElement>("is live!", MessageElementFlag::Text,
                                     MessageColor::Text);
    }

    builder.message().messageText = text;
    builder.message().searchText = text;
    builder.message().id = channelID;

    if (!extraFlags.isEmpty())
    {
        builder.message().flags.set(extraFlags);
    }

    return builder.release();
}

MessagePtr MessageBuilder::makeOfflineSystemMessage(const QString &channelName,
                                                    const QString &channelID)
{
    MessageBuilder builder;
    builder.emplace<TimestampElement>();
    builder.message().flags.set(MessageFlag::System);
    builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
    builder
        .emplace<TextElement>(channelName, MessageElementFlag::Username,
                              MessageColor::System, FontStyle::ChatMediumBold)
        ->setLink({Link::UserInfo, channelName});
    builder.emplace<TextElement>("is now offline.", MessageElementFlag::Text,
                                 MessageColor::System);
    auto text = QString("%1 is now offline.").arg(channelName);
    builder.message().messageText = text;
    builder.message().searchText = text;
    builder.message().id = channelID;

    return builder.release();
}

MessagePtr MessageBuilder::makeHostingSystemMessage(const QString &channelName,
                                                    bool hostOn)
{
    MessageBuilder builder;
    QString text;
    builder.emplace<TimestampElement>();
    builder.message().flags.set(MessageFlag::System);
    builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
    if (hostOn)
    {
        builder.emplace<TextElement>("Now hosting", MessageElementFlag::Text,
                                     MessageColor::System);
        builder
            .emplace<TextElement>(
                channelName + ".", MessageElementFlag::Username,
                MessageColor::System, FontStyle::ChatMediumBold)
            ->setLink({Link::UserInfo, channelName});
        text = QString("Now hosting %1.").arg(channelName);
    }
    else
    {
        builder
            .emplace<TextElement>(channelName, MessageElementFlag::Username,
                                  MessageColor::System,
                                  FontStyle::ChatMediumBold)
            ->setLink({Link::UserInfo, channelName});
        builder.emplace<TextElement>("has gone offline. Exiting host mode.",
                                     MessageElementFlag::Text,
                                     MessageColor::System);
        text =
            QString("%1 has gone offline. Exiting host mode.").arg(channelName);
    }
    builder.message().messageText = text;
    builder.message().searchText = text;
    return builder.release();
}

MessagePtr MessageBuilder::makeDeletionMessageFromIRC(
    const MessagePtr &originalMessage)
{
    MessageBuilder builder;

    builder.emplace<TimestampElement>();
    builder.message().flags.set(MessageFlag::System);
    builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
    builder.message().flags.set(MessageFlag::ModerationAction);
    // TODO(mm2pl): If or when jumping to a single message gets implemented a link,
    // add a link to the originalMessage
    builder.emplace<TextElement>("A message from", MessageElementFlag::Text,
                                 MessageColor::System);
    builder
        .emplace<TextElement>(originalMessage->displayName,
                              MessageElementFlag::Username,
                              MessageColor::System, FontStyle::ChatMediumBold)
        ->setLink({Link::UserInfo, originalMessage->loginName});
    builder.emplace<TextElement>("was deleted:", MessageElementFlag::Text,
                                 MessageColor::System);

    auto deletedMessageText = originalMessage->messageText;
    auto limit = getSettings()->deletedMessageLengthLimit.getValue();
    if (limit > 0 && deletedMessageText.length() > limit)
    {
        deletedMessageText = deletedMessageText.left(limit) + "…";
    }

    builder
        .emplace<TextElement>(deletedMessageText, MessageElementFlag::Text,
                              MessageColor::Text)
        ->setLink({Link::JumpToMessage, originalMessage->id});
    builder.message().timeoutUser = "msg:" + originalMessage->id;

    const auto deletionText =
        QString("A message from %1 was deleted: %2")
            .arg(originalMessage->loginName, deletedMessageText);
    builder.message().messageText = deletionText;
    builder.message().searchText = deletionText;

    return builder.release();
}

MessagePtr MessageBuilder::makeListOfUsersMessage(QString prefix,
                                                  QStringList users,
                                                  Channel *channel,
                                                  MessageFlags extraFlags)
{
    MessageBuilder builder;

    QString text = prefix + users.join(", ");

    builder.message().messageText = text;
    builder.message().searchText = text;

    builder.emplace<TimestampElement>();
    builder.message().flags.set(MessageFlag::System);
    builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
    builder.emplace<TextElement>(prefix, MessageElementFlag::Text,
                                 MessageColor::System);
    bool isFirst = true;
    auto *tc = dynamic_cast<TwitchChannel *>(channel);
    for (const QString &username : users)
    {
        if (!isFirst)
        {
            // this is used to add the ", " after each but the last entry
            builder.emplace<TextElement>(",", MessageElementFlag::Text,
                                         MessageColor::System);
        }
        isFirst = false;

        MessageColor color = MessageColor::System;

        if (tc)
        {
            if (auto userColor = tc->getUserColor(username);
                userColor.isValid())
            {
                color = MessageColor(userColor);
            }
        }

        // TODO: Ensure we make use of display name / username(login name) correctly here
        builder
            .emplace<MentionElement>(username, username, MessageColor::System,
                                     color)
            ->setTrailingSpace(false);
    }

    if (!extraFlags.isEmpty())
    {
        builder.message().flags.set(extraFlags);
    }

    return builder.release();
}

MessagePtr MessageBuilder::makeListOfUsersMessage(
    QString prefix, const std::vector<HelixModerator> &users, Channel *channel,
    MessageFlags extraFlags)
{
    MessageBuilder builder;

    QString text = prefix;

    builder.emplace<TimestampElement>();
    builder.message().flags.set(MessageFlag::System);
    builder.message().flags.set(MessageFlag::DoNotTriggerNotification);
    builder.emplace<TextElement>(prefix, MessageElementFlag::Text,
                                 MessageColor::System);
    bool isFirst = true;
    auto *tc = dynamic_cast<TwitchChannel *>(channel);
    for (const auto &user : users)
    {
        if (!isFirst)
        {
            // this is used to add the ", " after each but the last entry
            builder.emplace<TextElement>(",", MessageElementFlag::Text,
                                         MessageColor::System);
            text += QString(", %1").arg(user.userName);
        }
        else
        {
            text += user.userName;
        }
        isFirst = false;

        MessageColor color = MessageColor::System;

        if (tc)
        {
            if (auto userColor = tc->getUserColor(user.userLogin);
                userColor.isValid())
            {
                color = MessageColor(userColor);
            }
        }

        builder
            .emplace<MentionElement>(user.userName, user.userLogin,
                                     MessageColor::System, color)
            ->setTrailingSpace(false);
    }

    builder.message().messageText = text;
    builder.message().searchText = text;

    if (!extraFlags.isEmpty())
    {
        builder.message().flags.set(extraFlags);
    }

    return builder.release();
}

MessagePtr MessageBuilder::buildHypeChatMessage(
    Communi::IrcPrivateMessage *message)
{
    auto levelID = message->tag(u"pinned-chat-paid-level"_s).toString();
    auto currency = message->tag(u"pinned-chat-paid-currency"_s).toString();
    bool okAmount = false;
    auto amount = message->tag(u"pinned-chat-paid-amount"_s).toInt(&okAmount);
    bool okExponent = false;
    auto exponent =
        message->tag(u"pinned-chat-paid-exponent"_s).toInt(&okExponent);
    if (!okAmount || !okExponent || currency.isEmpty())
    {
        return {};
    }
    // additionally, there's `pinned-chat-paid-is-system-message` which isn't used by Chatterino.

    QString subtitle;
    auto levelIt = HYPE_CHAT_PAID_LEVEL.find(levelID);
    if (levelIt != HYPE_CHAT_PAID_LEVEL.end())
    {
        const auto &level = levelIt->second;
        subtitle = u"Level %1 Hype Chat (%2) "_s.arg(level.numeric)
                       .arg(formatTime(level.duration));
    }
    else
    {
        subtitle = u"Hype Chat "_s;
    }

    // actualAmount = amount * 10^(-exponent)
    double actualAmount = std::pow(10.0, double(-exponent)) * double(amount);

    auto locale = getSystemLocale();
    subtitle += locale.toCurrencyString(actualAmount, currency);

    auto dt = calculateMessageTime(message);
    MessageBuilder builder(systemMessage, parseTagString(subtitle), dt.time());
    builder->flags.set(MessageFlag::ElevatedMessage);
    return builder.release();
}

MessagePtrMut MessageBuilder::makeMissingScopesMessage(
    const QString &missingScopes)
{
    QString warnText =
        u"Your account is missing the following permission(s): " %
        missingScopes % u". Some features might not work correctly.";
    auto linkText = u"Consider re-adding your account."_s;

    MessageBuilder builder;
    QString text = warnText % ' ' % linkText;
    builder->messageText = text;
    builder->searchText = text;
    builder->flags.set(MessageFlag::System,
                       MessageFlag::DoNotTriggerNotification);

    builder.emplace<TimestampElement>();
    builder.emplace<TextElement>(warnText, MessageElementFlag::Text,
                                 MessageColor::System);
    builder
        .emplace<TextElement>(linkText, MessageElementFlag::Text,
                              MessageColor::Link)
        ->setLink({Link::OpenAccountsPage, {}});

    return builder.release();
}

MessagePtrMut MessageBuilder::makeClearChatMessage(const QDateTime &now,
                                                   const QString &actor,
                                                   uint32_t count)
{
    MessageBuilder builder;
    builder.emplace<TimestampElement>(now.time());
    builder->count = count;
    builder->serverReceivedTime = now;
    builder.message().flags.set(
        MessageFlag::System, MessageFlag::DoNotTriggerNotification,
        MessageFlag::ClearChat, MessageFlag::ModerationAction);

    QString messageText;
    if (actor.isEmpty())
    {
        builder.emplaceSystemTextAndUpdate(
            "Chat has been cleared by a moderator.", messageText);
    }
    else
    {
        builder.message().flags.set(MessageFlag::PubSub);
        builder.emplace<MentionElement>(actor, actor, MessageColor::System,
                                        MessageColor::System);
        messageText = actor + ' ';
        builder.emplaceSystemTextAndUpdate("cleared the chat.", messageText);
        builder->timeoutUser = actor;
    }

    if (count > 1)
    {
        builder.appendOrEmplaceSystemTextAndUpdate(
            '(' % QString::number(count) % u" times)", messageText);
    }

    builder->messageText = messageText;
    builder->searchText = messageText;

    return builder.release();
}

MessagePtrMut MessageBuilder::makePinSuccessMessage(QString text,
                                                    const QString &id)
{
    MessageBuilder builder;
    builder.emplace<TimestampElement>();
    builder->flags.set(MessageFlag::System,
                       MessageFlag::DoNotTriggerNotification,
                       MessageFlag::ModerationAction);
    QString searchText;
    builder.emplaceSystemTextAndUpdate("Pinned", searchText);

    if (text.isEmpty())
    {
        text = id;
        builder.emplaceSystemTextAndUpdate("message with ID", searchText);
    }

    if (text.length() > 50)
    {
        text = std::move(text).left(50) + "…";
    }
    searchText += text;

    builder
        .emplace<TextElement>(text, MessageElementFlag::Text,
                              MessageColor::Text)
        ->setLink({Link::JumpToMessage, id});

    builder->messageText = searchText;
    builder->searchText = searchText;
    return builder.release();
}

MessagePtrMut MessageBuilder::makeCurrentPinnedMessage(
    const TwitchChannel &chan, const HelixPinnedChatMessage &pin)
{
    MessageBuilder builder;
    builder->channelName = chan.getName();
    builder.emplace<TimestampElement>();
    builder->flags.set(MessageFlag::System,
                       MessageFlag::DoNotTriggerNotification);

    QString text = pin.pinnedBy.login + ' ';
    builder.emplace<MentionElement>(pin.pinnedBy.displayName,
                                    pin.pinnedBy.login, MessageColor::System,
                                    chan.getUserColor(pin.pinnedBy.login));
    builder.emplaceSystemTextAndUpdate("pinned a message", text);

    auto now = QDateTime::currentDateTimeUtc();
    builder.appendOrEmplaceSystemTextAndUpdate(
        formatTime(std::chrono::duration_cast<std::chrono::seconds>(
            now - pin.startsAt)),
        text);
    builder.appendOrEmplaceSystemTextAndUpdate("ago", text);
    if (pin.endsAt)
    {
        auto remaining =
            std::chrono::duration_cast<std::chrono::seconds>(*pin.endsAt - now);
        builder.appendOrEmplaceSystemTextAndUpdate(
            '(' % formatTime(remaining) % " remaining)", text);
    }
    else
    {
        builder.appendOrEmplaceSystemTextAndUpdate("until the stream ends",
                                                   text);
    }
    builder.appendOrEmplaceSystemTextAndUpdate("from", text);
    builder
        .emplace<MentionElement>(pin.sender.displayName, pin.sender.login,
                                 MessageColor::System,
                                 chan.getUserColor(pin.sender.login))
        ->setTrailingSpace(false);
    text += pin.sender.login;
    builder.appendOrEmplaceSystemTextAndUpdate(u":"_s, text);

    auto pinMessageText = pin.messageText;
    if (pinMessageText.length() > 50)
    {
        pinMessageText = pinMessageText.left(50) + "…";
    }

    builder
        .emplace<TextElement>(pinMessageText, MessageElementFlag::Text,
                              MessageColor::Text)
        ->setLink({Link::JumpToMessage, pin.messageID});
    builder->messageText = pinMessageText;
    builder->searchText = pinMessageText;
    return builder.release();
}

std::pair<MessagePtrMut, HighlightAlert> MessageBuilder::makeIrcMessage(
    /* mutable */ Channel *channel, const Communi::IrcMessage *ircMessage,
    const MessageParseArgs &args, /* mutable */ QString content,
    const QString::size_type messageOffset,
    const std::shared_ptr<MessageThread> &thread, const MessagePtr &parent)
{
    assert(ircMessage != nullptr);
    assert(channel != nullptr);

    auto tags = ircMessage->tags();
    if (args.allowIgnore)
    {
        bool ignored = MessageBuilder::isIgnored(
            content, tags.value("user-id").toString(), channel);
        if (ignored)
        {
            return {};
        }
    }

    auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel);

    auto userID = tags.value("user-id").toString();

    MessageBuilder builder;
    builder.parseUsernameColor(tags, userID);
    builder->userID = userID;

    if (args.isAction)
    {
        builder.textColor_ = builder.message_->usernameColor;
        builder->flags.set(MessageFlag::Action);
    }

    builder.parseUsername(ircMessage, twitchChannel,
                          args.trimSubscriberUsername);

    builder->flags.set(MessageFlag::Collapsed);

    bool senderIsBroadcaster = builder->loginName == channel->getName();
    bool userIsStaffOrBroadcaster = channel->isBroadcaster();

    builder->channelName = channel->getName();

    builder.parseMessageID(tags);

    MessageBuilder::parseRoomID(tags, twitchChannel);
    twitchChannel = builder.parseSharedChatInfo(tags, twitchChannel);

    // If it is a reward it has to be appended first
    if (!args.channelPointRewardId.isEmpty())
    {
        assert(twitchChannel != nullptr);
        auto reward =
            twitchChannel->channelPointReward(args.channelPointRewardId);
        if (reward)
        {
            builder.appendChannelPointRewardMessage(*reward, channel->isMod(),
                                                    channel->isBroadcaster());
        }
        builder->flags.set(MessageFlag::RedeemedChannelPointReward);
    }

    builder.appendChannelName(channel);

    if (tags.contains("rm-deleted"))
    {
        builder->flags.set(MessageFlag::Disabled);
    }

    builder.parseMessageTags(tags);

    if (tags.contains("first-msg") && tags["first-msg"].toString() == "1")
    {
        builder->flags.set(MessageFlag::FirstMessage);
    }

    if (tags.contains("pinned-chat-paid-amount"))
    {
        builder->flags.set(MessageFlag::ElevatedMessage);
    }

    if (tags.contains("bits"))
    {
        builder->flags.set(MessageFlag::CheerMessage);
        builder->bits = tags["bits"].toInt();
    }

    // reply threads
    builder.parseThread(content, tags, channel, thread, parent);

    // timestamp
    builder->serverReceivedTime = calculateMessageTime(ircMessage);
    builder.emplace<TimestampElement>(builder->serverReceivedTime.time());

    bool shouldAddModerationElements = [&] {
        if (senderIsBroadcaster)
        {
            // You cannot timeout the broadcaster
            return false;
        }

        if (tags.value("user-type").toString() == "mod" &&
            !userIsStaffOrBroadcaster)
        {
            // You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel
            // TODO: This is actually incorrect now - Twitch Staff do not have universal permission to timeout moderators anymore
            return false;
        }

        return true;
    }();
    if (shouldAddModerationElements)
    {
        builder.emplace<TwitchModerationElement>();
    }

    builder.appendTwitchBadges(tags, twitchChannel);

    builder.appendChatterinoBadges(userID);
    builder.appendFfzBadges(twitchChannel, userID);
    builder.appendBttvBadges(userID);
    builder.appendSeventvBadges(userID);

    builder.appendUsername(tags, args);

    TextState textState{.twitchChannel = twitchChannel};
    QString bits;

    auto iterator = tags.find("bits");
    if (iterator != tags.end())
    {
        textState.hasBits = true;
        textState.bitsLeft = iterator.value().toInt();
        bits = iterator.value().toString();
    }

    // Twitch emotes
    auto twitchEmotes =
        parseTwitchEmotes(tags, content, static_cast<int>(messageOffset));

    // This runs through all ignored phrases and runs its replacements on content
    processIgnorePhrases(*getSettings()->ignoredMessages.readOnly(), content,
                         twitchEmotes);

    std::ranges::sort(twitchEmotes, [](const auto &a, const auto &b) {
        return a.start < b.start;
    });
    auto uniqueEmotes = std::ranges::unique(
        twitchEmotes, [](const auto &first, const auto &second) {
            return first.start == second.start;
        });
    twitchEmotes.erase(uniqueEmotes.begin(), uniqueEmotes.end());

    // words
    QStringList splits = content.split(' ');

    builder.addWords(splits, twitchEmotes, textState);

    QString stylizedUsername =
        stylizeUsername(builder->loginName, builder.message());

    builder->messageText = content;
    builder->searchText = stylizedUsername + " " + builder->localizedName +
                          " " + builder->loginName + ": " + content + " " +
                          builder->searchText;

    // highlights
    HighlightAlert highlight = builder.parseHighlights(tags, content, args);
    if (tags.contains("historical"))
    {
        highlight.playSound = false;
        highlight.windowAlert = false;
    }

    // highlighting incoming whispers if requested per setting
    if (args.isReceivedWhisper && getSettings()->highlightInlineWhispers)
    {
        builder->flags.set(MessageFlag::HighlightedWhisper);
        builder->highlightColor =
            ColorProvider::instance().color(ColorType::Whisper);
    }

    if (!args.isReceivedWhisper && tags.value("msg-id") != "announcement")
    {
        if (thread)
        {
            auto &img = getResources().buttons.replyThreadDark;
            builder
                .emplace<CircularImageElement>(
                    Image::fromResourcePixmap(img, 0.15), 2, Qt::gray,
                    MessageElementFlag::ReplyButton)
                ->setLink({Link::ViewThread, thread->rootId()});
        }
        else
        {
            auto &img = getResources().buttons.replyDark;
            builder
                .emplace<CircularImageElement>(
                    Image::fromResourcePixmap(img, 0.15), 2, Qt::gray,
                    MessageElementFlag::ReplyButton)
                ->setLink({Link::ReplyToMessage, builder->id});
        }
    }

    return {builder.release(), highlight};
}

void MessageBuilder::addEmoji(const EmotePtr &emote)
{
    this->emplace<EmoteElement>(emote, MessageElementFlag::EmojiAll);
}

void MessageBuilder::addTextOrEmote(TextState &state, QString string)
{
    if (state.hasBits && this->tryAppendCheermote(state, string))
    {
        // This string was parsed as a cheermote
        return;
    }

    // TODO: Implement ignored emotes
    // Format of ignored emotes:
    // Emote name: "forsenPuke" - if string in ignoredEmotes
    // Will match emote regardless of source (i.e. bttv, ffz)
    // Emote source + name: "bttv:nyanPls"
    if (this->tryAppendEmote(state.twitchChannel, {string}))
    {
        // Successfully appended an emote
        return;
    }

    // Actually just text
    auto link = linkparser::parse(string);
    auto textColor = this->textColor_;

    if (link)
    {
        this->addLink(*link, string);
        return;
    }

    if (string.startsWith('@'))
    {
        auto match = mentionRegex.match(string);
        // Only treat as @mention if valid username
        if (match.hasMatch())
        {
            QString username = match.captured(1);
            auto originalTextColor = textColor;

            if (state.twitchChannel != nullptr)
            {
                if (auto userColor =
                        state.twitchChannel->getUserColor(username);
                    userColor.isValid())
                {
                    textColor = userColor;
                }
            }

            auto prefixedUsername = '@' + username;
            auto remainder = string.remove(prefixedUsername);
            this->emplace<MentionElement>(prefixedUsername, username,
                                          originalTextColor, textColor)
                ->setTrailingSpace(remainder.isEmpty());

            if (!remainder.isEmpty())
            {
                this->emplace<TextElement>(remainder, MessageElementFlag::Text,
                                           originalTextColor);
            }

            return;
        }
    }

    if (state.twitchChannel != nullptr && getSettings()->findAllUsernames)
    {
        auto match = allUsernamesMentionRegex.match(string);
        QString username = match.captured(1);

        if (match.hasMatch() &&
            state.twitchChannel->accessChatters()->contains(username))
        {
            auto originalTextColor = textColor;

            if (auto userColor = state.twitchChannel->getUserColor(username);
                userColor.isValid())
            {
                textColor = userColor;
            }

            auto remainder = string.remove(username);
            this->emplace<MentionElement>(username, username, originalTextColor,
                                          textColor)
                ->setTrailingSpace(remainder.isEmpty());

            if (!remainder.isEmpty())
            {
                this->emplace<TextElement>(remainder, MessageElementFlag::Text,
                                           originalTextColor);
            }

            return;
        }
    }

    this->appendOrEmplaceText(string, textColor);
}

bool MessageBuilder::isEmpty() const
{
    return this->message_->elements.empty();
}

MessageElement &MessageBuilder::back()
{
    assert(!this->isEmpty());
    return *this->message().elements.back();
}

std::unique_ptr<MessageElement> MessageBuilder::releaseBack()
{
    assert(!this->isEmpty());

    auto ptr = std::move(this->message().elements.back());
    this->message().elements.pop_back();
    return ptr;
}

TextElement *MessageBuilder::emplaceSystemTextAndUpdate(const QString &text,
                                                        QString &toUpdate)
{
    toUpdate.append(text + " ");
    return this->emplace<TextElement>(text, MessageElementFlag::Text,
                                      MessageColor::System);
}

void MessageBuilder::parseUsernameColor(const QVariantMap &tags,
                                        const QString &userID)
{
    const auto *userData = getApp()->getUserData();
    assert(userData != nullptr);

    if (const auto &user = userData->getUser(userID))
    {
        if (user->color)
        {
            this->usernameColor_ = user->color.value();
            this->message().usernameColor = this->usernameColor_;
            return;
        }
    }

    const auto iterator = tags.find("color");
    if (iterator != tags.end())
    {
        if (const auto color = iterator.value().toString(); !color.isEmpty())
        {
            this->usernameColor_ = QColor(color);
            this->message().usernameColor = this->usernameColor_;
            return;
        }
    }

    if (getSettings()->colorizeNicknames && tags.contains("user-id"))
    {
        this->usernameColor_ = getRandomColor(tags.value("user-id").toString());
        this->message().usernameColor = this->usernameColor_;
    }
}

void MessageBuilder::parseUsername(const Communi::IrcMessage *ircMessage,
                                   TwitchChannel *twitchChannel,
                                   bool trimSubscriberUsername)
{
    // username
    auto userName = ircMessage->nick();

    if (userName.isEmpty() || trimSubscriberUsername)
    {
        userName = ircMessage->tag("login").toString();
    }

    this->message_->loginName = userName;
    if (twitchChannel != nullptr)
    {
        twitchChannel->setUserColor(userName, this->message_->usernameColor);
    }

    // Update current user color if this is our message
    auto currentUser = getApp()->getAccounts()->twitch.getCurrent();
    if (ircMessage->nick() == currentUser->getUserName())
    {
        currentUser->setColor(this->message_->usernameColor);
    }
}

void MessageBuilder::parseMessageID(const QVariantMap &tags)
{
    auto iterator = tags.find("id");

    if (iterator != tags.end())
    {
        this->message().id = iterator.value().toString();
    }
}

void MessageBuilder::parseMessageTags(const QVariantMap &tags)
{
    const auto [messageType, mirrored] = parseMessageType(tags);

    if (!messageType.isEmpty())
    {
        if (messageType == "highlighted-message")
        {
            this->message().flags.set(MessageFlag::RedeemedHighlight);
        }
        else if (SUB_MESSAGE_TYPES.contains(messageType))
        {
            this->message().flags.set(MessageFlag::Subscription);
        }
        else if (messageType == "announcement")
        {
            this->message().flags.set(MessageFlag::Announcement);

            if (auto cit = tags.constFind("msg-param-color"); cit != tags.end())
            {
                this->message().announcementColor =
                    qmagicenum::enumCast<HelixAnnouncementColor>(
                        cit->toString(), qmagicenum::CASE_INSENSITIVE)
                        .value_or(HelixAnnouncementColor::Primary);
            }
        }
        else if (messageType == "viewermilestone" ||
                 messageType == "modiversary")
        {
            this->message().flags.set(MessageFlag::WatchStreak);
        }
        else
        {
            this->message().flags.set(MessageFlag::UncategorizedNotification);
        }
    }

    if (mirrored)
    {
        this->message().flags.set(MessageFlag::SharedMessage);
    }
}

QString MessageBuilder::parseRoomID(const QVariantMap &tags,
                                    TwitchChannel *twitchChannel)
{
    if (twitchChannel == nullptr)
    {
        return {};
    }

    auto iterator = tags.find("room-id");

    if (iterator != std::end(tags))
    {
        auto roomID = iterator->toString();
        if (twitchChannel->roomId() != roomID)
        {
            if (twitchChannel->roomId().isEmpty())
            {
                twitchChannel->setRoomId(roomID);
            }
            else
            {
                qCWarning(chatterinoTwitch)
                    << "The room-ID of the received message doesn't match the "
                       "room-ID of the channel - received:"
                    << roomID << "channel:" << twitchChannel->roomId();
            }
        }
        return roomID;
    }

    return {};
}

TwitchChannel *MessageBuilder::parseSharedChatInfo(const QVariantMap &tags,
                                                   TwitchChannel *twitchChannel)
{
    if (!twitchChannel)
    {
        return twitchChannel;
    }

    if (auto it = tags.find("source-room-id"); it != tags.end())
    {
        auto sourceRoom = it.value().toString();
        if (twitchChannel->roomId() != sourceRoom)
        {
            this->message().flags.set(MessageFlag::SharedMessage);

            auto sourceChan =
                getApp()->getTwitch()->getChannelOrEmptyByID(sourceRoom);
            if (sourceChan && !sourceChan->isEmpty())
            {
                // avoid duplicate pings
                this->message().flags.set(
                    MessageFlag::DoNotTriggerNotification);

                auto *chan = dynamic_cast<TwitchChannel *>(sourceChan.get());
                if (chan)
                {
                    return chan;
                }
            }
        }
    }
    return twitchChannel;
}

void MessageBuilder::parseThread(const QString &messageContent,
                                 const QVariantMap &tags,
                                 const Channel *channel,
                                 const std::shared_ptr<MessageThread> &thread,
                                 const MessagePtr &parent)
{
    if (thread)
    {
        // set references
        this->message().replyThread = thread;
        this->message().replyParent = parent;
        thread->addToThread(std::weak_ptr{this->message_});

        if (thread->subscribed())
        {
            this->message().flags.set(MessageFlag::SubscribedThread);
        }

        // enable reply flag
        this->message().flags.set(MessageFlag::ReplyMessage);

        MessagePtr threadRoot;
        if (!parent)
        {
            threadRoot = thread->root();
        }
        else
        {
            threadRoot = parent;
        }

        QString usernameText =
            stylizeUsername(threadRoot->loginName, *threadRoot);

        this->emplace<ReplyCurveElement>();

        // construct reply elements
        this->emplace<TextElement>(
                "Replying to", MessageElementFlag::RepliedMessage,
                MessageColor::System, FontStyle::ChatMediumSmall)
            ->setLink({Link::ViewThread, thread->rootId()});

        this->emplace<TextElement>(
                "@" + usernameText +
                    (threadRoot->flags.has(MessageFlag::Action) ? "" : ":"),
                MessageElementFlag::RepliedMessage, threadRoot->usernameColor,
                FontStyle::ChatMediumSmall)
            ->setLink({Link::UserInfo, threadRoot->displayName});

        MessageColor color = MessageColor::Text;
        if (threadRoot->flags.has(MessageFlag::Action))
        {
            color = threadRoot->usernameColor;
        }
        this->emplace<SingleLineTextElement>(
                threadRoot->messageText,
                MessageElementFlags({MessageElementFlag::RepliedMessage,
                                     MessageElementFlag::Text}),
                color, FontStyle::ChatMediumSmall)
            ->setLink({Link::ViewThread, thread->rootId()});
    }
    else if (tags.find("reply-parent-msg-id") != tags.end())
    {
        // Message is a reply but we couldn't find the original message.
        // Render the message using the additional reply tags

        auto replyDisplayName = tags.find("reply-parent-display-name");
        auto replyBody = tags.find("reply-parent-msg-body");

        if (replyDisplayName != tags.end() && replyBody != tags.end())
        {
            QString body;

            this->emplace<ReplyCurveElement>();
            this->emplace<TextElement>(
                "Replying to", MessageElementFlag::RepliedMessage,
                MessageColor::System, FontStyle::ChatMediumSmall);

            bool ignored = MessageBuilder::isIgnored(
                messageContent, tags.value("reply-parent-user-id").toString(),
                channel);
            if (ignored)
            {
                body = QString("[Blocked user]");
            }
            else
            {
                auto name = replyDisplayName->toString();
                body = parseTagString(replyBody->toString());

                this->emplace<TextElement>(
                        "@" + name + ":", MessageElementFlag::RepliedMessage,
                        this->textColor_, FontStyle::ChatMediumSmall)
                    ->setLink({Link::UserInfo, name});
            }

            this->emplace<SingleLineTextElement>(
                body,
                MessageElementFlags({MessageElementFlag::RepliedMessage,
                                     MessageElementFlag::Text}),
                this->textColor_, FontStyle::ChatMediumSmall);
        }
    }
}

HighlightAlert MessageBuilder::parseHighlights(const QVariantMap &tags,
                                               const QString &originalMessage,
                                               const MessageParseArgs &args)
{
    if (getSettings()->isBlacklistedUser(this->message().loginName))
    {
        // Do nothing. We ignore highlights from this user.
        return {};
    }

    auto badges = parseBadgeTag(tags);
    auto [highlighted, highlightResult] = getApp()->getHighlights()->check(
        args, badges, this->message().loginName, originalMessage,
        this->message().flags);

    if (!highlighted)
    {
        return {};
    }

    // This message triggered one or more highlights, act upon the highlight result

    this->message().flags.set(MessageFlag::Highlighted);

    this->message().highlightColor = highlightResult.color;

    if (highlightResult.showInMentions)
    {
        this->message().flags.set(MessageFlag::ShowInMentions);
    }

    auto customSound = [&] {
        if (highlightResult.customSoundUrl)
        {
            return *highlightResult.customSoundUrl;
        }
        return QUrl{};
    }();
    return {
        .customSound = customSound,
        .playSound = highlightResult.playSound,
        .windowAlert = highlightResult.alert,
    };
}

void MessageBuilder::appendChannelName(const Channel *channel)
{
    QString channelName("#" + channel->getName());
    Link link(Link::JumpToChannel, channel->getName());

    this->emplace<TextElement>(channelName, MessageElementFlag::ChannelName,
                               MessageColor::System)
        ->setLink(link);
}

void MessageBuilder::appendUsername(const QVariantMap &tags,
                                    const MessageParseArgs &args)
{
    auto *app = getApp();

    QString username = this->message_->loginName;
    QString localizedName;

    auto iterator = tags.find("display-name");
    if (iterator != tags.end())
    {
        QString displayName =
            parseTagString(iterator.value().toString()).trimmed();

        if (QString::compare(displayName, username, Qt::CaseInsensitive) == 0)
        {
            username = displayName;

            this->message().displayName = displayName;
        }
        else
        {
            localizedName = displayName;

            this->message().displayName = username;
            this->message().localizedName = displayName;
        }
    }

    QString usernameText = stylizeUsername(username, this->message());

    if (args.isSentWhisper)
    {
        // TODO(pajlada): Re-implement
        // userDisplayString +=
        // IrcManager::instance().getUser().getUserName();
    }
    else if (args.isReceivedWhisper)
    {
        // Sender username
        this->emplace<TextElement>(usernameText, MessageElementFlag::Username,
                                   this->usernameColor_,
                                   FontStyle::ChatMediumBold)
            ->setLink({Link::UserWhisper, this->message().displayName});

        auto currentUser = app->getAccounts()->twitch.getCurrent();

        // Separator
        this->emplace<TextElement>("->", MessageElementFlag::Username,
                                   MessageColor::System, FontStyle::ChatMedium);

        QColor selfColor = currentUser->color();
        MessageColor selfMsgColor =
            selfColor.isValid() ? selfColor : MessageColor::System;

        // Your own username
        this->emplace<TextElement>(currentUser->getUserName() + ":",
                                   MessageElementFlag::Username, selfMsgColor,
                                   FontStyle::ChatMediumBold);
    }
    else
    {
        if (!args.isAction)
        {
            usernameText += ":";
        }

        this->emplace<TextElement>(usernameText, MessageElementFlag::Username,
                                   this->usernameColor_,
                                   FontStyle::ChatMediumBold)
            ->setLink({Link::UserInfo, this->message().displayName});
    }
}

Outcome MessageBuilder::tryAppendEmote(TwitchChannel *twitchChannel,
                                       const EmoteName &name)
{
    auto emote = parseEmote(twitchChannel, name);

    if (!emote)
    {
        return Failure;
    }

    if (emote->zeroWidth && getSettings()->enableZeroWidthEmotes &&
        !this->isEmpty())
    {
        // Attempt to merge current zero-width emote into any previous emotes
        auto *asEmote = dynamic_cast<EmoteElement *>(&this->back());
        if (asEmote)
        {
            // Make sure to access asEmote before taking ownership when releasing
            auto baseEmote = asEmote->getEmote();
            // Need to remove EmoteElement and replace with LayeredEmoteElement
            auto baseEmoteElement = this->releaseBack();

            std::vector<LayeredEmoteElement::Emote> layers = {
                {baseEmote, baseEmoteElement->getFlags()},
                {emote, MessageElementFlag::Emote},
            };
            this->emplace<LayeredEmoteElement>(
                std::move(layers),
                baseEmoteElement->getFlags() | MessageElementFlag::Emote,
                this->textColor_);
            return Success;
        }

        auto *asLayered = dynamic_cast<LayeredEmoteElement *>(&this->back());
        if (asLayered)
        {
            asLayered->addEmoteLayer({emote, MessageElementFlag::Emote});
            asLayered->addFlags(MessageElementFlag::Emote);
            return Success;
        }

        // No emote to merge with, just show as regular emote
    }

    this->emplace<EmoteElement>(emote, MessageElementFlag::Emote,
                                this->textColor_);
    return Success;
}

void MessageBuilder::addWords(
    const QStringList &words,
    const std::vector<TwitchEmoteOccurrence> &twitchEmotes, TextState &state)
{
    // cursor currently indicates what character index we're currently operating in the full list of words
    int cursor = 0;
    auto currentTwitchEmoteIt = twitchEmotes.begin();

    for (auto word : words)
    {
        if (word.isEmpty())
        {
            cursor++;
            continue;
        }

        while (doesWordContainATwitchEmote(cursor, word, twitchEmotes,
                                           currentTwitchEmoteIt))
        {
            const auto &currentTwitchEmote = *currentTwitchEmoteIt;

            if (currentTwitchEmote.start == cursor)
            {
                // This emote exists right at the start of the word!
                this->emplace<EmoteElement>(currentTwitchEmote.ptr,
                                            MessageElementFlag::Emote,
                                            this->textColor_);

                auto len = currentTwitchEmote.name.string.length();
                cursor += len;
                word = word.mid(len);

                ++currentTwitchEmoteIt;

                if (word.isEmpty())
                {
                    // space
                    cursor += 1;
                    break;
                }
                else
                {
                    this->message().elements.back()->setTrailingSpace(false);
                }

                continue;
            }

            // Emote is not at the start

            // 1. Add text before the emote
            QString preText = word.left(currentTwitchEmote.start - cursor);
            for (auto variant :
                 getApp()->getEmotes()->getEmojis()->parse(preText))
            {
                std::visit(variant::Overloaded{
                               [&](const EmotePtr &emote) {
                                   this->addEmoji(emote);
                               },
                               [&](QStringView text) {
                                   this->addTextOrEmote(state, text.toString());
                               },
                           },
                           variant);
            }

            cursor += preText.size();

            word = word.mid(preText.size());
        }

        if (word.isEmpty())
        {
            continue;
        }

        // split words
        for (auto variant : getApp()->getEmotes()->getEmojis()->parse(word))
        {
            std::visit(variant::Overloaded{
                           [&](const EmotePtr &emote) {
                               this->addEmoji(emote);
                           },
                           [&](QStringView text) {
                               this->addTextOrEmote(state, text.toString());
                           },
                       },
                       variant);
        }

        cursor += word.size() + 1;
    }
}

void MessageBuilder::appendTwitchBadges(const QVariantMap &tags,
                                        TwitchChannel *twitchChannel)
{
    if (twitchChannel == nullptr)
    {
        return;
    }

    auto badges = parseBadgeTag(tags);

    if (this->message().flags.has(MessageFlag::SharedMessage))
    {
        const QString sourceId = tags["source-room-id"].toString();
        QString sourceName;
        QString sourceProfilePicture;
        QString sourceLogin;

        if (sourceId.isEmpty())
        {
            sourceName = "";
        }
        else
        {
            auto twitchUser = getApp()->getTwitchUsers()->resolveID({sourceId});
            sourceProfilePicture = twitchUser->profilePictureUrl;
            sourceLogin = twitchUser->name;

            if (twitchChannel->roomId() == sourceId)
            {
                // We have the source channel open, but we still need to load the profile picture URL
                sourceName = twitchChannel->getName();
            }
            else
            {
                sourceName = twitchUser->displayName;
            }
        }

        this->emplace<BadgeElement>(
            makeSharedChatBadge(sourceName, sourceProfilePicture, sourceLogin),
            MessageElementFlag::BadgeSharedChannel);

        const auto sourceBadges = parseBadgeTag(tags, "source-badges");
        const auto appendedBadges = appendSharedChatBadges(
            this, sourceBadges, sourceName, twitchChannel);

        // Dedup mod/vip badges if user is mod/vip in both chats,
        // preferring source channel's badges for the tooltips
        for (const auto &appendedBadge : appendedBadges)
        {
            if (auto b = std::ranges::find(badges, appendedBadge);
                b != badges.end())
            {
                badges.erase(b);
            }
        }
    }

    auto badgeInfos = parseBadgeInfoTag(tags);
    appendBadges(this, badges, badgeInfos, twitchChannel);
}

void MessageBuilder::appendChatterinoBadges(const QString &userID)
{
    if (auto badge = getApp()->getChatterinoBadges()->getBadge({userID}))
    {
        this->emplace<BadgeElement>(*badge,
                                    MessageElementFlag::BadgeChatterino);

        /// e.g. "chatterino:Chatterino Top donator"
        this->message().externalBadges.emplace_back((*badge)->name.string);
    }
}

void MessageBuilder::appendFfzBadges(TwitchChannel *twitchChannel,
                                     const QString &userID)
{
    for (const auto &badge : getApp()->getFfzBadges()->getUserBadges({userID}))
    {
        this->emplace<FfzBadgeElement>(
            badge.emote, MessageElementFlag::BadgeFfz, badge.color);

        /// e.g. "frankerfacez:subwoofer"
        this->message().externalBadges.emplace_back(badge.emote->name.string);
    }

    if (twitchChannel == nullptr)
    {
        return;
    }

    for (const auto &badge : twitchChannel->ffzChannelBadges(userID))
    {
        this->emplace<FfzBadgeElement>(
            badge.emote, MessageElementFlag::BadgeFfz, badge.color);

        /// e.g. "frankerfacez:subwoofer"
        this->message().externalBadges.emplace_back(badge.emote->name.string);
    }
}

void MessageBuilder::appendBttvBadges(const QString &userID)
{
    if (auto badge = getApp()->getBttvBadges()->getBadge({userID}))
    {
        this->emplace<BadgeElement>(*badge, MessageElementFlag::BadgeBttv);

        /// e.g. "betterttv:Pro Subscriber"
        this->message().externalBadges.emplace_back((*badge)->name.string);
    }
}

void MessageBuilder::appendSeventvBadges(const QString &userID)
{
    if (auto badge = getApp()->getSeventvBadges()->getBadge({userID}))
    {
        this->emplace<BadgeElement>(*badge, MessageElementFlag::BadgeSevenTV);

        /// e.g. "7tv:NNYS 2024"
        this->message().externalBadges.emplace_back((*badge)->name.string);
    }
}

Outcome MessageBuilder::tryAppendCheermote(TextState &state,
                                           const QString &string)
{
    if (state.bitsLeft == 0)
    {
        return Failure;
    }

    auto cheerOpt = state.twitchChannel->cheerEmote(string);

    if (!cheerOpt)
    {
        return Failure;
    }

    auto &cheerEmote = *cheerOpt;
    auto match = cheerEmote.regex.match(string);

    if (!match.hasMatch())
    {
        return Failure;
    }

    int cheerValue = match.captured(1).toInt();

    if (getSettings()->stackBits)
    {
        if (state.bitsStacked)
        {
            return Success;
        }
        if (cheerEmote.staticEmote)
        {
            this->emplace<EmoteElement>(cheerEmote.staticEmote,
                                        MessageElementFlag::BitsStatic,
                                        this->textColor_);
        }
        if (cheerEmote.animatedEmote)
        {
            this->emplace<EmoteElement>(cheerEmote.animatedEmote,
                                        MessageElementFlag::BitsAnimated,
                                        this->textColor_);
        }
        if (cheerEmote.color != QColor())
        {
            this->emplace<TextElement>(QString::number(state.bitsLeft),
                                       MessageElementFlag::BitsAmount,
                                       cheerEmote.color);
        }
        state.bitsStacked = true;
        return Success;
    }

    if (state.bitsLeft >= cheerValue)
    {
        state.bitsLeft -= cheerValue;
    }
    else
    {
        QString newString = string;
        newString.chop(QString::number(cheerValue).length());
        newString += QString::number(cheerValue - state.bitsLeft);

        return this->tryAppendCheermote(state, newString);
    }

    if (cheerEmote.staticEmote)
    {
        this->emplace<EmoteElement>(cheerEmote.staticEmote,
                                    MessageElementFlag::BitsStatic,
                                    this->textColor_);
    }
    if (cheerEmote.animatedEmote)
    {
        this->emplace<EmoteElement>(cheerEmote.animatedEmote,
                                    MessageElementFlag::BitsAnimated,
                                    this->textColor_);
    }
    if (cheerEmote.color != QColor())
    {
        this->emplace<TextElement>(match.captured(1),
                                   MessageElementFlag::BitsAmount,
                                   cheerEmote.color);
    }

    return Success;
}

}  // namespace chatterino
