use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::hash::{DefaultHasher, Hash as _, Hasher};
use std::iter;
use std::sync::{Arc, LazyLock};

use chrono::{DateTime, Local, Utc};
use const_format::concatcp;
use fancy_regex::{Match, Regex, RegexBuilder};
use indexmap::IndexMap;
use irc::proto;
use irc::proto::Command;
use itertools::{Either, Itertools};
use serde::{Deserialize, Serialize};
use url::Url;

pub use self::broadcast::Broadcast;
pub use self::formatting::{Color, Formatting};
pub use self::highlight::Highlight;
pub use self::source::Source;
pub use self::source::server::{Change, Kind, StandardReply};
use crate::capabilities::LabeledResponseContext;
use crate::client::Destination;
use crate::config::buffer::{CondensationFormat, UsernameFormat};
use crate::config::{self, Highlights};
use crate::history::reroute::RerouteRules;
use crate::log::Level;
use crate::reaction::Reaction;
use crate::redaction::Redaction;
use crate::serde::fail_as_none;
use crate::server::Server;
use crate::target::join_targets;
use crate::time::Posix;
use crate::user::{ChannelUsers, Nick, NickRef};
use crate::{Config, User, command, ctcp, isupport, message, target};

// References:
// - https://datatracker.ietf.org/doc/html/rfc1738#section-5
// - https://www.ietf.org/rfc/rfc2396.txt
const URL_PATH_UNRESERVED: &str = r#"\p{Letter}\p{Number}\-._~"#;

const URL_PATH_RESERVED: &str = r#":?@!&'()*+,;=\[\]"#;

static URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    RegexBuilder::new(concatcp!(
        r#"(?i)("#,
        r#"(?:https?|ircs?|wss?):\/\/"#,
        r#"[\p{Letter}\p{Number}\-@:%._+~#=]{1,256}"#,
        r#"(?:\.[\p{Letter}\p{Number}]{1,63})?"#,
        r#"\b(?:"#,
        r#"["#,
        URL_PATH_UNRESERVED,
        URL_PATH_RESERVED,
        r#"%\/#]"#,
        r#"*)|halloy:\/\/[^ ]*)"#
    ))
    .delegate_size_limit(15728640) // 1.5x default size_limit
    .build()
    .unwrap()
});

// Restrict characters per spec: https://modern.ircdocs.horse/#channels
static CHANNEL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    RegexBuilder::new(r#"(?i)(?<!\w)#([^\s,\x07]+)(?!\w)"#)
        .build()
        .unwrap()
});

// Regex for nicks per spec: https://modern.ircdocs.horse/#clients
// The capturing group is split in `MUST NOT start with` and `MUST NOT contain`
static USER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    RegexBuilder::new(r#"(?i)(?<!\w)([^ ,*?!@$:#&~%+][^ ,*?!@\.]*)(?!\w)"#)
        .build()
        .unwrap()
});

// used for matching punctuation that are commonly used at the end of a word
static EXCLUDED_TRAILING_CHARS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
    RegexBuilder::new(r#"(?i)([\.,:;?!]*)$"#).build().unwrap()
});

// matching delimiters, used to match and strip trailing chars if no matching delimiter
const PAIRED_DELIMITERS: [(char, char); 3] =
    [('(', ')'), ('{', '}'), ('[', ']')];

const SYMMETRIC_DELIMITERS: [char; 2] = ['"', '\''];

// used for matching delimiter chars at the end of a word
static EXCLUDED_TRAILING_DELIMITER_CHARS_REGEX: LazyLock<Regex> =
    LazyLock::new(|| RegexBuilder::new(r#"(?i)(["')\]}])$"#).build().unwrap());

pub(crate) mod broadcast;
pub mod formatting;
pub mod highlight;
pub mod source;

pub fn reroute_private_message_target(
    target: &Target,
    reroute_rules: &RerouteRules,
    server: &Server,
) -> Option<Target> {
    match target {
        Target::Query { query, source } => {
            reroute_rules.target_for_direct_message(query, server, source)
        }
        _ => None,
    }
}

pub fn reroute_private_notice_target(
    target: &Target,
    reroute_rules: &RerouteRules,
    server: &Server,
) -> Option<Target> {
    match target {
        Target::Query { query, source } => {
            reroute_rules.target_for_direct_notice(query, server, source)
        }
        _ => None,
    }
}

#[derive(Debug, Clone)]
pub struct Encoded(pub(crate) proto::Message);

impl Encoded {
    pub fn user(&self, casemapping: isupport::CaseMap) -> Option<User> {
        let source = self.source.as_ref()?;

        match source {
            proto::Source::User(user) => Some(User::from_proto_user(
                user.clone(),
                casemapping,
                self.from_bot(),
            )),
            _ => None,
        }
    }

    pub fn message_id(&self) -> Option<Id> {
        self.tags.get("msgid").map(|val| Id::from(&**val))
    }

    pub fn in_reply_to(&self) -> Option<Id> {
        self.tags
            .get("+reply")
            .or_else(|| self.tags.get("+draft/reply"))
            .map(|val| Id::from(&**val))
    }

    pub fn set_reply_to(&mut self, reply_id: Option<&Id>) {
        if let Some(id) = reply_id {
            self.tags.insert("+reply".to_string(), id.to_string());
            self.tags.insert("+draft/reply".to_string(), id.to_string());
        }
    }

    pub fn server_time(&self) -> Option<DateTime<Utc>> {
        self.tags
            .get("time")
            .and_then(|rfc3339| DateTime::parse_from_rfc3339(rfc3339).ok())
            .map(|dt| dt.with_timezone(&Utc))
    }

    pub fn server_time_or_now(&self) -> (DateTime<Utc>, bool) {
        let server_time = self.server_time();
        let received_with_server_time = server_time.is_some();

        (
            server_time.unwrap_or_else(Utc::now),
            received_with_server_time,
        )
    }

    pub fn from_bot(&self) -> bool {
        self.tags.contains_key("bot")
    }

    pub fn is_join_topic(&self) -> bool {
        self.tags.contains_key(":join_topic")
    }

    pub fn channel_context(
        &self,
        chantypes: &[char],
        statusmsg: &[char],
        casemapping: isupport::CaseMap,
    ) -> Option<target::Channel> {
        self.tags
            .get("+channel-context")
            .or(self.tags.get("+draft/channel-context"))
            .and_then(|channel| {
                target::Channel::parse(
                    channel,
                    chantypes,
                    statusmsg,
                    casemapping,
                )
                .ok()
            })
    }
}

impl std::ops::Deref for Encoded {
    type Target = proto::Message;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl std::ops::DerefMut for Encoded {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.0
    }
}

impl From<proto::Message> for Encoded {
    fn from(proto: proto::Message) -> Self {
        Self(proto)
    }
}

impl From<Encoded> for proto::Message {
    fn from(encoded: Encoded) -> Self {
        encoded.0
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Target {
    Server {
        source: Source,
    },
    Channel {
        channel: target::Channel,
        source: Source,
    },
    Query {
        query: target::Query,
        source: Source,
    },
    Logs {
        source: Source,
    },
    Highlights {
        server: Server,
        channel: target::Channel,
        source: Source,
    },
}

impl Target {
    pub fn prefixes(&self) -> Option<&[char]> {
        match self {
            Target::Server { .. } => None,
            Target::Channel { channel, .. } => {
                if channel.prefixes().is_empty() {
                    None
                } else {
                    Some(channel.prefixes())
                }
            }
            Target::Query { .. } => None,
            Target::Logs { .. } => None,
            Target::Highlights { .. } => None,
        }
    }

    pub fn source(&self) -> &Source {
        match self {
            Target::Server { source } => source,
            Target::Channel { source, .. } => source,
            Target::Query { source, .. } => source,
            Target::Logs { source } => source,
            Target::Highlights { source, .. } => source,
        }
    }

    pub fn source_mut(&mut self) -> &mut Source {
        match self {
            Target::Server { source } => source,
            Target::Channel { source, .. } => source,
            Target::Query { source, .. } => source,
            Target::Logs { source } => source,
            Target::Highlights { source, .. } => source,
        }
    }

    pub fn raw(&self) -> Option<&str> {
        match self {
            Target::Channel { channel, .. } => Some(channel.as_str()),
            Target::Query { query, .. } => Some(query.as_str()),
            Target::Server { .. }
            | Target::Logs { .. }
            | Target::Highlights { .. } => None,
        }
    }

    pub fn channel(&self) -> Option<&target::Channel> {
        match self {
            Target::Channel { channel, .. } => Some(channel),
            Target::Query { .. }
            | Target::Server { .. }
            | Target::Logs { .. }
            | Target::Highlights { .. } => None,
        }
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
pub enum Direction {
    Sent,
    Received,
}

#[derive(Debug, Clone, PartialEq)]
pub struct ReplyPreview {
    pub hash: Hash,
    pub server_time: DateTime<Utc>,
    pub user: Option<User>,
    pub content: Content,
    pub in_reply_to: Option<Box<ReplyPreview>>,
    pub redaction: Option<Redaction>,
    pub blocked: bool,
    pub is_action: bool,
}

impl ReplyPreview {
    pub fn preview_text(&self) -> String {
        match self {
            Self { blocked: true, .. } => {
                "Message blocked by Halloy configuration".to_string()
            }
            Self {
                redaction: Some(r), ..
            } => r.message(),
            Self {
                is_action: true,
                user: Some(user),
                ..
            } => action_preview_text(&self.content, user),
            _ => self.content.preview_text(),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Message {
    pub received_at: Posix,
    pub server_time: DateTime<Utc>,
    pub direction: Direction,
    pub target: Target,
    pub content: Content,
    pub id: Option<Id>,
    pub reply_to: Option<Id>,
    pub reply_preview: Option<ReplyPreview>,
    pub hash: Hash,
    pub hidden_urls: HashSet<Url>,
    pub is_echo: bool, // Only relevant if direction == Direction::Received
    pub received_with_server_time: bool, // Only relevant if direction == Direction::Received
    pub blocked: bool,
    pub condensed: Option<Arc<Message>>,
    pub expanded: bool, // Only relevant if can_condense or redaction.is_some()
    pub command: Option<command::Irc>, // Only relevant if direction == Direction::Sent
    pub reactions: Vec<Reaction>,
    pub rerouted_from: Option<Target>,
    pub deduplicate: bool,
    pub redaction: Option<Redaction>,
}

impl Message {
    pub fn triggers_unread(&self) -> bool {
        matches!(self.direction, Direction::Received)
            && !self.is_echo
            && match self.target.source() {
                Source::User(_) => true,
                Source::Action(_) => true,
                Source::Server(Some(server)) => server.kind().is_action(),
                Source::Internal(source::Internal::Logs(level)) => {
                    match level {
                        Level::Warn | Level::Error => true,
                        Level::Info | Level::Debug | Level::Trace => false,
                    }
                }
                _ => false,
            }
    }

    pub fn triggers_highlight(&self) -> bool {
        if matches!(self.direction, Direction::Received)
            && !self.is_echo
            && let Content::Fragments(fragments) = &self.content
            && fragments.iter().any(|fragment| {
                matches!(
                    fragment,
                    Fragment::HighlightNick(_, _) | Fragment::HighlightMatch(_)
                )
            })
        {
            true
        } else {
            false
        }
    }

    pub fn can_reference(&self) -> bool {
        if matches!(self.direction, Direction::Sent)
            || self.is_echo
            || matches!(self.target.source(), Source::Internal(_))
        {
            return false;
        } else if let Source::Server(source) = self.target.source()
            && source.as_ref().is_none_or(|source| !source.can_reference())
        {
            return false;
        }

        self.received_with_server_time || self.id.is_some()
    }

    pub fn is_rerouted(&self) -> bool {
        self.rerouted_from.is_some()
    }

    pub fn can_condense(
        &self,
        condense: &config::buffer::Condensation,
    ) -> bool {
        if let Source::Server(Some(source)) = self.target.source() {
            condense.kind(source)
        } else {
            false
        }
    }

    pub fn redaction_expanded(
        &self,
        config: &config::buffer::Redaction,
    ) -> Option<bool> {
        (self.redaction.is_some() && config.display.is_redacted())
            .then_some(self.expanded)
    }

    pub fn references(&self) -> MessageReferences {
        MessageReferences {
            timestamp: self.server_time,
            id: self.id.clone(),
        }
    }

    pub fn received<'a>(
        encoded: Encoded,
        our_nick: Nick,
        deduplicate: bool,
        config: &'a Config,
        reroute_rules: &RerouteRules,
        resolve_attributes: impl Fn(&User, &target::Channel) -> Option<User>,
        channel_users: impl Fn(&target::Channel) -> Option<&'a ChannelUsers>,
        server: &Server,
        chantypes: &[char],
        statusmsg: &[char],
        casemapping: isupport::CaseMap,
        prefix: &[isupport::PrefixMap],
    ) -> Option<Message> {
        let (server_time, received_with_server_time) =
            encoded.server_time_or_now();
        let id = encoded.message_id();
        let reply_to = encoded.in_reply_to();
        let is_echo = encoded
            .user(casemapping)
            .is_some_and(|user| user.nickname() == our_nick);
        let (target, rerouted_from) = target(
            &encoded,
            &our_nick,
            reroute_rules,
            &resolve_attributes,
            server,
            chantypes,
            statusmsg,
            casemapping,
        )?;
        let (content, _) = content(
            encoded,
            &target,
            &our_nick,
            config,
            &resolve_attributes,
            &channel_users,
            server,
            chantypes,
            statusmsg,
            casemapping,
            prefix,
        )?;
        let received_at = Posix::now();
        let hash = Hash::new(&server_time, &content, &received_at);

        Some(Message {
            received_at,
            server_time,
            direction: Direction::Received,
            target,
            content,
            id,
            reply_to,
            reply_preview: None,
            hash,
            hidden_urls: HashSet::default(),
            is_echo,
            received_with_server_time,
            blocked: false,
            condensed: None,
            expanded: false,
            command: None,
            reactions: vec![],
            rerouted_from,
            deduplicate,
            redaction: None,
        })
    }

    pub fn received_with_highlight<'a>(
        encoded: Encoded,
        our_nick: Nick,
        deduplicate: bool,
        config: &'a Config,
        reroute_rules: &RerouteRules,
        resolve_attributes: impl Fn(&User, &target::Channel) -> Option<User>,
        channel_users: impl Fn(&target::Channel) -> Option<&'a ChannelUsers>,
        is_our_message: impl Fn(&Id, &crate::history::Kind, &DateTime<Utc>) -> bool,
        server: &Server,
        chantypes: &[char],
        statusmsg: &[char],
        casemapping: isupport::CaseMap,
        prefix: &[isupport::PrefixMap],
    ) -> Option<(Message, Option<Highlight>, bool)> {
        let (server_time, received_with_server_time) =
            encoded.server_time_or_now();
        let id = encoded.message_id();
        let reply_to = encoded.in_reply_to();
        let is_echo = encoded
            .user(casemapping)
            .is_some_and(|user| user.nickname() == our_nick);
        let (target, rerouted_from) = target(
            &encoded,
            &our_nick,
            reroute_rules,
            &resolve_attributes,
            server,
            chantypes,
            statusmsg,
            casemapping,
        )?;
        let (content, highlight) = content(
            encoded,
            &target,
            &our_nick,
            config,
            &resolve_attributes,
            &channel_users,
            server,
            chantypes,
            statusmsg,
            casemapping,
            prefix,
        )?;
        let received_at = Posix::now();
        let hash = Hash::new(&server_time, &content, &received_at);

        let message = Message {
            received_at,
            server_time,
            direction: Direction::Received,
            target,
            content,
            id,
            reply_to,
            reply_preview: None,
            hash,
            hidden_urls: HashSet::default(),
            is_echo,
            received_with_server_time,
            blocked: false,
            condensed: None,
            expanded: false,
            command: None,
            reactions: vec![],
            rerouted_from,
            deduplicate,
            redaction: None,
        };

        let highlight = highlight.and_then(|kind| {
            if !message.is_echo
                && let Some((channel, user, source)) = match &message.target {
                    Target::Channel {
                        channel,
                        source: Source::User(user),
                        ..
                    } => Some((channel, user, Source::User(user.clone()))),
                    Target::Channel {
                        channel,
                        source: Source::Action(Some(user)),
                        ..
                    } => Some((
                        channel,
                        user,
                        Source::Action(Some(user.clone())),
                    )),
                    _ => None,
                }
            {
                Some(Highlight {
                    kind,
                    channel: channel.clone(),
                    user: user.clone(),
                    message: Message {
                        target: Target::Highlights {
                            server: server.clone(),
                            channel: channel.clone(),
                            source: source.clone(),
                        },
                        ..message.clone()
                    },
                })
            } else {
                None
            }
        });

        let is_reply_to_us = message.reply_to.as_ref().is_some_and(|id| {
            crate::history::Kind::from_server_message(server, &message)
                .is_some_and(|kind| {
                    is_our_message(id, &kind, &message.server_time)
                })
        });

        Some((message, highlight, is_reply_to_us))
    }

    // command, if provided, should be the command::Irc that corresponds to
    // sending only the returned Message.  If the originating command resulted
    // in other Messages, then it needs to be reformulated.
    pub fn sent(
        target: Target,
        content: Content,
        command: Option<command::Irc>,
    ) -> Self {
        let received_at = Posix::now();
        let server_time = Utc::now();
        let hash = Hash::new(&server_time, &content, &received_at);

        Message {
            received_at,
            server_time,
            direction: Direction::Sent,
            target,
            content,
            id: None,
            reply_to: None,
            reply_preview: None,
            hash,
            hidden_urls: HashSet::default(),
            is_echo: false,
            received_with_server_time: false,
            blocked: false,
            condensed: None,
            expanded: false,
            command,
            reactions: vec![],
            rerouted_from: None,
            deduplicate: false,
            redaction: None,
        }
    }

    pub fn file_transfer_request_received(
        from: &User,
        query: &target::Query,
        filename: &str,
    ) -> Message {
        let received_at = Posix::now();
        let server_time = Utc::now();
        let content = plain(format!(
            "{} wants to send you \"{filename}\"",
            from.nickname()
        ));
        let hash = Hash::new(&server_time, &content, &received_at);

        Message {
            received_at,
            server_time,
            direction: Direction::Received,
            target: Target::Query {
                query: query.clone(),
                source: Source::Action(None),
            },
            content,
            id: None,
            reply_to: None,
            reply_preview: None,
            hash,
            hidden_urls: HashSet::default(),
            is_echo: false,
            received_with_server_time: false,
            blocked: false,
            condensed: None,
            expanded: false,
            command: None,
            reactions: vec![],
            rerouted_from: None,
            deduplicate: false,
            redaction: None,
        }
    }

    pub fn file_transfer_request_sent(
        to: &User,
        query: &target::Query,
        filename: &str,
    ) -> Message {
        let received_at = Posix::now();
        let server_time = Utc::now();
        let content =
            plain(format!("Offering to send {} \"{filename}\"", to.nickname()));
        let hash = Hash::new(&server_time, &content, &received_at);

        Message {
            received_at,
            server_time,
            direction: Direction::Sent,
            target: Target::Query {
                query: query.clone(),
                source: Source::Action(None),
            },
            content,
            id: None,
            reply_to: None,
            reply_preview: None,
            hash,
            hidden_urls: HashSet::default(),
            is_echo: false,
            received_with_server_time: false,
            blocked: false,
            condensed: None,
            expanded: false,
            command: None,
            reactions: vec![],
            rerouted_from: None,
            deduplicate: false,
            redaction: None,
        }
    }

    pub fn with_target(self, target: Destination) -> Self {
        let source = self.target.source();

        Self {
            target: match target {
                Destination::Server => Target::Server {
                    source: source.clone(),
                },
                Destination::Target(target::Target::Channel(channel)) => {
                    Target::Channel {
                        channel,
                        source: source.clone(),
                    }
                }
                Destination::Target(target::Target::Query(query)) => {
                    Target::Query {
                        query,
                        source: source.clone(),
                    }
                }
            },
            ..self
        }
    }

    pub fn with_labeled_response_context(
        self,
        labeled_response_context: Option<LabeledResponseContext>,
    ) -> Self {
        if let Some(labeled_response_context) = labeled_response_context {
            Self {
                server_time: labeled_response_context.server_time,
                id: Some(labeled_response_context.label_as_id),
                ..self
            }
        } else {
            self
        }
    }

    pub fn reroute_with_target(self, target: Target) -> Self {
        Self {
            rerouted_from: Some(self.target),
            target,
            ..self
        }
    }

    pub fn plain(&self) -> Option<&str> {
        match &self.content {
            Content::Plain(s) => Some(s),
            Content::Fragments(_) => None,
            Content::Log(_) => None,
        }
    }

    pub fn text(&self) -> Cow<'_, str> {
        self.content.text()
    }

    pub fn log(record: crate::log::Record) -> Self {
        let received_at = Posix::now();
        let server_time = record.timestamp;
        let target = Target::Logs {
            source: Source::Internal(source::Internal::Logs(record.level)),
        };
        let content = Content::Log(record);
        let hash = Hash::new(&server_time, &content, &received_at);

        Self {
            received_at,
            server_time,
            direction: Direction::Received,
            target,
            content,
            id: None,
            reply_to: None,
            reply_preview: None,
            hash,
            hidden_urls: HashSet::default(),
            is_echo: false,
            received_with_server_time: false,
            blocked: false,
            condensed: None,
            expanded: false,
            command: None,
            reactions: vec![],
            rerouted_from: None,
            deduplicate: false,
            redaction: None,
        }
    }

    pub fn renormalize(&mut self, casemapping: isupport::CaseMap) {
        match self.target.source_mut() {
            Source::User(user) | Source::Action(Some(user)) => {
                user.renormalize(casemapping);
            }
            Source::Server(Some(server)) => server.renormalize(casemapping),
            _ => (),
        }

        if let Content::Fragments(fragments) = &mut self.content {
            fragments.iter_mut().for_each(|fragment| match fragment {
                Fragment::User(user, _) | Fragment::HighlightNick(user, _) => {
                    user.renormalize(casemapping);
                }
                _ => (),
            });
        }
    }

    pub fn user(&self) -> Option<&User> {
        match self.target.source() {
            Source::User(user) => Some(user),
            Source::Action(user) => user.as_ref(),
            Source::Server(_) | Source::Internal(_) => None,
        }
    }

    pub fn as_reply_preview(&self) -> ReplyPreview {
        ReplyPreview {
            hash: self.hash,
            server_time: self.server_time,
            user: self.user().cloned(),
            content: self.content.clone(),
            in_reply_to: self.reply_preview.clone().map(Box::new),
            redaction: self.redaction.clone(),
            blocked: self.blocked,
            is_action: matches!(self.target.source(), Source::Action(_)),
        }
    }
}

// When changing how Message (or its constituent parts) is serialized, run
// `data/scripts/generate-message-tests-json.sh` to produce test messages for
// backwards compatibility tests
impl Serialize for Message {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        #[derive(Serialize)]
        struct Data<'a> {
            received_at: &'a Posix,
            server_time: &'a DateTime<Utc>,
            direction: &'a Direction,
            target: &'a Target,
            content: &'a Content,
            id: &'a Option<Id>,
            #[serde(skip_serializing_if = "Option::is_none")]
            reply_to: &'a Option<Id>,
            // Old field before we had fragments,
            // added for downgrade compatibility
            text: Cow<'a, str>,
            hidden_urls: &'a HashSet<url::Url>,
            is_echo: &'a bool,
            received_with_server_time: &'a bool,
            command: &'a Option<command::Irc>,
            #[serde(skip_serializing_if = "<[_]>::is_empty")]
            reactions: &'a [Reaction],
            rerouted_from: &'a Option<Target>,
            redaction: &'a Option<Redaction>,
        }

        Data {
            received_at: &self.received_at,
            server_time: &self.server_time,
            direction: &self.direction,
            target: &self.target,
            content: &self.content,
            id: &self.id,
            reply_to: &self.reply_to,
            text: self.content.text(),
            hidden_urls: &self.hidden_urls,
            is_echo: &self.is_echo,
            received_with_server_time: &self.received_with_server_time,
            command: &self.command,
            reactions: &self.reactions,
            rerouted_from: &self.rerouted_from,
            redaction: &self.redaction,
        }
        .serialize(serializer)
    }
}

impl<'de> Deserialize<'de> for Message {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Data {
            received_at: Posix,
            server_time: DateTime<Utc>,
            direction: Direction,
            target: Target,
            // New field, optional for upgrade compatibility
            #[serde(default, deserialize_with = "fail_as_none")]
            content: Option<Content>,
            // Old field before we had fragments
            text: Option<String>,
            id: Option<Id>,
            #[serde(default, deserialize_with = "fail_as_none")]
            reply_to: Option<Id>,
            #[serde(default)]
            hidden_urls: HashSet<url::Url>,
            #[serde(default)]
            is_echo: bool,
            #[serde(default)]
            received_with_server_time: bool,
            #[serde(default, deserialize_with = "fail_as_none")]
            command: Option<command::Irc>,
            #[serde(default)]
            reactions: Vec<Reaction>,
            #[serde(default, deserialize_with = "fail_as_none")]
            rerouted_from: Option<Target>,
            #[serde(default, deserialize_with = "fail_as_none")]
            redaction: Option<Redaction>,
        }

        let Data {
            received_at,
            server_time,
            direction,
            target,
            content,
            text,
            id,
            reply_to,
            hidden_urls,
            is_echo,
            received_with_server_time,
            command,
            reactions,
            rerouted_from,
            redaction,
        } = Data::deserialize(deserializer)?;

        let content = if let Some(content) = content {
            content
        } else if let Some(text) = text {
            // First time upgrading, convert text into content
            parse_fragments(text)
        } else {
            // Unreachable
            Content::Plain(String::new())
        };

        let hash = Hash::new(&server_time, &content, &received_at);

        Ok(Message {
            received_at,
            server_time,
            direction,
            target,
            content,
            id,
            reply_to,
            reply_preview: None,
            hash,
            hidden_urls,
            is_echo,
            received_with_server_time,
            blocked: false,
            condensed: None,
            expanded: false,
            command,
            reactions,
            rerouted_from,
            deduplicate: false,
            redaction,
        })
    }
}

pub fn condense(
    messages: &[&Message],
    condense: &config::buffer::Condensation,
) -> Option<Arc<Message>> {
    if let (Some(first_message), Some(last_message)) =
        (messages.first(), messages.last())
    {
        let source = Source::Internal(source::Internal::Condensed(
            last_message.server_time,
        ));

        let target = match &first_message.target {
            Target::Server { .. } => Target::Server { source },
            Target::Channel { channel, .. } => Target::Channel {
                channel: channel.clone(),
                source,
            },
            Target::Query { query, .. } => Target::Query {
                query: query.clone(),
                source,
            },
            Target::Logs { .. } => Target::Logs { source },
            Target::Highlights {
                server, channel, ..
            } => Target::Highlights {
                server: server.clone(),
                channel: channel.clone(),
                source,
            },
        };

        let nick_associations = find_nickname_associations(messages);

        let mut condensed_fragments: IndexMap<NickRef, Vec<Fragment>> =
            IndexMap::new();

        // Convert messages to condensed fragments while grouping them based on
        // nickname association
        messages.iter().for_each(|message| {
            if let Source::Server(Some(source)) = message.target.source()
                && let Some(nick) = source.nick().map(NickRef::from)
                && let Some((nick, nick_fragment)) = match source.kind() {
                    Kind::Join => Some((
                        nick,
                        Fragment::Condensed {
                            text: String::from("+\u{FEFF}"),
                            source: source.clone(),
                        },
                    )),
                    Kind::Part => Some((
                        nick,
                        Fragment::Condensed {
                            text: String::from("-\u{FEFF}"),
                            source: source.clone(),
                        },
                    )),
                    Kind::Quit => Some((
                        nick,
                        Fragment::Condensed {
                            text: String::from("-\u{FEFF}"),
                            source: source.clone(),
                        },
                    )),
                    Kind::Kick => {
                        let kicked = if let Some(Change::Nick(kicked)) =
                            source.change()
                        {
                            Some(kicked.as_nickref())
                        } else {
                            find_other_nickname_in_message_content(
                                &message.content,
                                nick,
                            )
                        };

                        kicked.map(|kicked| {
                            // Kicks are usually associated with the kicker (for
                            // ignore purposes), but for condensation purposes
                            // we want to associate them with the kicked
                            (
                                kicked,
                                Fragment::Condensed {
                                    text: String::from("!\u{FEFF}"),
                                    source: source::Server::new(
                                        Kind::Kick,
                                        Some(kicked.to_owned()),
                                        None,
                                    ),
                                },
                            )
                        })
                    }
                    Kind::ChangeNick => {
                        if source.change().is_some() {
                            Some((
                                nick,
                                Fragment::Condensed {
                                    text: String::from("→\u{FEFF}"),
                                    source: source.clone(),
                                },
                            ))
                        } else {
                            find_other_nickname_in_message_content(
                                &message.content,
                                nick,
                            )
                            .map(NickRef::to_owned)
                            .map(|new_nick| {
                                (
                                    nick,
                                    Fragment::Condensed {
                                        text: String::from("→\u{FEFF}"),
                                        source: source::Server::new(
                                            Kind::ChangeNick,
                                            Some(nick.to_owned()),
                                            Some(Change::Nick(new_nick)),
                                        ),
                                    },
                                )
                            })
                        }
                    }
                    Kind::ChangeHost => {
                        if source.change().is_some() {
                            Some((
                                nick,
                                Fragment::Condensed {
                                    text: String::from("→\u{FEFF}"),
                                    source: source.clone(),
                                },
                            ))
                        } else {
                            // Don't try to find hostnames in message content,
                            // just hide the host change
                            None
                        }
                    }
                    _ => None,
                }
                && let Some(associated_nick) = nick_associations
                    .get(&nick)
                    .and_then(|association| match association {
                        NickAssociation::Association(_) => Some(nick),
                        NickAssociation::Associate(association_index) => {
                            nick_associations
                                .get_index(*association_index)
                                .map(|(nick, _)| *nick)
                        }
                    })
            {
                if let Some(nick_fragments) =
                    condensed_fragments.get_mut(&associated_nick)
                {
                    nick_fragments.push(nick_fragment);
                } else {
                    condensed_fragments
                        .insert(associated_nick, vec![nick_fragment]);
                }
            }
        });

        let mut condensed_fragments: Vec<Fragment> = condensed_fragments
            .into_iter()
            .filter_map(|(_, nick_fragments)| {
                filter_associated_fragments(nick_fragments, condense)
            })
            .flat_map(|nick_fragments| {
                condense_associated_fragments(nick_fragments, condense)
            })
            .collect();
        condensed_fragments.pop(); // Remove trailing whitespace fragment

        if let Some(max) = condense.max {
            let max = max as usize;
            let total_groups = condensed_fragments
                .iter()
                .filter(|f| matches!(f, Fragment::Text(_)))
                .count()
                + 1;

            if total_groups > max + 1 {
                let mut sep_count = 0;
                let truncate_pos = condensed_fragments.iter().position(|f| {
                    if matches!(f, Fragment::Text(_)) {
                        sep_count += 1;
                        sep_count == max
                    } else {
                        false
                    }
                });

                if let Some(pos) = truncate_pos {
                    condensed_fragments.truncate(pos);
                    let hidden = total_groups - max;
                    condensed_fragments
                        .push(Fragment::Text(format!("  (… {hidden} more)")));
                }
            }
        }

        Some(Arc::new(Message {
            received_at: Posix::now(),
            server_time: first_message.server_time,
            direction: Direction::Received,
            target,
            content: Content::Fragments(condensed_fragments),
            id: None,
            reply_to: None,
            reply_preview: None,
            hash: first_message.hash,
            hidden_urls: HashSet::default(),
            is_echo: false,
            received_with_server_time: false,
            blocked: false,
            condensed: None,
            expanded: false,
            command: None,
            reactions: vec![],
            rerouted_from: None,
            deduplicate: false,
            redaction: None,
        }))
    } else {
        None
    }
}

fn find_other_nickname_in_message_content<'a, 'b>(
    content: &'a Content,
    except: NickRef<'b>,
) -> Option<NickRef<'a>> {
    if let Content::Fragments(fragments) = content {
        fragments.iter().find_map(|fragment| {
            if let Fragment::User(user, _) = fragment {
                (user.nickname() != except).then_some(user.nickname())
            } else {
                None
            }
        })
    } else {
        None
    }
}

#[derive(Clone, Debug)]
enum NickAssociation<'a> {
    Association(HashSet<NickRef<'a>>),
    Associate(usize), // Index of an association (not an index of another associate)
}

// Finds associations between nicknames via nickname changes
fn find_nickname_associations<'a>(
    messages: &'a [&'a Message],
) -> IndexMap<NickRef<'a>, NickAssociation<'a>> {
    let mut nick_associations: IndexMap<NickRef, NickAssociation> =
        IndexMap::new();

    messages.iter().for_each(|message| {
        if let Source::Server(Some(source)) = message.target.source()
            && let Some(nick) = source.nick().map(NickRef::from)
        {
            match source.kind() {
                Kind::Join | Kind::Part | Kind::Quit | Kind::ChangeHost => {
                    // If the nick does not have an entry then create an
                    // association for it
                    nick_associations.entry(nick).or_insert(
                        NickAssociation::Association(HashSet::new()),
                    );
                }
                Kind::Kick => {
                    let kicked =
                        if let Some(Change::Nick(kicked)) = source.change() {
                            Some(kicked.as_nickref())
                        } else {
                            find_other_nickname_in_message_content(
                                &message.content,
                                nick,
                            )
                        };

                    if let Some(kicked) = kicked {
                        // If the nick does not have an entry then create an
                        // association for it
                        nick_associations.entry(kicked).or_insert(
                            NickAssociation::Association(HashSet::new()),
                        );
                    }
                }
                Kind::ChangeNick => {
                    let new_nick =
                        if let Some(Change::Nick(new_nick)) = source.change() {
                            Some(new_nick.as_nickref())
                        } else {
                            find_other_nickname_in_message_content(
                                &message.content,
                                nick,
                            )
                        };

                    if let Some(new_nick) = new_nick {
                        associate_nicknames(
                            nick,
                            new_nick,
                            &mut nick_associations,
                        );
                    }
                }
                _ => (),
            }
        }
    });

    nick_associations
}

fn associate_nicknames<'a>(
    nick: NickRef<'a>,
    new_nick: NickRef<'a>,
    nick_associations: &mut IndexMap<NickRef<'a>, NickAssociation<'a>>,
) {
    match (
        nick_associations.get_index_of(&nick),
        nick_associations.get_index_of(&new_nick),
    ) {
        (None, None) => {
            let (nick_index, _) = nick_associations.insert_full(
                nick,
                NickAssociation::Association(HashSet::from([new_nick])),
            );
            nick_associations
                .insert(new_nick, NickAssociation::Associate(nick_index));
        }
        (Some(nick_index), None) => {
            // Existing entry is either an association or contains the index of
            // an association.
            if let Some(association_index) = nick_associations
                .get_index(nick_index)
                .map(|(_, nick_association)| match nick_association {
                    NickAssociation::Association(_) => nick_index,
                    NickAssociation::Associate(association_index) => {
                        *association_index
                    }
                })
                && let Some((_, NickAssociation::Association(associated_nicks))) =
                    nick_associations.get_index_mut(association_index)
            {
                associated_nicks.insert(new_nick);
                nick_associations.insert(
                    new_nick,
                    NickAssociation::Associate(association_index),
                );
            }
        }
        (None, Some(new_nick_index)) => {
            // Existing entry is either an association or contains the index of
            // an association.
            if let Some(association_index) = nick_associations
                .get_index(new_nick_index)
                .map(|(_, new_nick_association)| match new_nick_association {
                    NickAssociation::Association(_) => new_nick_index,
                    NickAssociation::Associate(association_index) => {
                        *association_index
                    }
                })
                && let Some((_, NickAssociation::Association(associated_nicks))) =
                    nick_associations.get_index_mut(association_index)
            {
                associated_nicks.insert(nick);
                nick_associations.insert(
                    nick,
                    NickAssociation::Associate(association_index),
                );
            }
        }
        (Some(nick_index), Some(new_nick_index)) => {
            // Existing entries are either an association or contain the index
            // of an association.  Which association absorbs the other is
            // immaterial to how nickname associations are used, so we will
            // arbitrarily absorb new_nick's associations into nick's.
            if let Some(nick_association_index) = nick_associations
                .get_index(nick_index)
                .map(|(_, nick_association)| match nick_association {
                    NickAssociation::Association(_) => nick_index,
                    NickAssociation::Associate(association_index) => {
                        *association_index
                    }
                })
                && let Some(new_nick_association_index) = nick_associations
                    .get_index(new_nick_index)
                    .map(|(_, new_nick_association)| match new_nick_association
                    {
                        NickAssociation::Association(_) => new_nick_index,
                        NickAssociation::Associate(association_index) => {
                            *association_index
                        }
                    })
                && let Some((_, new_nick_association)) =
                    nick_associations.get_index(new_nick_association_index)
                && let new_nick_association = new_nick_association.clone()
                && let Some((_, nick_association)) =
                    nick_associations.get_index_mut(nick_association_index)
            {
                if let NickAssociation::Association(nick_associated_nicks) =
                    nick_association
                    && let NickAssociation::Association(
                        ref new_nick_associated_nicks,
                    ) = new_nick_association
                {
                    // Move all associated nicks from the absorbed association
                    // into the absorbing association
                    for new_nick_associated_nick in
                        new_nick_associated_nicks.iter()
                    {
                        nick_associated_nicks.insert(*new_nick_associated_nick);
                    }

                    // Add the key nick for the absorbed association to the
                    // absorbing association
                    nick_associated_nicks.insert(new_nick);
                }

                //
                if let NickAssociation::Association(new_nick_associated_nicks) =
                    new_nick_association
                {
                    // Update the association index for all newly absorbed nicks
                    // to the index of the absorbing association
                    for new_nick_associated_nick in
                        new_nick_associated_nicks.into_iter()
                    {
                        nick_associations.insert(
                            new_nick_associated_nick,
                            NickAssociation::Associate(nick_association_index),
                        );
                    }

                    // Overwrite absorbed association with the association index
                    // of the absorbing association
                    nick_associations.insert(
                        new_nick,
                        NickAssociation::Associate(nick_association_index),
                    );
                }
            }
        }
    }
}

fn filter_associated_fragments(
    mut fragments: Vec<Fragment>,
    condense: &config::buffer::Condensation,
) -> Option<Vec<Fragment>> {
    if matches!(condense.format, CondensationFormat::Brief) {
        // Remove the longest chain of condensed messages that results in no
        // state change
        let mut nick_initial_state: HashMap<NickRef, bool> = HashMap::new();

        for fragment in fragments.iter() {
            if let Fragment::Condensed { source, .. } = fragment
                && let Some(nick) = source.nick().map(NickRef::from)
            {
                match source.kind() {
                    Kind::Join => {
                        nick_initial_state.entry(nick).or_insert(false);
                    }
                    Kind::Part | Kind::Quit | Kind::Kick => {
                        nick_initial_state.entry(nick).or_insert(true);
                    }
                    Kind::ChangeNick => {
                        if let Some(Change::Nick(new_nick)) = source.change() {
                            nick_initial_state.entry(nick).or_insert(true);
                            nick_initial_state
                                .entry(new_nick.into())
                                .or_insert(false);
                        }
                    }
                    _ => (),
                }
            }
        }

        let mut changeless_chain_len = 0;

        let mut nick_state: HashMap<NickRef, bool> = HashMap::new();

        for (index, fragment) in fragments.iter().enumerate() {
            if let Fragment::Condensed { source, .. } = fragment
                && let Some(nick) = source.nick()
            {
                match source.kind() {
                    Kind::Join => {
                        nick_state.insert(nick.into(), true);
                    }
                    Kind::Part | Kind::Quit | Kind::Kick => {
                        nick_state.insert(nick.into(), false);
                    }
                    Kind::ChangeNick => {
                        if let Some(Change::Nick(new_nick)) = source.change() {
                            nick_state.insert(nick.into(), false);
                            nick_state.insert(new_nick.into(), true);
                        }
                    }
                    _ => (),
                }
            }

            if nick_state.iter().all(|(nick, state)| {
                nick_initial_state
                    .get(nick)
                    .is_some_and(|initial_state| state == initial_state)
            }) {
                changeless_chain_len = index + 1;
            }
        }

        (changeless_chain_len < fragments.len())
            .then_some(fragments.drain(changeless_chain_len..).collect())
    } else {
        Some(fragments)
    }
}

fn condense_associated_fragments(
    fragments: Vec<Fragment>,
    condense: &config::buffer::Condensation,
) -> Vec<Fragment> {
    let fragments = if matches!(condense.format, CondensationFormat::Brief) {
        // Condense nickname change chains
        fragments
            .into_iter()
            .chunk_by(|fragment| {
                if let Fragment::Condensed { source, .. } = &fragment
                    && matches!(source.kind(), Kind::ChangeNick)
                {
                    true
                } else {
                    false
                }
            })
            .into_iter()
            .flat_map(|(is_change_nick, fragments)| {
                if is_change_nick {
                    let mut fragments = fragments.into_iter();
                    let first_fragment = fragments.next();
                    let last_fragment = fragments.last();

                    first_fragment.and_then(|first_fragment| {
                        if let Some(last_fragment) = last_fragment {
                            if let Fragment::Condensed { text, source, .. } =
                                first_fragment
                                && let source::Server::Details(
                                    source::server::Details {
                                        nick: Some(old_nick),
                                        ..
                                    },
                                ) = source
                                && let Fragment::Condensed { source, .. } =
                                    last_fragment
                                && let source::Server::Details(
                                    source::server::Details {
                                        change: Some(Change::Nick(new_nick)),
                                        ..
                                    },
                                ) = source
                                && old_nick != new_nick
                            {
                                Some(vec![Fragment::Condensed {
                                    text,
                                    source: source::Server::new(
                                        Kind::ChangeNick,
                                        Some(old_nick),
                                        Some(Change::Nick(new_nick)),
                                    ),
                                }])
                            } else {
                                None
                            }
                        } else {
                            Some(vec![first_fragment])
                        }
                    })
                } else {
                    Some(fragments.collect())
                }
            })
            .flatten()
            .collect()
    } else {
        fragments
    };

    let fragments = if matches!(condense.format, CondensationFormat::Brief) {
        // Condense hostname change chains
        fragments
            .into_iter()
            .chunk_by(|fragment| {
                if let Fragment::Condensed { source, .. } = &fragment
                    && matches!(source.kind(), Kind::ChangeHost)
                {
                    true
                } else {
                    false
                }
            })
            .into_iter()
            .flat_map(|(is_change_host, fragments)| {
                if is_change_host {
                    let mut fragments = fragments.into_iter();
                    let first_fragment = fragments.next();
                    let last_fragment = fragments.last();

                    first_fragment.and_then(|first_fragment| {
                        if let Some(last_fragment) = last_fragment {
                            if let Fragment::Condensed { text, source, .. } =
                                first_fragment
                                && let source::Server::Details(
                                    source::server::Details {
                                        nick: Some(nick),
                                        change:
                                            Some(Change::Host(old_hostname, _)),
                                        ..
                                    },
                                ) = source
                                && let Fragment::Condensed { source, .. } =
                                    last_fragment
                                && let source::Server::Details(
                                    source::server::Details {
                                        change:
                                            Some(Change::Host(_, new_hostname)),
                                        ..
                                    },
                                ) = source
                                && old_hostname != new_hostname
                            {
                                Some(vec![Fragment::Condensed {
                                    text,
                                    source: source::Server::new(
                                        Kind::ChangeHost,
                                        Some(nick),
                                        Some(Change::Host(
                                            old_hostname,
                                            new_hostname,
                                        )),
                                    ),
                                }])
                            } else {
                                None
                            }
                        } else {
                            Some(vec![first_fragment])
                        }
                    })
                } else {
                    Some(fragments.collect())
                }
            })
            .flatten()
            .collect()
    } else {
        fragments
    };

    let fragments = if matches!(
        condense.format,
        CondensationFormat::Brief | CondensationFormat::Detailed
    ) {
        // Condense join/part/quit/kick chains
        fragments
            .into_iter()
            .chunk_by(|fragment| {
                if let Fragment::Condensed { source, .. } = &fragment
                    && matches!(
                        source.kind(),
                        Kind::Join | Kind::Part | Kind::Quit | Kind::Kick
                    )
                {
                    true
                } else {
                    false
                }
            })
            .into_iter()
            .flat_map(|(is_join_part_quit, mut nick_fragments)| {
                if is_join_part_quit {
                    let first_nick_fragment = nick_fragments.next();
                    let last_nick_fragment = nick_fragments.last();

                    first_nick_fragment.and_then(|first_nick_fragment| {
                        if let Some(last_nick_fragment) = last_nick_fragment
                            && last_nick_fragment.as_str()
                                != first_nick_fragment.as_str()
                        {
                            matches!(
                                condense.format,
                                CondensationFormat::Detailed
                            )
                            .then_some(vec![
                                first_nick_fragment,
                                last_nick_fragment,
                            ])
                        } else {
                            Some(vec![first_nick_fragment])
                        }
                    })
                } else {
                    Some(nick_fragments.collect())
                }
            })
            .flatten()
            .collect()
    } else {
        fragments
    };

    fragments
        .into_iter()
        .chunk_by(|fragment| {
            if let Fragment::Condensed { source, .. } = &fragment
                && let Some(nick) = source.nick()
            {
                Some((nick.clone(), source.change().cloned()))
            } else {
                None
            }
        })
        .into_iter()
        .flat_map(|(chunk_key, nick_fragments)| {
            if let Some((nick, change)) = chunk_key {
                let user = User::from(nick);

                // When history is incomplete it is possible to have repeat
                // Change::Nick or Change::Host fragments.
                let nick_fragments: Vec<Fragment> = match change {
                    Some(Change::Nick(new_nick)) => if matches!(
                        condense.format,
                        CondensationFormat::Brief
                            | CondensationFormat::Detailed
                    ) {
                        Either::Left(nick_fragments.take(1))
                    } else {
                        Either::Right(nick_fragments)
                    }
                    .flat_map(|nick_fragment| {
                        let nick = user.nickname().to_string() + "\u{FEFF}";

                        let new_user = User::from(new_nick.clone());

                        let new_nick = new_user.nickname().to_string();

                        vec![
                            Fragment::User(user.clone(), nick),
                            nick_fragment,
                            Fragment::User(new_user, new_nick),
                            Fragment::Text(String::from("  ")),
                        ]
                    })
                    .collect(),
                    Some(Change::Host(old_hostname, new_hostname)) => {
                        if matches!(
                            condense.format,
                            CondensationFormat::Brief
                                | CondensationFormat::Detailed
                        ) {
                            Either::Left(nick_fragments.take(1))
                        } else {
                            Either::Right(nick_fragments)
                        }
                        .flat_map(|nick_fragment| {
                            let nick = user.nickname().to_string();

                            vec![
                                Fragment::User(user.clone(), nick),
                                Fragment::Condensed {
                                    text: String::from("@"),
                                    source: source::Server::new(
                                        Kind::ChangeHost,
                                        Some(user.nickname().to_owned()),
                                        None,
                                    ),
                                },
                                Fragment::User(
                                    user.clone(),
                                    old_hostname.clone(),
                                ),
                                nick_fragment,
                                Fragment::User(
                                    user.clone(),
                                    new_hostname.clone(),
                                ),
                                Fragment::Text(String::from("  ")),
                            ]
                        })
                        .collect()
                    }
                    None => {
                        let nick = user.nickname().to_string();

                        nick_fragments
                            .chain(vec![
                                Fragment::User(user, nick),
                                Fragment::Text(String::from("  ")),
                            ])
                            .collect()
                    }
                };

                Some(nick_fragments)
            } else {
                None
            }
        })
        .flatten()
        .collect()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Hash(u64);

impl Hash {
    pub fn new(
        server_time: &DateTime<Utc>,
        content: &Content,
        received_at: &Posix,
    ) -> Self {
        let mut hasher = DefaultHasher::new();
        server_time.hash(&mut hasher);
        content.hash(&mut hasher);
        received_at.hash(&mut hasher);
        Self(hasher.finish())
    }
}

pub fn plain(text: String) -> Content {
    Content::Plain(text)
}

pub fn parse_fragments_with_highlights(
    text: String,
    message_user: Option<&User>,
    channel_users: Option<&ChannelUsers>,
    target: &target::Target,
    our_nick: &Nick,
    highlights: &Highlights,
    server: &Server,
    casemapping: isupport::CaseMap,
) -> (Content, Option<highlight::Kind>) {
    let mut highlight_kind = None;

    let mut fragments =
        parse_fragments_with_users_inner(text, channel_users, casemapping)
            .map(|fragment| match fragment {
                Fragment::User(user, raw)
                    if highlights.nickname.is_target_included(
                        message_user,
                        target.as_target_ref(),
                        server,
                        casemapping,
                    ) && (user.nickname() == *our_nick
                        && highlights.nickname.case_insensitive)
                        || raw.as_str() == our_nick.as_str() =>
                {
                    if highlight_kind.is_none() {
                        highlight_kind = Some(highlight::Kind::Nick);
                    }

                    Fragment::HighlightNick(user, raw)
                }
                f => f,
            })
            .collect::<Vec<_>>();

    for (regex, sound) in highlights.matches.iter().filter_map(|m| {
        m.is_target_included(
            message_user,
            target.as_target_ref(),
            server,
            casemapping,
        )
        .then_some((&m.regex, &m.sound))
    }) {
        fragments = fragments
            .into_iter()
            .flat_map(|fragment| {
                if let Fragment::Text(text) = &fragment {
                    return Either::Left(
                        parse_regex_fragments(regex, text, |text| {
                            let set_highlight_kind = if highlight_kind.is_none()
                            {
                                true
                            } else if sound.is_some()
                                && let Some(highlight::Kind::Match {
                                    sound: highlight_kind_sound,
                                    ..
                                }) = &highlight_kind
                                && highlight_kind_sound.is_none()
                            {
                                true
                            } else {
                                false
                            };

                            if set_highlight_kind {
                                highlight_kind = Some(highlight::Kind::Match {
                                    matching: regex.to_string(),
                                    sound: sound.clone(),
                                });
                            }

                            Some(Fragment::HighlightMatch(text.to_owned()))
                        })
                        .into_iter(),
                    );
                }

                Either::Right(iter::once(fragment))
            })
            .collect();
    }

    if fragments.len() == 1 && matches!(&fragments[0], Fragment::Text(_)) {
        let Some(Fragment::Text(text)) = fragments.into_iter().next() else {
            unreachable!();
        };

        (Content::Plain(text), None)
    } else {
        (Content::Fragments(fragments), highlight_kind)
    }
}

pub fn parse_fragments_with_user(
    text: String,
    user: &User,
    casemapping: isupport::CaseMap,
) -> Content {
    // XXX(pounce) annoying clone. Cow somewhere?
    parse_fragments_with_users(
        text,
        Some(&[user.clone()].into_iter().collect()),
        casemapping,
    )
}

pub fn parse_fragments_with_users(
    text: String,
    channel_users: Option<&ChannelUsers>,
    casemapping: isupport::CaseMap,
) -> Content {
    let fragments =
        parse_fragments_with_users_inner(text, channel_users, casemapping)
            .collect::<Vec<_>>();

    if fragments.len() == 1 && matches!(&fragments[0], Fragment::Text(_)) {
        let Some(Fragment::Text(text)) = fragments.into_iter().next() else {
            unreachable!();
        };

        Content::Plain(text)
    } else {
        Content::Fragments(fragments)
    }
}

pub fn parse_fragments(text: String) -> Content {
    let fragments = parse_fragments_inner(text).collect::<Vec<_>>();

    if fragments.len() == 1 && matches!(&fragments[0], Fragment::Text(_)) {
        let Some(Fragment::Text(text)) = fragments.into_iter().next() else {
            unreachable!();
        };

        Content::Plain(text)
    } else {
        Content::Fragments(fragments)
    }
}

fn parse_fragments_with_users_inner(
    text: String,
    channel_users: Option<&ChannelUsers>,
    casemapping: isupport::CaseMap,
) -> impl Iterator<Item = Fragment> + use<'_> {
    parse_fragments_inner(text).flat_map(move |fragment| {
        if let Fragment::Text(text) = &fragment {
            return Either::Left(
                parse_regex_fragments(&USER_REGEX, text, |text| {
                    channel_users?
                        .get_by_nick(
                            Nick::from_str(text, casemapping).as_nickref(),
                        )
                        .map(|user| {
                            Fragment::User(user.clone(), text.to_owned())
                        })
                })
                .into_iter(),
            );
        }

        Either::Right(iter::once(fragment))
    })
}

fn parse_fragments_inner<'a>(
    text: String,
) -> impl Iterator<Item = Fragment> + use<'a> {
    let mut modifiers = HashSet::new();
    let mut fg = None;
    let mut bg = None;

    // To preserve the integrity of the code block, formatting is
    // ignored and links not detected within code blocks.  When code
    // styling is able to encompass multiple Spans we should reconsider
    // whether we want to permit formatting and/or link detection within
    // code blocks.
    formatting::parse_code_fragments(&text)
        .into_iter()
        .map(Fragment::from)
        .flat_map(|fragment| {
            if let Fragment::Text(text) = &fragment {
                return Either::Left(
                    parse_regex_fragments(&URL_REGEX, text, |url| {
                        Url::parse(url).ok().map(|canonical| {
                            Fragment::Url(canonical, url.to_string())
                        })
                    })
                    .into_iter(),
                );
            }

            Either::Right(iter::once(fragment))
        })
        .flat_map(|fragment| {
            if let Fragment::Text(text) = &fragment {
                return Either::Left(
                    parse_regex_fragments(&CHANNEL_REGEX, text, |channel| {
                        Some(Fragment::Channel(channel.to_owned()))
                    })
                    .into_iter(),
                );
            }

            Either::Right(iter::once(fragment))
        })
        .flat_map(move |fragment| {
            if let Fragment::Text(text) = &fragment {
                if let Some(fragments) = formatting::parse_fragments(
                    text,
                    &mut modifiers,
                    &mut fg,
                    &mut bg,
                ) {
                    if fragments.is_empty() {
                        return Either::Right(Either::Left(iter::empty()));
                    }

                    if fragments.iter().any(|fragment| {
                        matches!(
                            fragment,
                            formatting::Fragment::Formatted(_, _)
                        )
                    }) {
                        return Either::Left(
                            fragments.into_iter().map(Fragment::from),
                        );
                    // If there are no Formatted fragments,
                    // then fragments should contain a single Unformatted fragment
                    } else if let Some(text) = fragments
                        .into_iter()
                        .next()
                        .and_then(|fragment| match fragment {
                            formatting::Fragment::Unformatted(text) => {
                                Some(text)
                            }
                            formatting::Fragment::Formatted(_, _) => None,
                        })
                    {
                        // Even if the fragment is Unformatted there may have been formatting
                        // characters in the text input into formatting::parse. They are
                        // stripped from the text contained in the fragment.
                        return Either::Right(Either::Right(iter::once(
                            Fragment::Text(text),
                        )));
                    }
                } else if text.is_empty() {
                    return Either::Right(Either::Left(iter::empty()));
                } else {
                    return Either::Right(Either::Right(iter::once(
                        Fragment::Text(text.clone()),
                    )));
                }
            }

            Either::Right(Either::Right(iter::once(fragment)))
        })
}

fn parse_regex_fragments<'a>(
    regex: &Regex,
    text: impl Into<Cow<'a, str>>,
    mut f: impl FnMut(&str) -> Option<Fragment>,
) -> Vec<Fragment> {
    let text: Cow<'a, str> = text.into();

    let mut i = 0;
    let mut fragments = Vec::with_capacity(1);

    for re_match in regex.find_iter(&text).filter_map(Result::ok) {
        let (matching, trailing_punctuation) =
            filter_trailing_punctuation(re_match, &text);
        let (matching, trailing_delimiter) = filter_trailing_delimiter(
            matching,
            re_match.start(),
            re_match.end(),
            &text,
        );
        let (matching, leading_delimiter) = filter_leading_delimiter(matching);

        if let Some(fragment) = (f)(matching) {
            let mut leading_text = String::new();
            if i < re_match.start() {
                leading_text.push_str(&text[i..re_match.start()]);
            }
            if let Some(delimiter) = leading_delimiter {
                leading_text.push_str(delimiter);
            }
            if !leading_text.is_empty() {
                merge_text_fragment(&mut fragments, leading_text);
            }

            fragments.push(fragment);

            if trailing_delimiter.is_some() || trailing_punctuation.is_some() {
                let mut trailing = String::new();
                if let Some(delimiter) = trailing_delimiter {
                    trailing.push_str(delimiter);
                }
                if let Some(punctuation) = trailing_punctuation {
                    trailing.push_str(punctuation);
                }
                fragments.push(Fragment::Text(trailing));
            }

            i = re_match.end();
        }
    }

    let next_fragment_text = if i == 0 { &text } else { &text[i..] };
    merge_text_fragment(&mut fragments, next_fragment_text.to_string());

    fragments
}

fn merge_text_fragment(fragments: &mut Vec<Fragment>, text: String) {
    if text.is_empty() {
        return;
    }
    match fragments.pop() {
        Some(Fragment::Text(mut fragment_text)) => {
            fragment_text.push_str(&text);
            fragments.push(Fragment::Text(fragment_text));
        }
        fragment => {
            if let Some(fragment) = fragment {
                fragments.push(fragment);
            }
            fragments.push(Fragment::Text(text));
        }
    }
}

fn filter_trailing_punctuation<'a>(
    re_match: Match<'a>,
    text: &str,
) -> (&'a str, Option<&'a str>) {
    let matching = re_match.as_str();
    let (matching, trailing) = if let Some(Ok(trailing)) =
        EXCLUDED_TRAILING_CHARS_REGEX.find_iter(matching).next()
    {
        let trimmed_end = trailing.start();
        let is_end_of_text = re_match.end() >= text.len()
            || text[re_match.end()..].starts_with(|c: char| c.is_whitespace());

        if trimmed_end > 0 && trimmed_end < matching.len() && is_end_of_text {
            (&matching[..trimmed_end], Some(&matching[trimmed_end..]))
        } else {
            (matching, None)
        }
    } else {
        (matching, None)
    };

    (matching, trailing)
}

fn filter_trailing_delimiter<'a>(
    matching: &'a str,
    start: usize,
    end: usize,
    text: &str,
) -> (&'a str, Option<&'a str>) {
    let Some(Ok(trailing)) = EXCLUDED_TRAILING_DELIMITER_CHARS_REGEX
        .find_iter(matching)
        .next()
    else {
        return (matching, None);
    };

    let preceding_match = &text[..start];

    let mut trim_at = None;
    for (i, ch) in trailing.as_str().char_indices() {
        let preceding_trail = &matching[..trailing.start() + i];

        let filter = if SYMMETRIC_DELIMITERS.contains(&ch) {
            let unmatched_before_match =
                preceding_match.matches(ch).count() % 2 == 1;
            let unmatched_before_trail =
                preceding_trail.matches(ch).count() % 2 == 0;
            unmatched_before_match && unmatched_before_trail
        } else if let Some((open, _)) =
            PAIRED_DELIMITERS.iter().find(|(_, close)| *close == ch)
        {
            let orphaned_before_match = preceding_match.matches(*open).count()
                > preceding_match.matches(ch).count();
            let immediately_preceding = preceding_match.ends_with(*open);
            let orphaned_in_match = preceding_trail.matches(*open).count()
                <= preceding_trail.matches(ch).count();
            (orphaned_before_match || immediately_preceding)
                && orphaned_in_match
        } else {
            continue;
        };

        if filter {
            trim_at = Some(trailing.start() + i);
            break;
        }
    }

    if let Some(trimmed_end) = trim_at {
        let is_end_of_text = end >= text.len()
            || text[end..].starts_with(|c: char| c.is_whitespace())
            || matches!(
                EXCLUDED_TRAILING_CHARS_REGEX.find_iter(&text[end..]).next(),
                Some(Ok(_))
            );
        if trimmed_end > 0 && trimmed_end < matching.len() && is_end_of_text {
            (&matching[..trimmed_end], Some(&matching[trimmed_end..]))
        } else {
            (matching, None)
        }
    } else {
        (matching, None)
    }
}

fn filter_leading_delimiter(matching: &str) -> (&str, Option<&str>) {
    let Some(first_char) = matching.chars().next() else {
        return (matching, None);
    };

    if SYMMETRIC_DELIMITERS.contains(&first_char) {
        return (&matching[1..], Some(&matching[..1]));
    }

    (matching, None)
}

#[derive(Debug, Clone, Eq, Serialize, Deserialize)]
pub enum Content {
    Plain(String),
    Fragments(Vec<Fragment>),
    Log(crate::log::Record),
}

impl Content {
    pub fn text(&self) -> Cow<'_, str> {
        match self {
            Content::Plain(s) => s.into(),
            Content::Fragments(fragments) => {
                fragments.iter().map(Fragment::as_str).join("").into()
            }
            Content::Log(record) => (&record.message).into(),
        }
    }

    pub fn preview_text(&self) -> String {
        static NEWLINES: LazyLock<Regex> =
            LazyLock::new(|| Regex::new(r"\n+").unwrap());
        NEWLINES.replace_all(&self.text(), " ").into_owned()
    }

    pub fn echo_cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.text().trim_end().cmp(other.text().trim_end())
    }
}

impl PartialEq for Content {
    fn eq(&self, other: &Self) -> bool {
        self.text() == other.text()
    }
}

impl std::hash::Hash for Content {
    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
        self.text().hash(state);
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Fragment {
    Text(String),
    Channel(String),
    User(User, String),
    Url(url::Url, String),
    Formatted {
        text: String,
        formatting: Formatting,
    },
    HighlightNick(User, String),
    HighlightMatch(String),
    Condensed {
        text: String,
        source: source::Server,
    },
}

impl Fragment {
    pub fn url(&self) -> Option<&url::Url> {
        if let Self::Url(url, _) = self {
            Some(url)
        } else {
            None
        }
    }

    pub fn as_str(&self) -> &str {
        match self {
            Fragment::Text(s) => s,
            Fragment::Channel(c) => c,
            Fragment::User(_, t) => t,
            Fragment::Url(_, s) => s,
            Fragment::Formatted { text, .. } => text,
            Fragment::HighlightNick(_, s) => s,
            Fragment::HighlightMatch(s) => s,
            Fragment::Condensed { text, .. } => text,
        }
    }
}

impl From<formatting::Fragment> for Fragment {
    fn from(value: formatting::Fragment) -> Self {
        match value {
            formatting::Fragment::Unformatted(text) => Self::Text(text),
            formatting::Fragment::Formatted(text, formatting) => {
                Self::Formatted { text, formatting }
            }
        }
    }
}

fn target(
    message: &Encoded,
    our_nick: &Nick,
    reroute_rules: &RerouteRules,
    resolve_attributes: &dyn Fn(&User, &target::Channel) -> Option<User>,
    server: &Server,
    chantypes: &[char],
    statusmsg: &[char],
    casemapping: isupport::CaseMap,
) -> Option<(Target, Option<Target>)> {
    use proto::command::Numeric::*;

    let user = message.user(casemapping);

    match &message.command {
        // Channel
        Command::MODE(target, ..) => {
            if let Ok(channel) = target::Channel::parse(
                target,
                chantypes,
                statusmsg,
                casemapping,
            ) {
                Some((
                    Target::Channel {
                        channel,
                        source: Source::Server(Some(source::Server::new(
                            Kind::ChangeMode,
                            Some(user?.nickname().to_owned()),
                            None,
                        ))),
                    },
                    None,
                ))
            } else {
                Some((
                    Target::Server {
                        source: Source::Server(None),
                    },
                    None,
                ))
            }
        }
        Command::TOPIC(channel, _) => {
            let channel = target::Channel::parse(
                channel,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(Some(source::Server::new(
                        Kind::ChangeTopic,
                        Some(user?.nickname().to_owned()),
                        None,
                    ))),
                },
                None,
            ))
        }
        Command::KICK(channel, victim, _) => {
            let channel = target::Channel::parse(
                channel,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(Some(source::Server::new(
                        Kind::Kick,
                        Some(user?.nickname().to_owned()),
                        Some(Change::Nick(Nick::from_str(
                            victim.as_str(),
                            casemapping,
                        ))),
                    ))),
                },
                None,
            ))
        }
        Command::PART(channel, _) => {
            let channel = target::Channel::parse(
                channel,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(Some(source::Server::new(
                        Kind::Part,
                        Some(user?.nickname().to_owned()),
                        None,
                    ))),
                },
                None,
            ))
        }
        Command::QUIT(_) => Some((
            Target::Server {
                source: source::Source::Server(Some(source::Server::new(
                    source::server::Kind::Quit,
                    Some(user?.nickname().to_owned()),
                    None,
                ))),
            },
            None,
        )),
        Command::JOIN(channel, _) => {
            let channel = target::Channel::parse(
                channel,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(Some(source::Server::new(
                        Kind::Join,
                        Some(user?.nickname().to_owned()),
                        None,
                    ))),
                },
                None,
            ))
        }
        Command::NICK(new_nick) => {
            let new_nick = Nick::from_str(new_nick, casemapping);

            Some((
                Target::Server {
                    source: source::Source::Server(Some(source::Server::new(
                        source::server::Kind::ChangeNick,
                        Some(user?.nickname().to_owned()),
                        Some(source::server::Change::Nick(new_nick)),
                    ))),
                },
                None,
            ))
        }
        Command::Numeric(
            RPL_TOPIC | RPL_TOPICWHOTIME | RPL_NOTOPIC,
            params,
        ) => {
            let channel = target::Channel::parse(
                params.get(1)?,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;
            let kind = if message.is_join_topic() {
                Kind::JoinTopic
            } else {
                Kind::RequestTopic
            };

            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(Some(source::Server::new(
                        kind, None, None,
                    ))),
                },
                None,
            ))
        }
        Command::Numeric(RPL_CHANNELMODEIS, params) => {
            let channel = target::Channel::parse(
                params.get(1)?,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;
            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(None),
                },
                None,
            ))
        }
        Command::Numeric(RPL_AWAY, params) => {
            let user = params.get(1)?;

            let query =
                target::Query::parse(user, chantypes, statusmsg, casemapping)
                    .ok()?;

            Some((
                Target::Query {
                    query,
                    source: Source::Server(Some(source::Server::new(
                        Kind::Away,
                        Some(Nick::from_string(user.clone(), casemapping)),
                        None,
                    ))),
                },
                None,
            ))
        }
        Command::Numeric(
            ERR_NOSUCHCHANNEL | ERR_TOOMANYCHANNELS | ERR_CHANNELISFULL
            | ERR_INVITEONLYCHAN | ERR_BANNEDFROMCHAN | ERR_BADCHANNELKEY
            | ERR_BADCHANMASK,
            params,
        ) => {
            let channel = target::Channel::parse(
                params.get(1)?,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(Some(source::Server::new(
                        Kind::StandardReply(StandardReply::Fail),
                        None,
                        None,
                    ))),
                },
                None,
            ))
        }
        Command::PRIVMSG(target, text) | Command::NOTICE(target, text) => {
            let is_notice = matches!(message.0.command, Command::NOTICE(_, _));
            let is_privmsg =
                matches!(message.0.command, Command::PRIVMSG(_, _));

            let is_action = is_action(text);

            let source = |user| {
                if is_action {
                    Source::Action(Some(user))
                } else {
                    Source::User(user)
                }
            };

            let resolve_attributes_with_bot =
                |user: &User, channel: &target::Channel| -> Option<User> {
                    resolve_attributes(user, channel)
                        .map(|user| user.with_bot(message.from_bot()))
                };

            if target == "*" {
                let source = user.map_or(Source::Server(None), source);

                return Some((Target::Server { source }, None));
            }

            // Mass message
            if target.starts_with('$') {
                if let Some(user) = user {
                    return Some((
                        Target::Query {
                            query: target::Query::from(user.clone()),
                            source: source(user),
                        },
                        None,
                    ));
                } else {
                    return Some((
                        Target::Server {
                            source: Source::Server(None),
                        },
                        None,
                    ));
                }
            }

            // CTCP Handling.
            let (target, rerouted_from) = if ctcp::is_query(text) && !is_action
            {
                let user = user.as_ref()?;
                let target = User::from(Nick::from_str(target, casemapping));

                // We want to show both requests, and responses in query with the client.
                let user = if user.nickname() == *our_nick {
                    target
                } else {
                    user.clone()
                };

                (
                    Target::Query {
                        query: target::Query::from(user),
                        source: Source::Server(None),
                    },
                    None,
                )
            } else {
                match (
                    target::Target::parse(
                        target,
                        chantypes,
                        statusmsg,
                        casemapping,
                    ),
                    &user,
                ) {
                    (target::Target::Channel(channel), Some(user)) => {
                        let source = source(
                            resolve_attributes_with_bot(user, &channel)
                                .unwrap_or(user.clone()),
                        );
                        (Target::Channel { channel, source }, None)
                    }
                    (target::Target::Channel(channel), None) => (
                        Target::Channel {
                            channel,
                            source: Source::Server(None),
                        },
                        None,
                    ),
                    (target::Target::Query(query), Some(user)) => {
                        let query = if user.nickname() == *our_nick {
                            // Message from ourself, from another client.
                            query
                        } else {
                            // Message from conversation partner.
                            target::Query::from(user.clone())
                        };

                        let target = Target::Query {
                            query,
                            source: source(user.clone()),
                        };

                        if is_privmsg
                            && let Some(rerouted_target) =
                                reroute_private_message_target(
                                    &target,
                                    reroute_rules,
                                    server,
                                )
                        {
                            (rerouted_target, Some(target))
                        } else if is_notice
                            && let Some(rerouted_target) =
                                reroute_private_notice_target(
                                    &target,
                                    reroute_rules,
                                    server,
                                )
                        {
                            (rerouted_target, Some(target))
                        } else {
                            (target, None)
                        }
                    }
                    (target::Target::Query(_), None) => (
                        Target::Server {
                            source: Source::Server(None),
                        },
                        None,
                    ),
                }
            };

            let channel_context =
                message.channel_context(chantypes, statusmsg, casemapping);

            Some(if let Some(channel_context) = channel_context {
                // Resolve attributes in the context channel
                let source =
                    user.as_ref().map_or(Source::Server(None), |user| {
                        source(
                            resolve_attributes_with_bot(user, &channel_context)
                                .unwrap_or(user.clone()),
                        )
                    });

                let channel_context = Target::Channel {
                    channel: channel_context,
                    source,
                };

                if rerouted_from.is_some() {
                    (channel_context, rerouted_from)
                } else {
                    (channel_context, Some(target))
                }
            } else if rerouted_from.is_some()
                && let Target::Channel { channel, source } = target
            {
                // Resolve attributes in the target channel
                let source = match source {
                    Source::User(user) => Source::User(
                        resolve_attributes_with_bot(&user, &channel)
                            .unwrap_or(user),
                    ),
                    Source::Action(Some(user)) => Source::Action(Some(
                        resolve_attributes_with_bot(&user, &channel)
                            .unwrap_or(user),
                    )),
                    Source::Action(None)
                    | Source::Server(_)
                    | Source::Internal(_) => source,
                };

                (Target::Channel { channel, source }, rerouted_from)
            } else {
                (target, rerouted_from)
            })
        }
        Command::Numeric(RPL_MONONLINE, _) => Some((
            Target::Server {
                source: Source::Server(Some(source::Server::new(
                    Kind::MonitoredOnline,
                    None,
                    None,
                ))),
            },
            None,
        )),
        Command::Numeric(RPL_MONOFFLINE, _) => Some((
            Target::Server {
                source: Source::Server(Some(source::Server::new(
                    Kind::MonitoredOffline,
                    None,
                    None,
                ))),
            },
            None,
        )),
        Command::FAIL(_, _, _, _) => Some((
            Target::Server {
                source: Source::Server(Some(source::Server::new(
                    Kind::StandardReply(StandardReply::Fail),
                    None,
                    None,
                ))),
            },
            None,
        )),
        Command::WARN(_, _, _, _) => Some((
            Target::Server {
                source: Source::Server(Some(source::Server::new(
                    Kind::StandardReply(StandardReply::Warn),
                    None,
                    None,
                ))),
            },
            None,
        )),
        Command::NOTE(_, _, _, _) => Some((
            Target::Server {
                source: Source::Server(Some(source::Server::new(
                    Kind::StandardReply(StandardReply::Note),
                    None,
                    None,
                ))),
            },
            None,
        )),
        Command::WALLOPS(_) => Some((
            Target::Server {
                source: Source::Server(Some(source::Server::new(
                    Kind::WAllOps,
                    None,
                    None,
                ))),
            },
            None,
        )),
        Command::Numeric(ERR_CANNOTSENDTOCHAN, params) => {
            match target::Target::parse(
                params.get(1)?,
                chantypes,
                statusmsg,
                casemapping,
            ) {
                target::Target::Channel(channel) => Some((
                    Target::Channel {
                        channel,
                        source: Source::Server(None),
                    },
                    None,
                )),
                target::Target::Query(query) => Some((
                    Target::Query {
                        query,
                        source: Source::Server(None),
                    },
                    None,
                )),
            }
        }
        Command::INVITE(invitee, channel) => {
            let invitee = User::from(Nick::from_str(invitee, casemapping));

            if invitee.nickname() == *our_nick {
                if let Some(user) = user {
                    return Some((
                        Target::Query {
                            query: target::Query::from(user),
                            source: Source::Server(None),
                        },
                        None,
                    ));
                } else {
                    return Some((
                        Target::Server {
                            source: Source::Server(None),
                        },
                        None,
                    ));
                }
            }

            let channel = target::Channel::parse(
                channel,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            Some((
                Target::Channel {
                    channel,
                    source: Source::Server(None),
                },
                None,
            ))
        }
        // Server
        Command::PASS(_)
        | Command::CHGHOST(_, _)
        | Command::USER(_, _)
        | Command::OPER(_, _)
        | Command::SQUIT(_, _)
        | Command::NAMES(_)
        | Command::LIST(_, _)
        | Command::MOTD(_)
        | Command::LUSERS
        | Command::VERSION(_)
        | Command::STATS(_, _)
        | Command::LINKS
        | Command::TIME(_)
        | Command::CONNECT(_, _, _)
        | Command::ADMIN(_)
        | Command::INFO
        | Command::WHO(_, _, _)
        | Command::WHOIS(_, _)
        | Command::WHOWAS(_, _)
        | Command::KILL(_, _)
        | Command::PING(_)
        | Command::PONG(_, _)
        | Command::ERROR(_)
        | Command::AWAY(_)
        | Command::REHASH
        | Command::RESTART
        | Command::USERHOST(_)
        | Command::CAP(_, _, _, _)
        | Command::AUTHENTICATE(_)
        | Command::ACCOUNT(_)
        | Command::BATCH(_, _)
        | Command::CHATHISTORY(_, _)
        | Command::CNOTICE(_, _, _)
        | Command::CPRIVMSG(_, _, _)
        | Command::KNOCK(_, _)
        | Command::MARKREAD(_, _)
        | Command::MONITOR(_, _)
        | Command::SETNAME(_)
        | Command::TAGMSG(_)
        | Command::USERIP(_)
        | Command::HELP(_)
        | Command::Numeric(_, _)
        | Command::Unknown(_, _)
        | Command::BOUNCER(_, _)
        | Command::REDACT(_, _, _)
        | Command::METADATA(_, _)
        | Command::Raw(_) => Some((
            Target::Server {
                source: Source::Server(None),
            },
            None,
        )),
    }
}

pub type Id = Arc<str>;

fn content<'a>(
    message: Encoded,
    message_target: &Target,
    our_nick: &Nick,
    config: &Config,
    resolve_attributes: &dyn Fn(&User, &target::Channel) -> Option<User>,
    channel_users: impl Fn(&target::Channel) -> Option<&'a ChannelUsers>,
    server: &Server,
    chantypes: &[char],
    statusmsg: &[char],
    casemapping: isupport::CaseMap,
    prefix: &[isupport::PrefixMap],
) -> Option<(Content, Option<highlight::Kind>)> {
    use irc::proto::command::Numeric::*;

    match &message.command {
        Command::TOPIC(target, topic) => {
            let raw_user = message.user(casemapping)?;
            let user = target::Channel::parse(
                target,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()
            .and_then(|channel| resolve_attributes(&raw_user, &channel))
            .unwrap_or(raw_user);

            let topic = topic.as_ref()?;

            if topic.is_empty() {
                Some((
                    parse_fragments_with_user(
                        format!("{} cleared the topic", user.nickname()),
                        &user,
                        casemapping,
                    ),
                    None,
                ))
            } else {
                Some((
                    parse_fragments_with_user(
                        format!(
                            "{} changed the topic to {topic}",
                            user.nickname()
                        ),
                        &user,
                        casemapping,
                    ),
                    None,
                ))
            }
        }
        Command::PART(target, text) => {
            let raw_user = message.user(casemapping)?;
            let user = target::Channel::parse(
                target,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()
            .and_then(|channel| resolve_attributes(&raw_user, &channel))
            .unwrap_or(raw_user);

            let text = text
                .as_ref()
                .map(|text| format!(" ({text})"))
                .unwrap_or_default();

            Some((
                parse_fragments_with_user(
                    format!(
                        "{} {} has left the channel{text}",
                        config.display.direction_arrows.left,
                        user.formatted(
                            config
                                .buffer
                                .server_messages
                                .username_format(Some(Kind::Part))
                        )
                    ),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::JOIN(target, _) => {
            let raw_user = message.user(casemapping)?;
            let user = target::Channel::parse(
                target,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()
            .and_then(|channel| resolve_attributes(&raw_user, &channel))
            .unwrap_or(raw_user);

            (user.nickname() != *our_nick).then(|| {
                (
                    parse_fragments_with_user(
                        format!(
                            "{} {} has joined the channel",
                            config.display.direction_arrows.right,
                            user.formatted(
                                config
                                    .buffer
                                    .server_messages
                                    .username_format(Some(Kind::Join))
                            )
                        ),
                        &user,
                        casemapping,
                    ),
                    None,
                )
            })
        }
        Command::NICK(new_nick) => {
            let old_user = message.user(casemapping)?;
            let ourself = old_user.nickname() == *our_nick;

            let new_nick = Nick::from_str(new_nick.as_str(), casemapping);

            Some((
                nickname_text(
                    old_user.nickname(),
                    new_nick.as_nickref(),
                    ourself,
                    casemapping,
                ),
                None,
            ))
        }
        Command::QUIT(comment) => {
            let user = message.user(casemapping)?;

            Some((quit_text(&user, comment, config, casemapping), None))
        }
        Command::KICK(channel, victim, reason) => {
            let channel = target::Channel::parse(
                channel,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok();

            let raw_victim_user =
                User::from(Nick::from_str(victim.as_str(), casemapping));
            let victim = channel
                .as_ref()
                .and_then(|channel| {
                    resolve_attributes(&raw_victim_user, channel)
                })
                .unwrap_or(raw_victim_user);

            let ourself = victim.nickname() == *our_nick;

            let raw_user = message.user(casemapping)?;
            let user = channel
                .as_ref()
                .and_then(|channel| resolve_attributes(&raw_user, channel))
                .unwrap_or(raw_user);

            Some((
                kick_text(
                    user,
                    victim,
                    ourself,
                    reason,
                    None,
                    &config.display.direction_arrows,
                    casemapping,
                ),
                None,
            ))
        }
        Command::MODE(target, modes, args) => {
            let raw_user = message.user(casemapping)?;

            let modes = modes
                .iter()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(" ");

            let mut args = args
                .iter()
                .flatten()
                .map(ToString::to_string)
                .collect::<Vec<_>>()
                .join(" ");

            if !args.is_empty() {
                args.insert(0, ' ');
            }

            if let Ok(channel) = target::Channel::parse(
                target,
                chantypes,
                statusmsg,
                casemapping,
            ) {
                let user =
                    resolve_attributes(&raw_user, &channel).unwrap_or(raw_user);

                let channel_users = target::Channel::parse(
                    target,
                    chantypes,
                    statusmsg,
                    casemapping,
                )
                .ok()
                .and_then(|channel| channel_users(&channel));

                Some((
                    parse_fragments_with_users(
                        format!("{} sets mode {modes}{args}", user.nickname()),
                        channel_users,
                        casemapping,
                    ),
                    None,
                ))
            } else if raw_user.nickname() == *our_nick {
                if casemapping.normalize(target) == our_nick.as_normalized_str()
                {
                    Some((
                        parse_fragments(format!("User mode set {modes}{args}")),
                        None,
                    ))
                } else {
                    Some((
                        parse_fragments(format!(
                            "Set {target} mode {modes}{args}"
                        )),
                        None,
                    ))
                }
            } else {
                let channel_users =
                    [raw_user.clone(), User::from(our_nick.clone())]
                        .into_iter()
                        .collect::<ChannelUsers>();

                Some((
                    parse_fragments_with_users(
                        format!(
                            "{} sets {target} mode {modes}{args}",
                            raw_user.nickname()
                        ),
                        Some(&channel_users),
                        casemapping,
                    ),
                    None,
                ))
            }
        }
        Command::PRIVMSG(target, text) | Command::NOTICE(target, text) => {
            let target = target::Target::parse(
                target,
                chantypes,
                statusmsg,
                casemapping,
            );

            let user = message.user(casemapping);

            let channel_users =
                message_target.channel().and_then(channel_users);

            // Check if a synthetic action message

            if let Some(user) = user.as_ref()
                && let Some(action) = parse_action(
                    user,
                    text,
                    channel_users,
                    &target,
                    our_nick,
                    &config.highlights,
                    server,
                    casemapping,
                )
            {
                return Some(action);
            }

            if let Some(query) = ctcp::parse_query(text) {
                let arrow = if target.as_normalized_str()
                    == our_nick.as_normalized_str()
                {
                    "←"
                } else {
                    "→"
                };

                let command = query.command.as_ref();

                let text = if let Some(params) = query.params {
                    [arrow, command, params].join(" ")
                } else {
                    [arrow, command].join(" ")
                };

                return Some((parse_fragments(text), None));
            }

            if user
                .as_ref()
                .is_some_and(|user| user.nickname() == *our_nick)
            {
                Some((
                    parse_fragments_with_users(
                        text.clone(),
                        channel_users,
                        casemapping,
                    ),
                    None,
                ))
            } else {
                Some(parse_fragments_with_highlights(
                    text.clone(),
                    user.as_ref(),
                    channel_users,
                    &target,
                    our_nick,
                    &config.highlights,
                    server,
                    casemapping,
                ))
            }
        }
        Command::Numeric(RPL_TOPIC, params) => {
            let topic = params.get(2)?;

            Some((parse_fragments(format!("Topic is {topic}")), None))
        }
        Command::Numeric(RPL_ENDOFWHOIS, _) => {
            // We skip the end message of a WHOIS.
            None
        }
        Command::Numeric(RPL_WHOISIDLE, params) => {
            let user = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));

            let idle = params.get(2)?.parse::<u64>().ok()?;
            let sign_on = params.get(3)?.parse::<u64>().ok()?;

            let sign_on = Posix::from_seconds(sign_on);
            let sign_on_datetime = sign_on.datetime()?.to_string();

            let mut formatter = timeago::Formatter::new();
            // Remove "ago" from relative time.
            formatter.ago("");

            let duration = std::time::Duration::from_secs(idle);
            let idle_readable = formatter.convert(duration);

            Some((
                parse_fragments_with_user(
                    format!(
                        "{} signed on at {sign_on_datetime} and has been idle for {idle_readable}",
                        user.nickname()
                    ),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_WHOISSERVER, params) => {
            let user = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));

            let server = params.get(2)?;
            let region = params.get(3)?;

            Some((
                parse_fragments_with_user(
                    format!(
                        "{} is connected on {server} ({region})",
                        user.nickname()
                    ),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_WHOISUSER, params) => {
            let user = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));

            let userhost = format!("{}@{}", params.get(2)?, params.get(3)?);
            let real_name = params.get(5)?;

            Some((
                parse_fragments_with_user(
                    format!(
                        "{} has userhost {userhost} and real name '{real_name}'",
                        user.nickname()
                    ),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_WHOISCHANNELS, params) => {
            let user = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));
            let channels = params.get(2)?;

            Some((
                parse_fragments_with_user(
                    format!("{} is in {channels}", user.nickname()),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_WHOISACTUALLY, params) => {
            let user: User = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));
            let ip = params.get(2)?;
            let status_text = params.get(3)?;

            Some((
                parse_fragments_with_user(
                    format!("{} {status_text} {ip}", user.nickname()),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(
            RPL_WHOISCERTFP | RPL_WHOISHOST | RPL_WHOISSECURE | RPL_WHOISBOT
            | RPL_WHOISMODES,
            params,
        ) => {
            let user: User = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));
            let status_text = params.get(2)?;

            Some((
                parse_fragments_with_user(
                    format!("{} {status_text}", user.nickname()),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_WHOISACCOUNT, params) => {
            let user: User = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));
            let account = params.get(2)?;
            let status_text = params.get(3)?;

            Some((
                parse_fragments_with_user(
                    format!("{} {status_text} {account}", user.nickname()),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_WHOISKEYVALUE, params) => {
            let user: User = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));
            let key = params.get(2)?;
            let visibility = params.get(3)?;
            let value = params.get(4)?;

            Some((
                parse_fragments_with_user(
                    format!(
                        "{} has {key} {value} (visibility: {visibility})",
                        user.nickname()
                    ),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_TOPICWHOTIME, params) => {
            let user =
                User::parse(params.get(2)?.as_str(), casemapping, Some(prefix))
                    .ok()?;

            let datetime = params
                .get(3)?
                .parse::<u64>()
                .ok()
                .map(Posix::from_seconds)
                .as_ref()
                .and_then(Posix::datetime)?
                .with_timezone(&Local)
                .to_rfc2822();

            Some((
                parse_fragments_with_user(
                    format!("Topic set by {} at {datetime}", user.nickname()),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(RPL_NOTOPIC, _) => {
            Some((plain("No topic is set".to_string()), None))
        }
        Command::Numeric(RPL_CHANNELMODEIS, params) => {
            let mode = params
                .iter()
                .skip(2)
                .map(String::as_str)
                .collect::<Vec<_>>()
                .join(" ");

            Some((parse_fragments(format!("Channel mode is {mode}")), None))
        }
        Command::Numeric(RPL_UMODEIS, params) => {
            let mode = params
                .iter()
                .skip(1)
                .map(String::as_str)
                .collect::<Vec<_>>()
                .join(" ");

            Some((parse_fragments(format!("User mode is {mode}")), None))
        }
        Command::Numeric(RPL_AWAY, params) => {
            let user = User::from(Nick::from_str(
                params.get(1)?.as_str(),
                casemapping,
            ));
            let away_message = params
                .get(2)
                .map(|away| format!(" ({away})"))
                .unwrap_or_default();

            Some((
                parse_fragments_with_user(
                    format!("{} is away{away_message}", user.nickname()),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::Numeric(numeric, params)
            if matches!(numeric, RPL_MONONLINE | RPL_MONOFFLINE) =>
        {
            let target_users = params
                .get(1)?
                .split(',')
                .map(|target| {
                    User::parse(target, casemapping, Some(prefix)).unwrap_or(
                        User::from(Nick::from_str(target, casemapping)),
                    )
                })
                .collect::<ChannelUsers>();

            let target_usernames = target_users
                .iter()
                .map(|user| user.formatted(UsernameFormat::Full))
                .collect::<Vec<_>>();

            let targets = monitored_targets_text(target_usernames)?;

            Some((
                parse_fragments_with_users(
                    match numeric {
                        RPL_MONONLINE => format!("Monitored {targets} online"),
                        RPL_MONOFFLINE => {
                            format!("Monitored {targets} offline")
                        }
                        _ => {
                            log::debug!("Unexpected numeric {numeric:?}");
                            format!("Monitored {targets}")
                        }
                    },
                    Some(&target_users),
                    casemapping,
                ),
                None,
            ))
        }
        Command::CHATHISTORY(sub, args) => {
            if sub == "TARGETS" {
                let target = args.first()?;
                let timestamp = args.get(1)?;

                Some((
                    plain(format!("Chat history for {target} at {timestamp}")),
                    None,
                ))
            } else {
                None
            }
        }
        Command::FAIL(command, _, context, description) => {
            if let Some(context) = context {
                Some((
                    plain(format!(
                        "{command} ({}) failed: {description}",
                        context.join(", ")
                    )),
                    None,
                ))
            } else {
                Some((plain(format!("{command} failed: {description}")), None))
            }
        }
        Command::WARN(command, _, context, description) => {
            if let Some(context) = context {
                Some((
                    plain(format!(
                        "{command} ({}) warning: {description}",
                        context.join(", ")
                    )),
                    None,
                ))
            } else {
                Some((plain(format!("{command} warning: {description}")), None))
            }
        }
        Command::NOTE(command, _, context, description) => {
            if let Some(context) = context {
                Some((
                    plain(format!(
                        "{command} ({}) notice: {description}",
                        context.join(", ")
                    )),
                    None,
                ))
            } else {
                Some((plain(format!("{command} notice: {description}")), None))
            }
        }
        Command::WALLOPS(text) => {
            let user = message.user(casemapping)?;

            Some((
                parse_fragments_with_user(
                    format!(
                        "WALLOPS from {}: {}",
                        user.nickname(),
                        text.clone()
                    ),
                    &user,
                    casemapping,
                ),
                None,
            ))
        }
        Command::INVITE(user, channel) => {
            let channel = target::Channel::parse(
                channel,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            let raw_inviter = message.user(casemapping)?;
            let inviter = resolve_attributes(&raw_inviter, &channel)
                .unwrap_or(raw_inviter);
            let invitee =
                User::from(Nick::from_str(user.as_str(), casemapping));

            Some((
                invite_text(inviter, invitee, our_nick, channel, casemapping),
                None,
            ))
        }
        Command::Numeric(RPL_INVITING, params) => {
            let channel = target::Channel::parse(
                params.get(2)?,
                chantypes,
                statusmsg,
                casemapping,
            )
            .ok()?;

            let raw_inviter =
                User::from(Nick::from_str(params.first()?, casemapping));
            let inviter = resolve_attributes(&raw_inviter, &channel)
                .unwrap_or(raw_inviter);

            let invitee =
                User::from(Nick::from_str(params.get(1)?, casemapping));

            Some((
                invite_text(inviter, invitee, our_nick, channel, casemapping),
                None,
            ))
        }
        Command::Numeric(RPL_WELCOME, params) => Some((
            parse_fragments_with_user(
                params
                    .iter()
                    .map(String::as_str)
                    .skip(1)
                    .collect::<Vec<_>>()
                    .join(" "),
                &User::from(our_nick.clone()),
                casemapping,
            ),
            None,
        )),
        Command::Numeric(ERR_CANNOTSENDTOCHAN, params) => {
            match target::Target::parse(
                params.get(1)?,
                chantypes,
                statusmsg,
                casemapping,
            ) {
                target::Target::Channel(_) => {
                    Some((plain("Cannot send to channel".to_string()), None))
                }
                target::Target::Query(_) => {
                    Some((plain("Cannot send to user".to_string()), None))
                }
            }
        }
        Command::Numeric(_, responses) | Command::Unknown(_, responses) => {
            (!responses.is_empty()).then_some((
                parse_fragments(
                    responses
                        .iter()
                        .map(String::as_str)
                        .skip(1)
                        .collect::<Vec<_>>()
                        .join(" "),
                ),
                None,
            ))
        }
        _ => None,
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Limit {
    Top(usize),
    Bottom(usize),
    Since(DateTime<Utc>),
    Around(usize, Hash),
}

pub fn is_action(text: &str) -> bool {
    if let Some(query) = ctcp::parse_query(text) {
        matches!(query.command, ctcp::Command::Action)
    } else {
        false
    }
}

fn parse_action(
    user: &User,
    text: &str,
    channel_users: Option<&ChannelUsers>,
    target: &target::Target,
    our_nick: &Nick,
    highlights: &Highlights,
    server: &Server,
    casemapping: isupport::CaseMap,
) -> Option<(Content, Option<highlight::Kind>)> {
    if !is_action(text) {
        return None;
    }

    let query = ctcp::parse_query(text)?;

    if user.nickname() == *our_nick {
        Some((
            action_text(user, query.params, channel_users, casemapping),
            None,
        ))
    } else {
        Some(action_text_with_highlights(
            user,
            query.params,
            channel_users,
            target,
            our_nick,
            highlights,
            server,
            casemapping,
        ))
    }
}

/// in preview contexts the nick is added on the side as a `UserDisplay`
pub fn action_preview_text(content: &Content, user: &User) -> String {
    let text = content.preview_text();
    let prefix = format!("{} ", user.nickname());
    text.strip_prefix(&prefix).unwrap_or(&text).to_string()
}

pub fn action_text(
    user: &User,
    action: Option<&str>,
    channel_users: Option<&ChannelUsers>,
    casemapping: isupport::CaseMap,
) -> Content {
    let text = if let Some(action) = action {
        format!("{} {action}", user.nickname())
    } else {
        user.nickname().to_string()
    };

    parse_fragments_with_users(text, channel_users, casemapping)
}

pub fn action_text_with_highlights(
    user: &User,
    action: Option<&str>,
    channel_users: Option<&ChannelUsers>,
    target: &target::Target,
    our_nick: &Nick,
    highlights: &Highlights,
    server: &Server,
    casemapping: isupport::CaseMap,
) -> (Content, Option<highlight::Kind>) {
    let text = if let Some(action) = action {
        format!("{} {action}", user.nickname())
    } else {
        user.nickname().to_string()
    };

    parse_fragments_with_highlights(
        text,
        Some(user),
        channel_users,
        target,
        our_nick,
        highlights,
        server,
        casemapping,
    )
}

fn invite_text(
    inviter: User,
    invitee: User,
    our_nick: &Nick,
    channel: target::Channel,
    casemapping: isupport::CaseMap,
) -> Content {
    parse_fragments_with_users(
        if invitee.nickname() == *our_nick {
            format!("{} has invited you to {channel}", inviter.as_str())
        } else if inviter.nickname() == *our_nick {
            format!("You have invited {} to {channel}", invitee.as_str())
        } else {
            format!(
                "{} has been invited to {channel} by {}",
                invitee.as_str(),
                inviter.as_str(),
            )
        },
        Some(&[inviter, invitee].into_iter().collect()),
        casemapping,
    )
}

fn kick_text(
    kicker: User,
    victim: User,
    ourself: bool,
    reason: &Option<String>,
    channel: Option<target::Channel>,
    direction_arrows: &crate::config::display::DirectionArrows,
    casemapping: isupport::CaseMap,
) -> Content {
    let target = if ourself {
        "You have".to_string()
    } else {
        format!("{} has", victim.nickname())
    };

    let reason = reason
        .as_ref()
        .map(|reason| format!(" ({reason})"))
        .unwrap_or_default();

    parse_fragments_with_users(
        if let Some(channel) = channel {
            format!(
                "{target} been kicked from {channel} by {}{reason}",
                kicker.nickname()
            )
        } else {
            format!(
                "{} {target} been kicked by {}{reason}",
                direction_arrows.left,
                kicker.nickname()
            )
        },
        Some(&[kicker, victim].into_iter().collect()),
        casemapping,
    )
}

pub fn nickname_text(
    old_nick: NickRef,
    new_nick: NickRef,
    ourself: bool,
    casemapping: isupport::CaseMap,
) -> Content {
    let old_user = User::from(old_nick.to_owned());
    let new_user = User::from(new_nick.to_owned());

    if ourself {
        parse_fragments_with_user(
            format!("You're now known as {new_nick}"),
            &new_user,
            casemapping,
        )
    } else {
        parse_fragments_with_users(
            format!("{old_nick} is now known as {new_nick}"),
            Some(&[old_user.clone(), new_user].into_iter().collect()),
            casemapping,
        )
    }
}

fn monitored_targets_text(targets: Vec<String>) -> Option<String> {
    if targets.is_empty() {
        None
    } else if targets.len() == 1 {
        Some(format!("User {} is", targets.first()?))
    } else {
        Some(format!(
            "Users {} are",
            join_targets(targets.iter().map(String::as_ref).collect())
        ))
    }
}

pub fn quit_text(
    user: &User,
    comment: &Option<String>,
    config: &Config,
    casemapping: isupport::CaseMap,
) -> Content {
    let comment = comment
        .as_ref()
        .map(|comment| format!(" ({comment})"))
        .unwrap_or_default();

    parse_fragments_with_user(
        format!(
            "{} {} has quit{comment}",
            config.display.direction_arrows.left,
            user.formatted(
                config
                    .buffer
                    .server_messages
                    .username_format(Some(source::server::Kind::Quit))
            )
        ),
        user,
        casemapping,
    )
}

#[derive(Debug, Clone)]
pub enum Link {
    Channel(Server, target::Channel),
    Url(String),
    User(Server, User),
    GoToMessage(Server, target::Channel, Hash),
    ExpandMessage(DateTime<Utc>, Hash),
    ContractMessage(DateTime<Utc>, Hash),
}

impl Link {
    pub fn user(&self) -> Option<&User> {
        match self {
            Link::User(_, user) => Some(user),
            _ => None,
        }
    }

    pub fn url(&self) -> Option<&String> {
        match self {
            Link::Url(url) => Some(url),
            _ => None,
        }
    }
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct MessageReferences {
    pub timestamp: DateTime<Utc>,
    pub id: Option<message::Id>,
}

impl MessageReferences {
    pub fn message_reference(
        &self,
        message_reference_types: &[isupport::MessageReferenceType],
    ) -> Option<isupport::MessageReference> {
        for message_reference_type in message_reference_types {
            match message_reference_type {
                isupport::MessageReferenceType::MessageId => {
                    if let Some(id) = &self.id {
                        return Some(isupport::MessageReference::MessageId(
                            id.clone(),
                        ));
                    }
                }
                isupport::MessageReferenceType::Timestamp => {
                    return Some(isupport::MessageReference::Timestamp(
                        self.timestamp,
                    ));
                }
            }
        }

        None
    }
}

impl PartialEq for MessageReferences {
    fn eq(&self, other: &Self) -> bool {
        self.timestamp.eq(&other.timestamp)
    }
}

impl Eq for MessageReferences {}

impl Ord for MessageReferences {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.timestamp.cmp(&other.timestamp)
    }
}

impl PartialOrd for MessageReferences {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

#[cfg(any(test, feature = "message_tests"))]
pub mod tests {
    #[allow(unused_imports)]
    use super::{
        parse_fragments, parse_fragments_with_highlights,
        parse_fragments_with_users,
    };
    #[allow(unused_imports)]
    use crate::bouncer::BouncerNetwork;
    #[allow(unused_imports)]
    use crate::config::Highlights;
    #[allow(unused_imports)]
    use crate::config::highlights::Nickname;
    #[allow(unused_imports)]
    use crate::config::inclusivities::Inclusivities;
    #[allow(unused_imports)]
    use crate::history::reroute::RerouteRules;
    #[allow(unused_imports)]
    use crate::message::formatting::Color;
    #[allow(unused_imports)]
    use crate::message::{
        self, Broadcast, Content, Formatting, Fragment, Highlight, Message,
        Source, Target, broadcast,
    };
    #[allow(unused_imports)]
    use crate::server::{Server, ServerName};
    #[allow(unused_imports)]
    use crate::user::{ChannelUsers, Nick, User};
    #[allow(unused_imports)]
    use crate::{isupport, target};

    #[test]
    fn special_targets() {
        use std::collections::HashMap;

        use irc::proto;

        let reroute_rules = RerouteRules::default();

        let server = Server::from(ServerName::from("test-server"));

        let isupport = HashMap::<isupport::Kind, isupport::Parameter>::new();

        let chantypes = isupport::get_chantypes_or_default(&isupport);
        let statusmsg = isupport::get_statusmsg_or_default(&isupport);
        let casemapping = isupport::get_casemapping_or_default(&isupport);

        let our_nick = Nick::from_str("dan", casemapping);

        let our_user = User::from(our_nick.clone());

        let tests = [
            (
                ":dan!d@localhost NOTICE * :Server notice! \r\n",
                Some((
                    Target::Server {
                        source: Source::User(User::from(Nick::from_str(
                            "dan",
                            casemapping,
                        ))),
                    },
                    None,
                )),
            ),
            (
                ":dan!d@localhost PRIVMSG $$* :Mass message! \r\n",
                Some((
                    Target::Query {
                        query: target::Query::from(our_user),
                        source: Source::User(User::from(Nick::from_str(
                            "dan",
                            casemapping,
                        ))),
                    },
                    None,
                )),
            ),
        ];

        for (irc_message, expected) in tests {
            let encoded = proto::parse::message(irc_message).unwrap();

            let actual = message::target(
                &message::Encoded(encoded),
                &our_nick,
                &reroute_rules,
                &|_: &User, _: &target::Channel| None,
                &server,
                chantypes,
                statusmsg,
                casemapping,
            );

            assert_eq!(expected, actual);
        }
    }

    #[test]
    fn fragments_parsing() {
        let tests = [
            (
                r##"#channel: "#foo""##,
                vec![
                    Fragment::Channel("#channel".into()),
                    Fragment::Text(": \"".into()),
                    Fragment::Channel("#foo".into()),
                    Fragment::Text("\"".into()),
                ],
            ),
            (
                "Test channels #test.123.,! and another #channel? and #testing!!",
                vec![
                    Fragment::Text("Test channels ".into()),
                    Fragment::Channel("#test.123.".into()),
                    Fragment::Text(",! and another ".into()),
                    Fragment::Channel("#channel".into()),
                    Fragment::Text("? and ".into()),
                    Fragment::Channel("#testing".into()),
                    Fragment::Text("!!".into()),
                ],
            ),
            (
                "Testing delimiters in channel name (#[channel] here).",
                vec![
                    Fragment::Text("Testing delimiters in channel name (".into()),
                    Fragment::Channel("#[channel]".into()),
                    Fragment::Text(" here).".into()),
                ],
            ),
            (
                "Testing delimiters \"quoting a #channel\" here.",
                vec![
                    Fragment::Text("Testing delimiters \"quoting a ".into()),
                    Fragment::Channel("#channel".into()),
                    Fragment::Text("\" here.".into()),
                ],
            ),
            (
                "Testing delimiters (this is a #channel) here.",
                vec![
                    Fragment::Text("Testing delimiters (this is a ".into()),
                    Fragment::Channel("#channel".into()),
                    Fragment::Text(") here.".into()),
                ],
            ),
            (
                "(#channel1 and #channel2).",
                vec![
                    Fragment::Text("(".into()),
                    Fragment::Channel("#channel1".into()),
                    Fragment::Text(" and ".into()),
                    Fragment::Channel("#channel2".into()),
                    Fragment::Text(").".into()),
                ],
            ),
            (
                "testing new line #match\ning.",
                vec![
                    Fragment::Text("testing new line ".into()),
                    Fragment::Channel("#match".into()),
                    Fragment::Text("\ning.".into()),
                ],
            ),
            (
                "\"a #test\",",
                vec![
                    Fragment::Text("\"a ".into()),
                    Fragment::Channel("#test".into()),
                    Fragment::Text("\",".into()),
                ],
            ),
            (
                "#!/bin/sh",
                vec![
                    Fragment::Channel("#!/bin/sh".into()),
                ],
            ),
            (
                "foo#bar and #foo",
                vec![
                    Fragment::Text("foo#bar and ".into()),
                    Fragment::Channel("#foo".into()),
                ],
            ),
            (
                "Checkout https://foo.bar/asdf?1=2 now!",
                vec![
                    Fragment::Text("Checkout ".into()),
                    Fragment::Url("https://foo.bar/asdf?1=2".parse().unwrap(), "https://foo.bar/asdf?1=2".to_string()),
                    Fragment::Text(" now!".into()),
                ],
            ),
            (
                "http://google.us.edi?34535/534534?dfg=g&fg",
                vec![Fragment::Url(
                    "http://google.us.edi?34535/534534?dfg=g&fg".parse().unwrap(), "http://google.us.edi?34535/534534?dfg=g&fg".to_string(),
                )],
            ),
            (
                "http://regexr.com is a great tool",
                vec![
                    Fragment::Url("http://regexr.com".parse().unwrap(), "http://regexr.com".to_string()),
                    Fragment::Text(" is a great tool".into()),
                ],
            ),
            (
                "We have a wiki at https://halloy.chat",
                vec![
                    Fragment::Text("We have a wiki at ".into()),
                    Fragment::Url("https://halloy.chat".parse().unwrap(), "https://halloy.chat".to_string()),
                ],
            ),
            (
                "https://catgirl.delivery/2024/07/25/sometimes-it-is-correct-to-blame-the-compiler/",
                vec![Fragment::Url(
                    "https://catgirl.delivery/2024/07/25/sometimes-it-is-correct-to-blame-the-compiler/".parse().unwrap(), "https://catgirl.delivery/2024/07/25/sometimes-it-is-correct-to-blame-the-compiler/".to_string()
                )],
            ),
            (
                "https://www.google.com/maps/@61.0873595,-27.322408,3z?entry=ttu",
                vec![Fragment::Url(
                    "https://www.google.com/maps/@61.0873595,-27.322408,3z?entry=ttu".parse().unwrap(), "https://www.google.com/maps/@61.0873595,-27.322408,3z?entry=ttu".to_string()
                )],
            ),
            (
                "https://doc.rust-lang.org/book/ch03-05-control-flow.html#loop-labels-to-disambiguate-between-multiple-loops",
                vec![Fragment::Url(
                    "https://doc.rust-lang.org/book/ch03-05-control-flow.html#loop-labels-to-disambiguate-between-multiple-loops".parse().unwrap(), "https://doc.rust-lang.org/book/ch03-05-control-flow.html#loop-labels-to-disambiguate-between-multiple-loops".to_string()
                )],
            ),
            (
                "(https://yt.drgnz.club/watch?v=s_VH36ChGXw and https://invidious.incogniweb.net/watch?v=H3v9unphfi0).",
                vec![
                    Fragment::Text("(".into()),
                    Fragment::Url("https://yt.drgnz.club/watch?v=s_VH36ChGXw".parse().unwrap(), "https://yt.drgnz.club/watch?v=s_VH36ChGXw".to_string()),
                    Fragment::Text(" and ".into()),
                    Fragment::Url("https://invidious.incogniweb.net/watch?v=H3v9unphfi0".parse().unwrap(), "https://invidious.incogniweb.net/watch?v=H3v9unphfi0".to_string()),
                    Fragment::Text(").".into()),
                ],
            ),
            (
                "https://www.reddit.com/r/witze/comments/1fcoz5a/ein_vampir_auf_einem_tandem_gerät_in_eine/", // spellchecker:disable-line
                vec![Fragment::Url(
                    "https://www.reddit.com/r/witze/comments/1fcoz5a/ein_vampir_auf_einem_tandem_gerät_in_eine/".parse().unwrap(), "https://www.reddit.com/r/witze/comments/1fcoz5a/ein_vampir_auf_einem_tandem_gerät_in_eine/".to_string() // spellchecker:disable-line
                )],
            ),
            (
                "http://öbb.at",
                vec![
                    Fragment::Url("http://öbb.at".parse().unwrap(), "http://öbb.at".to_string()),
                ],
            ),
            (
                "(Example brackets https://example.com)",
                vec![
                    Fragment::Text("(Example brackets ".into()),
                    Fragment::Url("https://example.com".parse().unwrap(), "https://example.com".to_string()),
                    Fragment::Text(")".into()),
                ]
            ),
            (
                "see website (https://website-url.com)",
                vec![
                    Fragment::Text("see website (".into()),
                    Fragment::Url("https://website-url.com".parse().unwrap(), "https://website-url.com".to_string()),
                    Fragment::Text(")".into()),
                ]
            ),
            (
                "This is a valid url https://www.example.com/test_(example)",
                vec![
                    Fragment::Text("This is a valid url ".into()),
                    Fragment::Url("https://www.example.com/test_(example)".parse().unwrap(), "https://www.example.com/test_(example)".to_string()),
                ]
            ),
            (
                "(This is also a valid url https://www.example.com/another_test_(example))",
                vec![
                    Fragment::Text("(This is also a valid url ".into()),
                    Fragment::Url("https://www.example.com/another_test_(example)".parse().unwrap(), "https://www.example.com/another_test_(example)".to_string()),
                    Fragment::Text(")".into()),
                ]
            ),
            (
                "test ) and another test (https://example.com/)",
                vec![
                    Fragment::Text("test ) and another test (".into()),
                    Fragment::Url("https://example.com/".parse().unwrap(), "https://example.com/".to_string()),
                    Fragment::Text(")".into()),
                ]
            ),
            (
                "what about https://example.com/]",
                vec![
                    Fragment::Text("what about ".into()),
                    Fragment::Url("https://example.com/]".parse().unwrap(), "https://example.com/]".to_string()),
                ]
            ),
            (
                "what about https://example.com/a[test]",
                vec![
                    Fragment::Text("what about ".into()),
                    Fragment::Url("https://example.com/a[test]".parse().unwrap(), "https://example.com/a[test]".to_string()),
                ]
            ),
            (
                "{test: https://halloy.chat/configuration/context-menu}",
                vec![
                    Fragment::Text("{test: ".into()),
                    Fragment::Url("https://halloy.chat/configuration/context-menu".parse().unwrap(), "https://halloy.chat/configuration/context-menu".to_string()),
                    Fragment::Text("}".into()),
                ],
            ),
            (
                "[another test!!!: https://themes.halloy.chat]",
                vec![
                    Fragment::Text("[another test!!!: ".into()),
                    Fragment::Url("https://themes.halloy.chat".parse().unwrap(), "https://themes.halloy.chat".to_string()),
                    Fragment::Text("]".into()),
                ],
            ),
            (
                r#""Check https://flathub.org/en/apps/org.squidowl.halloy""#,
                vec![
                    Fragment::Text(r#""Check "#.into()),
                    Fragment::Url("https://flathub.org/en/apps/org.squidowl.halloy".parse().unwrap(), "https://flathub.org/en/apps/org.squidowl.halloy".to_string()),
                    Fragment::Text(r#"""#.into()),
                ],
            ),
            (
                "\u{f}\u{3}03VLC\u{f} \u{3}05master\u{f} \u{3}06somenick\u{f} \u{3}14http://some.website.com/\u{f} * describe commit * \u{3}14https://code.videolan.org/videolan/vlc/\u{f}",
                vec![
                    Fragment::Formatted{ text: "VLC".into(), formatting: Formatting { fg: Some(Color::Green), ..Formatting::default() }},
                    Fragment::Text(" ".into()),
                    Fragment::Formatted{ text: "master".into(), formatting: Formatting { fg: Some(Color::Brown), ..Formatting::default() }},
                    Fragment::Text(" ".into()),
                    Fragment::Formatted{ text: "somenick".into(), formatting: Formatting { fg: Some(Color::Magenta), ..Formatting::default() }},
                    Fragment::Text(" ".into()),
                    Fragment::Url("http://some.website.com/".parse().unwrap(), "http://some.website.com/".to_string()),
                    Fragment::Text(" * describe commit * ".into()),
                    Fragment::Url("https://code.videolan.org/videolan/vlc/".parse().unwrap(), "https://code.videolan.org/videolan/vlc/".to_string()),
                ],
            ),
            (
                "\u{f}\u{11}code span that wraps a https://www.website.com/ like so\u{f}",
                vec![
                    Fragment::Formatted{ text: "code span that wraps a https://www.website.com/ like so".into(), formatting: Formatting { monospace: true, ..Formatting::default() }},
                ],
            ),
            (
                "\u{f}\u{3}09color that wraps a https://www.website.com/ like so\u{f}",
                vec![
                    Fragment::Formatted{ text: "color that wraps a ".into(), formatting: Formatting { fg: Some(Color::LightGreen), ..Formatting::default() }},
                    Fragment::Url("https://www.website.com/".parse().unwrap(), "https://www.website.com/".to_string()),
                    Fragment::Formatted{ text: " like so".into(), formatting: Formatting { fg: Some(Color::LightGreen), ..Formatting::default() }},
                ],
            ),
            (
                "\u{f}\u{3}11color that wraps a #channel like so\u{f}",
                vec![
                    Fragment::Formatted{ text: "color that wraps a ".into(), formatting: Formatting { fg: Some(Color::LightCyan), ..Formatting::default() }},
                    Fragment::Channel("#channel".into()),
                    Fragment::Formatted{ text: " like so".into(), formatting: Formatting { fg: Some(Color::LightCyan), ..Formatting::default() }},
                ],
            ),
            (
                "\u{3}22,33testing normal color with background and color \u{3}reset",
                vec![
                    Fragment::Formatted { text: "testing normal color with background and color ".into(), formatting: Formatting { fg: Some(Color::Code22), bg: Some(Color::Code33), ..Formatting::default() }},
                    Fragment::Text("reset".into()),
                ],
            ),
            (
                "\u{4}ffffff,0909aatesting hex color with background and color \u{4}reset",
                vec![
                    Fragment::Formatted { text: "testing hex color with background and color ".into(), formatting: Formatting { fg: Some(Color::Rgb(255, 255, 255)), bg: Some(Color::Rgb(9, 9, 170)), ..Formatting::default() }},
                    Fragment::Text("reset".into()),
                ],
            ),
            (
                "\u{3}22, testing comma that doesn't indicate background \u{3}reset",
                vec![
                    Fragment::Formatted { text: ", testing comma that doesn't indicate background ".into(), formatting: Formatting { fg: Some(Color::Code22), ..Formatting::default() }},
                    Fragment::Text("reset".into()),
                ],
            ),
            (
                "https://en.wiktionary.org/wiki/百聞は一見に如かず",
                vec![Fragment::Url(
                    "https://en.wiktionary.org/wiki/百聞は一見に如かず".parse().unwrap(), "https://en.wiktionary.org/wiki/百聞は一見に如かず".to_string()
                )],
            ),
            (
                "open http://localhost/foo in a browser",
                vec![
                    Fragment::Text("open ".into()),
                    Fragment::Url("http://localhost/foo".parse().unwrap(), "http://localhost/foo".to_string()),
                    Fragment::Text(" in a browser".into()),
                ],
            ),
            (
                "http://localhost:8080/api",
                vec![Fragment::Url(
                    "http://localhost:8080/api".parse().unwrap(), "http://localhost:8080/api".to_string(),
                )],
            ),
            (
                "https://localhost/secure",
                vec![Fragment::Url(
                    "https://localhost/secure".parse().unwrap(), "https://localhost/secure".to_string(),
                )],
            ),
            (
                "ws://localhost:9000/socket",
                vec![Fragment::Url(
                    "ws://localhost:9000/socket".parse().unwrap(), "ws://localhost:9000/socket".to_string(),
                )],
            ),
            (
                "http://127.0.0.1:8080/api",
                vec![Fragment::Url(
                    "http://127.0.0.1:8080/api".parse().unwrap(), "http://127.0.0.1:8080/api".to_string(),
                )],
            ),
        ];

        for (text, expected) in tests {
            let Content::Fragments(actual) = parse_fragments(text.to_string())
            else {
                panic!("expected fragments from {text}")
            };

            assert_eq!(expected, actual);
        }
    }

    #[test]
    fn fragment_url_preserves_raw_and_canonical() {
        // Non-ASCII URLs should display as is, but expose
        // percent-encoded canonical form for cache lookups and opening.
        let Content::Fragments(fragments) = parse_fragments(
            "https://en.wiktionary.org/wiki/百聞は一見に如かず".to_string(),
        ) else {
            panic!("expected fragments");
        };

        let [Fragment::Url(canonical, raw)] = fragments.as_slice() else {
            panic!("expected single Url fragment");
        };

        assert_eq!(raw, "https://en.wiktionary.org/wiki/百聞は一見に如かず");
        assert_eq!(
            canonical.as_str(),
            "https://en.wiktionary.org/wiki/%E7%99%BE%E8%81%9E%E3%81%AF%E4%B8%80%E8%A6%8B%E3%81%AB%E5%A6%82%E3%81%8B%E3%81%9A"
        );
    }

    #[test]
    fn fragment_url_preserves_percent_encoded_segments_after_parentheses() {
        let Content::Fragments(fragments) = parse_fragments(
            "https://billwurtz.com/(What)%20Love%20Is.mp3".to_string(),
        ) else {
            panic!("expected fragments");
        };

        let [Fragment::Url(canonical, raw)] = fragments.as_slice() else {
            panic!("expected single Url fragment");
        };

        assert_eq!(raw, "https://billwurtz.com/(What)%20Love%20Is.mp3");
        assert_eq!(
            canonical.as_str(),
            "https://billwurtz.com/(What)%20Love%20Is.mp3"
        );
    }

    #[test]
    fn fragment_url_with_extra_closing_paren_in_path() {
        let Content::Fragments(fragments) = parse_fragments(
            "https://billwurtz.com/(What)%20Lov)e%20Is.mp3".to_string(),
        ) else {
            panic!("expected fragments");
        };

        let [Fragment::Url(canonical, raw)] = fragments.as_slice() else {
            panic!("expected single Url fragment, got {fragments:?}");
        };

        assert_eq!(raw, "https://billwurtz.com/(What)%20Lov)e%20Is.mp3");
        assert_eq!(
            canonical.as_str(),
            "https://billwurtz.com/(What)%20Lov)e%20Is.mp3"
        );
    }

    #[test]
    fn fragments_with_users_parsing() {
        let casemapping = isupport::CaseMap::default();
        let tests = [
            (
                (
                    "Hey Dave!~Dave@user/Dave have you seen &`Bill`?"
                        .to_string(),
                    ["Greg", "Dave", "Bob", "George_", "`Bill`"]
                        .into_iter()
                        .map(|nick| {
                            User::from(Nick::from_str(nick, casemapping))
                        })
                        .collect::<ChannelUsers>(),
                ),
                vec![
                    Fragment::Text("Hey ".into()),
                    Fragment::User(
                        User::from(Nick::from_str("Dave", casemapping)),
                        "Dave".into(),
                    ),
                    Fragment::Text("!~".into()),
                    Fragment::User(
                        User::from(Nick::from_str("Dave", casemapping)),
                        "Dave".into(),
                    ),
                    Fragment::Text("@user/Dave have you seen &".into()),
                    Fragment::User(
                        User::from(Nick::from_str("`Bill`", casemapping)),
                        "`Bill`".into(),
                    ),
                    Fragment::Text("?".into()),
                ],
            ),
            (
                (
                    "And they said: \"Susan, where are you?\"".to_string(),
                    ["Susan"]
                        .into_iter()
                        .map(|nick| {
                            User::from(Nick::from_str(nick, casemapping))
                        })
                        .collect::<ChannelUsers>(),
                ),
                vec![
                    Fragment::Text("And they said: \"".into()),
                    Fragment::User(
                        User::from(Nick::from_str("Susan", casemapping)),
                        "Susan".into(),
                    ),
                    Fragment::Text(", where are you?\"".into()),
                ],
            ),
            (
                (
                    "This shouldn't match but t _his_ should!".to_string(),
                    ["t"]
                        .into_iter()
                        .map(|nick| {
                            User::from(Nick::from_str(nick, casemapping))
                        })
                        .collect::<ChannelUsers>(),
                ),
                vec![
                    Fragment::Text("This shouldn't match but ".into()),
                    Fragment::User(
                        User::from(Nick::from_str("t", casemapping)),
                        "t".into(),
                    ),
                    Fragment::Text(" _his_ should!".into()),
                ],
            ),
        ];
        for ((text, channel_users), expected) in tests {
            if let Content::Fragments(actual) = parse_fragments_with_users(
                text.clone(),
                Some(&channel_users),
                casemapping,
            ) {
                assert_eq!(expected, actual);
            } else {
                panic!("expected fragments with users from {text}");
            }
        }
    }

    #[test]
    fn fragments_with_highlights_parsing() {
        use std::collections::HashMap;

        let server = Server {
            name: "Test Server".into(),
            network: None,
        };

        let isupport = HashMap::<isupport::Kind, isupport::Parameter>::new();

        let chantypes = isupport::get_chantypes_or_default(&isupport);
        let statusmsg = isupport::get_statusmsg_or_default(&isupport);
        let casemapping = isupport::get_casemapping_or_default(&isupport);

        let tests = [
            (
                (
                    "Bob: I'm in #interesting with Greg, George_, &`bill`. I hope @Dave doesn't notice.".to_string(),
                    User::from(Nick::from_str("Steve", casemapping)),
                    [
                        "Greg",
                        "Dave",
                        "Bob",
                        "George_",
                        "`Bill`",
                        "Steve",
                    ].into_iter().map(|nick| User::from(Nick::from_str(nick, casemapping))).collect::<ChannelUsers>(),
                    target::Target::parse("#interesting", chantypes, statusmsg, casemapping),
                    Nick::from_str("Bob", casemapping),
                    &Highlights {
                        nickname: Nickname {exclude: None, include: Some(Inclusivities::parse(vec!["#interesting".into()])), case_insensitive: true},
                        matches: vec![],
                    },
                ),
                Some(vec![
                    Fragment::HighlightNick(User::from(Nick::from_str("Bob", casemapping)), "Bob".into()),
                    Fragment::Text(": I'm in ".into()),
                    Fragment::Channel("#interesting".into()),
                    Fragment::Text(" with ".into()),
                    Fragment::User(User::from(Nick::from_str("Greg", casemapping)), "Greg".into()),
                    Fragment::Text(", ".into()),
                    Fragment::User(User::from(Nick::from_str("George_", casemapping)), "George_".into()),
                    Fragment::Text(", &".into()),
                    Fragment::User(User::from(Nick::from_str("`Bill`", casemapping)), "`bill`".into()),
                    Fragment::Text(". I hope @".into()),
                    Fragment::User(User::from(Nick::from_str("Dave", casemapping)), "Dave".into()),
                    Fragment::Text(" doesn't notice.".into()),
                ]),
            ),
            (
                (
                    "the boat would bob up and down!".to_string(),
                    User::from(Nick::from_str("Greg", casemapping)),
                    [
                        "Greg",
                        "Dave",
                        "Bob",
                        "George_",
                        "`Bill`",
                    ].into_iter().map(|nick| User::from(Nick::from_str(nick, casemapping))).collect::<ChannelUsers>(),
                    target::Target::parse("#interesting", chantypes, statusmsg, casemapping),
                    Nick::from_str("Bob", casemapping),
                    &Highlights {
                        nickname: Nickname {exclude: None, include: None, case_insensitive: false},
                        matches: vec![],
                    },
                ),
                Some(vec![
                    Fragment::Text("the boat would ".into()),
                    Fragment::User(User::from(Nick::from_str("Bob", casemapping)), "bob".into()),
                    Fragment::Text(" up and down!".into()),
                ]),
            ),
            (
                (
                    "\u{3}14<\u{3}\u{3}04lurk_\u{3}\u{3}14/rx>\u{3} f_~oftc: > A��\u{1f}qj\u{14}��L�5�g���5�P��yn_?�i3g�1\u{7f}mE�\\X��� Xe�\u{5fa}{d�+�`@�^��NK��~~ޏ\u{7}\u{8}\u{15}\\�\u{4}A� \u{f}\u{1c}�N\u{11}6�r�\u{4}t��Q��\u{1c}�m\u{19}��".to_string(),
                    User::from(Nick::from_str("rx", casemapping)),
                    [
                        "f_",
                        "rx",
                    ].into_iter().map(|nick| User::from(Nick::from_str(nick, casemapping))).collect::<ChannelUsers>(),
                    target::Target::parse("#funderscore-helped", chantypes, statusmsg, casemapping),
                    Nick::from_str("f_", casemapping),
                    &Highlights {
                        nickname: Nickname {exclude: None, include: Some(Inclusivities::all()), case_insensitive: true},
                        matches: vec![],
                    },
                ),
                None, // We only care that this message doesn't cause the parser to panic
            ),
        ];
        for (
            (text, user, channel_users, target, our_nick, highlights),
            expected,
        ) in tests
        {
            if let (Content::Fragments(actual), _) =
                parse_fragments_with_highlights(
                    text.clone(),
                    Some(&user),
                    Some(&channel_users),
                    &target,
                    &our_nick,
                    highlights,
                    &server,
                    casemapping,
                )
            {
                if let Some(expected) = expected {
                    assert_eq!(expected, actual);
                }
            } else {
                panic!("expected fragments with highlighting from {text}");
            }
        }
    }

    pub const SERDE_IRC_MESSAGES: &[&str] = &[
        "@time=2023-07-20T21:19:11.000Z :chat!test@user/test/bot/chat PRIVMSG ##chat :\\_o< quack!\r\n",
        "@id=234AB :dan!d@localhost PRIVMSG #chan :Hey what's up! \r\n",
        "@time=2025-02-11T20:28:47.354Z :our_nick PRIVMSG #test-chan :\u{1}ACTION wants to generate an action message for testing XD\u{1}\r\n",
        "@id=DTSA :our_nick PRIVMSG #halloy :check out https://unstable.halloy.squidowl.org/, when you're building from source\r\n",
        ":dan!d@localhost PRIVMSG #chan-chan \u{1f}how\u{1f} \u{11}about\u{11} \u{2}\u{1d}some\u{1d}\u{2} \u{2}markdown\u{2} \u{1d}for\u{1d} \u{1e}testing\u{1e} \u{3}4too\u{3}? \r\n",
        ":WiZ JOIN #Twilight_zone\r\n",
        ":`whammer`!warhammer@40k PART #test\r\n",
        ":soju.bouncer FAIL * ACCOUNT_REQUIRED :Authentication required\r\n",
        ":rabbit MODE #토끼세계 +o bunny\r\n",
        ":dan!d@localhost PRIVMSG #chan :Need a highlight our_nick?\r\n",
    ];

    pub fn message_with_highlight_from_irc_message(
        irc_message: &str,
        server: &Server,
    ) -> (Message, Option<Highlight>) {
        use std::collections::HashMap;

        use irc::proto;

        use crate::config::Config;
        use crate::history::reroute::RerouteRules;
        use crate::message::Encoded;

        let isupport = HashMap::<isupport::Kind, isupport::Parameter>::new();

        let our_nick = Nick::from_str(
            "our_nick",
            isupport::get_casemapping_or_default(&isupport),
        );

        let channel_users: ChannelUsers = [
            User::from(Nick::from_str(
                "chat",
                isupport::get_casemapping_or_default(&isupport),
            )),
            User::from(Nick::from_str(
                "dan",
                isupport::get_casemapping_or_default(&isupport),
            )),
            User::from(our_nick.clone()),
        ]
        .into_iter()
        .collect();

        let encoded = proto::parse::message(irc_message).unwrap();

        Message::received_with_highlight(
            Encoded::from(encoded.clone()),
            our_nick.clone(),
            false,
            &Config::default(),
            &RerouteRules::default(),
            |user: &User, _channel: &target::Channel| {
                channel_users
                    .iter()
                    .find(|channel_user| *channel_user == user)
                    .cloned()
            },
            |_channel: &target::Channel| Some(&channel_users),
            |_, _, _| false,
            server,
            isupport::get_chantypes_or_default(&isupport),
            isupport::get_statusmsg_or_default(&isupport),
            isupport::get_casemapping_or_default(&isupport),
            isupport::get_prefix_or_default(&isupport),
        )
        .map_or_else(
            || panic!("failed to create Message from {encoded:?}"),
            |(msg, highlight, _)| (msg, highlight),
        )
    }

    #[test]
    fn server_notice_with_wildcard_target_uses_server_buffer() {
        let server = Server {
            name: "Test Server".into(),
            network: None,
        };

        let (message, highlight) = message_with_highlight_from_irc_message(
            "@time=2026-03-22T02:29:14.051Z :osmium.libera.chat NOTICE * :*** Notice -- [snip]\r\n",
            &server,
        );

        assert!(highlight.is_none());
        assert!(matches!(
            &message.target,
            crate::message::Target::Server {
                source: crate::message::Source::Server(None),
            }
        ));
        assert_eq!(message.text(), "*** Notice -- [snip]");
    }

    #[test]
    fn wildcard_target_with_user_source_uses_server_buffer() {
        let server = Server {
            name: "Test Server".into(),
            network: None,
        };

        let (message, highlight) = message_with_highlight_from_irc_message(
            ":dan!d@localhost NOTICE * :maintenance window\r\n",
            &server,
        );

        assert!(highlight.is_none());
        assert!(matches!(
            &message.target,
            Target::Server {
                source: Source::User(user),
            } if user.nickname().to_string() == "dan"
        ));
        assert_eq!(message.text(), "maintenance window");
    }

    pub fn serde_broadcasts() -> Vec<Broadcast> {
        use std::collections::HashMap;

        use crate::{isupport, target};

        let isupport = HashMap::<isupport::Kind, isupport::Parameter>::new();

        let user_channels = vec![
            target::Channel::from_str(
                "##chat",
                isupport::get_chantypes_or_default(&isupport),
                isupport::get_casemapping_or_default(&isupport),
            ),
            target::Channel::from_str(
                "#halloy",
                isupport::get_chantypes_or_default(&isupport),
                isupport::get_casemapping_or_default(&isupport),
            ),
        ];

        vec![
            Broadcast::Connecting,
            Broadcast::Connected,
            Broadcast::ConnectionFailed {
                error: "a TLS error occurred: \
                            io error: received fatal alert: HandshakeFailure"
                    .to_string(),
            },
            Broadcast::Disconnected { error: None },
            Broadcast::Reconnected,
            Broadcast::Quit {
                user: User::parse(
                    "+nieve!snow@yeti",
                    isupport::get_casemapping_or_default(&isupport),
                    isupport::get_prefix(&isupport),
                )
                .unwrap(),
                comment: Some("see you later our_nick".to_string()),
                user_channels: user_channels.clone(),
                casemapping: isupport::get_casemapping_or_default(&isupport),
            },
            Broadcast::Nickname {
                old_nick: Nick::from_str(
                    "dan",
                    isupport::get_casemapping_or_default(&isupport),
                ),
                new_nick: Nick::from_str(
                    "dandadan",
                    isupport::get_casemapping_or_default(&isupport),
                ),
                ourself: false,
                user_channels: user_channels.clone(),
                casemapping: isupport::get_casemapping_or_default(&isupport),
            },
            Broadcast::Nickname {
                old_nick: Nick::from_str(
                    "our_old_nick",
                    isupport::get_casemapping_or_default(&isupport),
                ),
                new_nick: Nick::from_str(
                    "our_new_nick",
                    isupport::get_casemapping_or_default(&isupport),
                ),
                ourself: true,
                user_channels: user_channels.clone(),
                casemapping: isupport::get_casemapping_or_default(&isupport),
            },
            Broadcast::ChangeHost {
                old_user: User::parse(
                    "our_nick!old_user@old_host",
                    isupport::get_casemapping_or_default(&isupport),
                    isupport::get_prefix(&isupport),
                )
                .unwrap(),
                new_username: "new_user".to_string(),
                new_hostname: "new_host".to_string(),
                ourself: true,
                logged_in: false,
                user_channels: user_channels.clone(),
                casemapping: isupport::get_casemapping_or_default(&isupport),
            },
            Broadcast::ChangeHost {
                old_user: User::parse(
                    "+nieve!snow@yeti",
                    isupport::get_casemapping_or_default(&isupport),
                    isupport::get_prefix(&isupport),
                )
                .unwrap(),
                new_username: "lava".to_string(),
                new_hostname: "troll".to_string(),
                ourself: false,
                logged_in: true,
                user_channels: user_channels.clone(),
                casemapping: isupport::get_casemapping_or_default(&isupport),
            },
        ]
    }

    pub fn messages_from_broadcast(broadcast: Broadcast) -> Vec<Message> {
        use chrono::Utc;

        use crate::config::Config;

        let channels = vec![
            target::Channel::from_str(
                "##chat",
                isupport::DEFAULT_CHANTYPES,
                isupport::CaseMap::default(),
            ),
            target::Channel::from_str(
                "#halloy",
                isupport::DEFAULT_CHANTYPES,
                isupport::CaseMap::default(),
            ),
            target::Channel::from_str(
                "#libera",
                isupport::DEFAULT_CHANTYPES,
                isupport::CaseMap::default(),
            ),
            target::Channel::from_str(
                "&test-chan",
                isupport::DEFAULT_CHANTYPES,
                isupport::CaseMap::default(),
            ),
        ]
        .into_iter();

        let queries = vec![
            target::Query::from(&User::from(Nick::from_str(
                "dan",
                isupport::CaseMap::default(),
            ))),
            target::Query::from(&User::from(Nick::from_str(
                "WiZ",
                isupport::CaseMap::default(),
            ))),
        ]
        .into_iter();

        broadcast::into_messages(
            broadcast,
            &Config::default(),
            Utc::now(),
            false,
            channels,
            queries,
        )
    }

    // Test consistency between current Message serialization & deserialization
    #[test]
    fn messages_serde() {
        let server = Server {
            name: "Highlight Server".into(),
            network: None,
        };

        let mut messages = SERDE_IRC_MESSAGES
            .iter()
            .flat_map(|irc_message| {
                let (message, highlight) =
                    message_with_highlight_from_irc_message(
                        irc_message,
                        &server,
                    );
                if let Some(highlight) =
                    highlight.map(|highlight| highlight.message)
                {
                    vec![message, highlight]
                } else {
                    vec![message]
                }
            })
            .collect::<Vec<Message>>();

        messages.extend(
            serde_broadcasts()
                .into_iter()
                .flat_map(messages_from_broadcast),
        );

        let bouncer_server = Server {
            name: "Bounced Highlight Server".into(),
            network: Some(
                BouncerNetwork {
                    id: "BouncerNetid".to_string(),
                    name: "Bouncer Name".to_string(),
                }
                .into(),
            ),
        };

        messages.extend(SERDE_IRC_MESSAGES.iter().filter_map(|irc_message| {
            if let (_, Some(Highlight { message, .. })) =
                message_with_highlight_from_irc_message(
                    irc_message,
                    &bouncer_server,
                )
            {
                Some(message)
            } else {
                None
            }
        }));

        for expected in messages {
            let bytes = serde_json::to_vec(&expected).unwrap();

            let actual: Message = serde_json::from_slice(&bytes).unwrap();

            assert_eq!(expected, actual);
        }
    }

    // Test Message deserialization from samples of messages serialized by
    // earlier versions (i.e. backward compatibility)
    #[test]
    fn messages_json_deserialize() {
        use std::fs;

        for file in fs::read_dir("tests/message/").unwrap() {
            let path = file.unwrap().path();
            let file_string = fs::read_to_string(&path).unwrap();
            let _messages: Vec<Message> =
                serde_json::from_str(&file_string).unwrap();
        }
    }
}
