//! # Messages and their identifiers.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::str;

use anyhow::{ensure, format_err, Context as _, Result};
use deltachat_contact_tools::{parse_vcard, VcardContact};
use deltachat_derive::{FromSql, ToSql};
use serde::{Deserialize, Serialize};
use tokio::{fs, io};

use crate::blob::BlobObject;
use crate::chat::{Chat, ChatId, ChatIdBlocked, ChatVisibility};
use crate::chatlist_events;
use crate::config::Config;
use crate::constants::{
    Blocked, Chattype, VideochatType, DC_CHAT_ID_TRASH, DC_DESIRED_TEXT_LEN, DC_MSG_ID_LAST_SPECIAL,
};
use crate::contact::{self, Contact, ContactId};
use crate::context::Context;
use crate::debug_logging::set_debug_logging_xdc;
use crate::download::DownloadState;
use crate::ephemeral::{start_ephemeral_timers_msgids, Timer as EphemeralTimer};
use crate::events::EventType;
use crate::imap::markseen_on_imap_table;
use crate::location::delete_poi_location;
use crate::mimeparser::{parse_message_id, SystemMessage};
use crate::param::{Param, Params};
use crate::pgp::split_armored_data;
use crate::reaction::get_msg_reactions;
use crate::sql;
use crate::summary::Summary;
use crate::tools::{
    buf_compress, buf_decompress, get_filebytes, get_filemeta, gm2local_offset, read_file, time,
    timestamp_to_str, truncate,
};

/// Message ID, including reserved IDs.
///
/// Some message IDs are reserved to identify special message types.
/// This type can represent both the special as well as normal
/// messages.
#[derive(
    Debug, Copy, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
pub struct MsgId(u32);

impl MsgId {
    /// Create a new [MsgId].
    pub fn new(id: u32) -> MsgId {
        MsgId(id)
    }

    /// Create a new unset [MsgId].
    pub fn new_unset() -> MsgId {
        MsgId(0)
    }

    /// Whether the message ID signifies a special message.
    ///
    /// This kind of message ID can not be used for real messages.
    pub fn is_special(self) -> bool {
        self.0 <= DC_MSG_ID_LAST_SPECIAL
    }

    /// Whether the message ID is unset.
    ///
    /// When a message is created it initially has a ID of `0`, which
    /// is filled in by a real message ID once the message is saved in
    /// the database.  This returns true while the message has not
    /// been saved and thus not yet been given an actual message ID.
    ///
    /// When this is `true`, [MsgId::is_special] will also always be
    /// `true`.
    pub fn is_unset(self) -> bool {
        self.0 == 0
    }

    /// Returns message state.
    pub async fn get_state(self, context: &Context) -> Result<MessageState> {
        let result = context
            .sql
            .query_row_optional(
                concat!(
                    "SELECT m.state, mdns.msg_id",
                    " FROM msgs m LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
                    " WHERE id=?",
                    " LIMIT 1",
                ),
                (self,),
                |row| {
                    let state: MessageState = row.get(0)?;
                    let mdn_msg_id: Option<MsgId> = row.get(1)?;
                    Ok(state.with_mdns(mdn_msg_id.is_some()))
                },
            )
            .await?
            .unwrap_or_default();
        Ok(result)
    }

    pub(crate) async fn get_param(self, context: &Context) -> Result<Params> {
        let res: Option<String> = context
            .sql
            .query_get_value("SELECT param FROM msgs WHERE id=?", (self,))
            .await?;
        Ok(res
            .map(|s| s.parse().unwrap_or_default())
            .unwrap_or_default())
    }

    /// Put message into trash chat and delete message text.
    ///
    /// It means the message is deleted locally, but not on the server.
    /// We keep some infos to
    /// 1. not download the same message again
    /// 2. be able to delete the message on the server if we want to
    ///
    /// * `on_server`: Delete the message on the server also if it is seen on IMAP later, but only
    ///   if all parts of the message are trashed with this flag. `true` if the user explicitly
    ///   deletes the message. As for trashing a partially downloaded message when replacing it with
    ///   a fully downloaded one, see `receive_imf::add_parts()`.
    pub async fn trash(self, context: &Context, on_server: bool) -> Result<()> {
        let chat_id = DC_CHAT_ID_TRASH;
        let deleted_subst = match on_server {
            true => ", deleted=1",
            false => "",
        };
        context
            .sql
            .execute(
                // If you change which information is removed here, also change delete_expired_messages() and
                // which information receive_imf::add_parts() still adds to the db if the chat_id is TRASH
                &format!(
                    "UPDATE msgs SET \
                     chat_id=?, txt='', txt_normalized=NULL, \
                     subject='', txt_raw='', \
                     mime_headers='', \
                     from_id=0, to_id=0, \
                     param=''{deleted_subst} \
                     WHERE id=?"
                ),
                (chat_id, self),
            )
            .await?;

        Ok(())
    }

    pub(crate) async fn set_delivered(self, context: &Context) -> Result<()> {
        update_msg_state(context, self, MessageState::OutDelivered).await?;
        let chat_id: Option<ChatId> = context
            .sql
            .query_get_value("SELECT chat_id FROM msgs WHERE id=?", (self,))
            .await?;
        context.emit_event(EventType::MsgDelivered {
            chat_id: chat_id.unwrap_or_default(),
            msg_id: self,
        });
        if let Some(chat_id) = chat_id {
            chatlist_events::emit_chatlist_item_changed(context, chat_id);
        }
        Ok(())
    }

    /// Bad evil escape hatch.
    ///
    /// Avoid using this, eventually types should be cleaned up enough
    /// that it is no longer necessary.
    pub fn to_u32(self) -> u32 {
        self.0
    }

    /// Returns raw text of a message, used for message info
    pub async fn rawtext(self, context: &Context) -> Result<String> {
        Ok(context
            .sql
            .query_get_value("SELECT txt_raw FROM msgs WHERE id=?", (self,))
            .await?
            .unwrap_or_default())
    }

    /// Returns server foldernames and UIDs of a message, used for message info
    pub async fn get_info_server_urls(
        context: &Context,
        rfc724_mid: String,
    ) -> Result<Vec<String>> {
        context
            .sql
            .query_map(
                "SELECT folder, uid FROM imap WHERE rfc724_mid=?",
                (rfc724_mid,),
                |row| {
                    let folder: String = row.get("folder")?;
                    let uid: u32 = row.get("uid")?;
                    Ok(format!("</{folder}/;UID={uid}>"))
                },
                |rows| {
                    rows.collect::<std::result::Result<Vec<_>, _>>()
                        .map_err(Into::into)
                },
            )
            .await
    }

    /// Returns information about hops of a message, used for message info
    pub async fn hop_info(self, context: &Context) -> Result<String> {
        let hop_info = context
            .sql
            .query_get_value("SELECT IFNULL(hop_info, '') FROM msgs WHERE id=?", (self,))
            .await?
            .with_context(|| format!("Message {self} not found"))?;
        Ok(hop_info)
    }

    /// Returns detailed message information in a multi-line text form.
    pub async fn get_info(self, context: &Context) -> Result<String> {
        let msg = Message::load_from_db(context, self).await?;
        let rawtxt: String = self.rawtext(context).await?;

        let mut ret = String::new();

        let rawtxt = truncate(rawtxt.trim(), DC_DESIRED_TEXT_LEN);

        let fts = timestamp_to_str(msg.get_timestamp());
        ret += &format!("Sent: {fts}");

        let from_contact = Contact::get_by_id(context, msg.from_id).await?;
        let name = from_contact.get_name_n_addr();
        if let Some(override_sender_name) = msg.get_override_sender_name() {
            let addr = from_contact.get_addr();
            ret += &format!(" by ~{override_sender_name} ({addr})");
        } else {
            ret += &format!(" by {name}");
        }
        ret += "\n";

        if msg.from_id != ContactId::SELF {
            let s = timestamp_to_str(if 0 != msg.timestamp_rcvd {
                msg.timestamp_rcvd
            } else {
                msg.timestamp_sort
            });
            ret += &format!("Received: {}", &s);
            ret += "\n";
        }

        if let EphemeralTimer::Enabled { duration } = msg.ephemeral_timer {
            ret += &format!("Ephemeral timer: {duration}\n");
        }

        if msg.ephemeral_timestamp != 0 {
            ret += &format!("Expires: {}\n", timestamp_to_str(msg.ephemeral_timestamp));
        }

        if msg.from_id == ContactId::INFO || msg.to_id == ContactId::INFO {
            // device-internal message, no further details needed
            return Ok(ret);
        }

        if let Ok(rows) = context
            .sql
            .query_map(
                "SELECT contact_id, timestamp_sent FROM msgs_mdns WHERE msg_id=?",
                (self,),
                |row| {
                    let contact_id: ContactId = row.get(0)?;
                    let ts: i64 = row.get(1)?;
                    Ok((contact_id, ts))
                },
                |rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
            )
            .await
        {
            for (contact_id, ts) in rows {
                let fts = timestamp_to_str(ts);
                ret += &format!("Read: {fts}");

                let name = Contact::get_by_id(context, contact_id)
                    .await
                    .map(|contact| contact.get_name_n_addr())
                    .unwrap_or_default();

                ret += &format!(" by {name}");
                ret += "\n";
            }
        }

        ret += &format!("State: {}", msg.state);

        if msg.has_location() {
            ret += ", Location sent";
        }

        if 0 != msg.param.get_int(Param::GuaranteeE2ee).unwrap_or_default() {
            ret += ", Encrypted";
        }

        ret += "\n";

        let reactions = get_msg_reactions(context, self).await?;
        if !reactions.is_empty() {
            ret += &format!("Reactions: {reactions}\n");
        }

        if let Some(error) = msg.error.as_ref() {
            ret += &format!("Error: {error}");
        }

        if let Some(path) = msg.get_file(context) {
            let bytes = get_filebytes(context, &path).await?;
            ret += &format!(
                "\nFile: {}, name: {}, {} bytes\n",
                path.display(),
                msg.get_filename().unwrap_or_default(),
                bytes
            );
        }

        if msg.viewtype != Viewtype::Text {
            ret += "Type: ";
            ret += &format!("{}", msg.viewtype);
            ret += "\n";
            ret += &format!("Mimetype: {}\n", &msg.get_filemime().unwrap_or_default());
        }
        let w = msg.param.get_int(Param::Width).unwrap_or_default();
        let h = msg.param.get_int(Param::Height).unwrap_or_default();
        if w != 0 || h != 0 {
            ret += &format!("Dimension: {w} x {h}\n",);
        }
        let duration = msg.param.get_int(Param::Duration).unwrap_or_default();
        if duration != 0 {
            ret += &format!("Duration: {duration} ms\n",);
        }
        if !rawtxt.is_empty() {
            ret += &format!("\n{rawtxt}\n");
        }
        if !msg.rfc724_mid.is_empty() {
            ret += &format!("\nMessage-ID: {}", msg.rfc724_mid);

            let server_urls = Self::get_info_server_urls(context, msg.rfc724_mid).await?;
            for server_url in server_urls {
                // Format as RFC 5092 relative IMAP URL.
                ret += &format!("\nServer-URL: {server_url}");
            }
        }
        let hop_info = self.hop_info(context).await?;

        ret += "\n\n";
        if hop_info.is_empty() {
            ret += "No Hop Info";
        } else {
            ret += &hop_info;
        }

        Ok(ret)
    }
}

impl std::fmt::Display for MsgId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Msg#{}", self.0)
    }
}

/// Allow converting [MsgId] to an SQLite type.
///
/// This allows you to directly store [MsgId] into the database.
///
/// # Errors
///
/// This **does** ensure that no special message IDs are written into
/// the database and the conversion will fail if this is not the case.
impl rusqlite::types::ToSql for MsgId {
    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
        if self.0 <= DC_MSG_ID_LAST_SPECIAL {
            return Err(rusqlite::Error::ToSqlConversionFailure(
                format_err!("Invalid MsgId {}", self.0).into(),
            ));
        }
        let val = rusqlite::types::Value::Integer(i64::from(self.0));
        let out = rusqlite::types::ToSqlOutput::Owned(val);
        Ok(out)
    }
}

/// Allow converting an SQLite integer directly into [MsgId].
impl rusqlite::types::FromSql for MsgId {
    fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult<Self> {
        // Would be nice if we could use match here, but alas.
        i64::column_result(value).and_then(|val| {
            if 0 <= val && val <= i64::from(u32::MAX) {
                Ok(MsgId::new(val as u32))
            } else {
                Err(rusqlite::types::FromSqlError::OutOfRange(val))
            }
        })
    }
}

#[derive(
    Debug,
    Copy,
    Clone,
    PartialEq,
    FromPrimitive,
    ToPrimitive,
    FromSql,
    ToSql,
    Serialize,
    Deserialize,
)]
#[repr(u8)]
pub(crate) enum MessengerMessage {
    No = 0,
    Yes = 1,

    /// No, but reply to messenger message.
    Reply = 2,
}

impl Default for MessengerMessage {
    fn default() -> Self {
        Self::No
    }
}

/// An object representing a single message in memory.
/// The message object is not updated.
/// If you want an update, you have to recreate the object.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Message {
    /// Message ID.
    pub(crate) id: MsgId,

    /// `From:` contact ID.
    pub(crate) from_id: ContactId,

    /// ID of the first contact in the `To:` header.
    pub(crate) to_id: ContactId,

    /// ID of the chat message belongs to.
    pub(crate) chat_id: ChatId,

    /// Type of the message.
    pub(crate) viewtype: Viewtype,

    /// State of the message.
    pub(crate) state: MessageState,
    pub(crate) download_state: DownloadState,

    /// Whether the message is hidden.
    pub(crate) hidden: bool,
    pub(crate) timestamp_sort: i64,
    pub(crate) timestamp_sent: i64,
    pub(crate) timestamp_rcvd: i64,
    pub(crate) ephemeral_timer: EphemeralTimer,
    pub(crate) ephemeral_timestamp: i64,
    pub(crate) text: String,

    /// Message subject.
    ///
    /// If empty, a default subject will be generated when sending.
    pub(crate) subject: String,

    /// `Message-ID` header value.
    pub(crate) rfc724_mid: String,

    /// `In-Reply-To` header value.
    pub(crate) in_reply_to: Option<String>,
    pub(crate) is_dc_message: MessengerMessage,
    pub(crate) mime_modified: bool,
    pub(crate) chat_blocked: Blocked,
    pub(crate) location_id: u32,
    pub(crate) error: Option<String>,
    pub(crate) param: Params,
}

impl Message {
    /// Creates a new message with given view type.
    pub fn new(viewtype: Viewtype) -> Self {
        Message {
            viewtype,
            ..Default::default()
        }
    }

    /// Creates a new message with Viewtype::Text.
    pub fn new_text(text: String) -> Self {
        Message {
            viewtype: Viewtype::Text,
            text,
            ..Default::default()
        }
    }

    /// Loads message with given ID from the database.
    ///
    /// Returns an error if the message does not exist.
    pub async fn load_from_db(context: &Context, id: MsgId) -> Result<Message> {
        let message = Self::load_from_db_optional(context, id)
            .await?
            .with_context(|| format!("Message {id} does not exist"))?;
        Ok(message)
    }

    /// Loads message with given ID from the database.
    ///
    /// Returns `None` if the message does not exist.
    pub async fn load_from_db_optional(context: &Context, id: MsgId) -> Result<Option<Message>> {
        ensure!(
            !id.is_special(),
            "Can not load special message ID {} from DB",
            id
        );
        let msg = context
            .sql
            .query_row_optional(
                concat!(
                    "SELECT",
                    "    m.id AS id,",
                    "    rfc724_mid AS rfc724mid,",
                    "    m.mime_in_reply_to AS mime_in_reply_to,",
                    "    m.chat_id AS chat_id,",
                    "    m.from_id AS from_id,",
                    "    m.to_id AS to_id,",
                    "    m.timestamp AS timestamp,",
                    "    m.timestamp_sent AS timestamp_sent,",
                    "    m.timestamp_rcvd AS timestamp_rcvd,",
                    "    m.ephemeral_timer AS ephemeral_timer,",
                    "    m.ephemeral_timestamp AS ephemeral_timestamp,",
                    "    m.type AS type,",
                    "    m.state AS state,",
                    "    mdns.msg_id AS mdn_msg_id,",
                    "    m.download_state AS download_state,",
                    "    m.error AS error,",
                    "    m.msgrmsg AS msgrmsg,",
                    "    m.mime_modified AS mime_modified,",
                    "    m.txt AS txt,",
                    "    m.subject AS subject,",
                    "    m.param AS param,",
                    "    m.hidden AS hidden,",
                    "    m.location_id AS location,",
                    "    c.blocked AS blocked",
                    " FROM msgs m",
                    " LEFT JOIN chats c ON c.id=m.chat_id",
                    " LEFT JOIN msgs_mdns mdns ON mdns.msg_id=m.id",
                    " WHERE m.id=? AND chat_id!=3",
                    " LIMIT 1",
                ),
                (id,),
                |row| {
                    let state: MessageState = row.get("state")?;
                    let mdn_msg_id: Option<MsgId> = row.get("mdn_msg_id")?;
                    let text = match row.get_ref("txt")? {
                        rusqlite::types::ValueRef::Text(buf) => {
                            match String::from_utf8(buf.to_vec()) {
                                Ok(t) => t,
                                Err(_) => {
                                    warn!(
                                        context,
                                        concat!(
                                            "dc_msg_load_from_db: could not get ",
                                            "text column as non-lossy utf8 id {}"
                                        ),
                                        id
                                    );
                                    String::from_utf8_lossy(buf).into_owned()
                                }
                            }
                        }
                        _ => String::new(),
                    };
                    let msg = Message {
                        id: row.get("id")?,
                        rfc724_mid: row.get::<_, String>("rfc724mid")?,
                        in_reply_to: row
                            .get::<_, Option<String>>("mime_in_reply_to")?
                            .and_then(|in_reply_to| parse_message_id(&in_reply_to).ok()),
                        chat_id: row.get("chat_id")?,
                        from_id: row.get("from_id")?,
                        to_id: row.get("to_id")?,
                        timestamp_sort: row.get("timestamp")?,
                        timestamp_sent: row.get("timestamp_sent")?,
                        timestamp_rcvd: row.get("timestamp_rcvd")?,
                        ephemeral_timer: row.get("ephemeral_timer")?,
                        ephemeral_timestamp: row.get("ephemeral_timestamp")?,
                        viewtype: row.get("type")?,
                        state: state.with_mdns(mdn_msg_id.is_some()),
                        download_state: row.get("download_state")?,
                        error: Some(row.get::<_, String>("error")?)
                            .filter(|error| !error.is_empty()),
                        is_dc_message: row.get("msgrmsg")?,
                        mime_modified: row.get("mime_modified")?,
                        text,
                        subject: row.get("subject")?,
                        param: row.get::<_, String>("param")?.parse().unwrap_or_default(),
                        hidden: row.get("hidden")?,
                        location_id: row.get("location")?,
                        chat_blocked: row
                            .get::<_, Option<Blocked>>("blocked")?
                            .unwrap_or_default(),
                    };
                    Ok(msg)
                },
            )
            .await
            .with_context(|| format!("failed to load message {id} from the database"))?;

        Ok(msg)
    }

    /// Returns the MIME type of an attached file if it exists.
    ///
    /// If the MIME type is not known, the function guesses the MIME type
    /// from the extension. `application/octet-stream` is used as a fallback
    /// if MIME type is not known, but `None` is only returned if no file
    /// is attached.
    pub fn get_filemime(&self) -> Option<String> {
        if let Some(m) = self.param.get(Param::MimeType) {
            return Some(m.to_string());
        } else if let Some(file) = self.param.get(Param::File) {
            if let Some((_, mime)) = guess_msgtype_from_suffix(Path::new(file)) {
                return Some(mime.to_string());
            }
            // we have a file but no mimetype, let's use a generic one
            return Some("application/octet-stream".to_string());
        }
        // no mimetype and no file
        None
    }

    /// Returns the full path to the file associated with a message.
    pub fn get_file(&self, context: &Context) -> Option<PathBuf> {
        self.param.get_path(Param::File, context).unwrap_or(None)
    }

    /// Returns vector of vcards if the file has a vCard attachment.
    pub async fn vcard_contacts(&self, context: &Context) -> Result<Vec<VcardContact>> {
        if self.viewtype != Viewtype::Vcard {
            return Ok(Vec::new());
        }

        let path = self
            .get_file(context)
            .context("vCard message does not have an attachment")?;
        let bytes = tokio::fs::read(path).await?;
        let vcard_contents = std::str::from_utf8(&bytes).context("vCard is not a valid UTF-8")?;
        Ok(parse_vcard(vcard_contents))
    }

    /// Save file copy at the user-provided path.
    pub async fn save_file(&self, context: &Context, path: &Path) -> Result<()> {
        let path_src = self.get_file(context).context("No file")?;
        let mut src = fs::OpenOptions::new().read(true).open(path_src).await?;
        let mut dst = fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(path)
            .await?;
        io::copy(&mut src, &mut dst).await?;
        Ok(())
    }

    /// If message is an image or gif, set Param::Width and Param::Height
    pub(crate) async fn try_calc_and_set_dimensions(&mut self, context: &Context) -> Result<()> {
        if self.viewtype.has_file() {
            let file_param = self.param.get_path(Param::File, context)?;
            if let Some(path_and_filename) = file_param {
                if (self.viewtype == Viewtype::Image || self.viewtype == Viewtype::Gif)
                    && !self.param.exists(Param::Width)
                {
                    let buf = read_file(context, &path_and_filename).await?;

                    match get_filemeta(&buf) {
                        Ok((width, height)) => {
                            self.param.set_int(Param::Width, width as i32);
                            self.param.set_int(Param::Height, height as i32);
                        }
                        Err(err) => {
                            self.param.set_int(Param::Width, 0);
                            self.param.set_int(Param::Height, 0);
                            warn!(
                                context,
                                "Failed to get width and height for {}: {err:#}.",
                                path_and_filename.display()
                            );
                        }
                    }

                    if !self.id.is_unset() {
                        self.update_param(context).await?;
                    }
                }
            }
        }
        Ok(())
    }

    /// Check if a message has a POI location bound to it.
    /// These locations are also returned by [`location::get_range()`].
    /// The UI may decide to display a special icon beside such messages.
    ///
    /// [`location::get_range()`]: crate::location::get_range
    pub fn has_location(&self) -> bool {
        self.location_id != 0
    }

    /// Set any location that should be bound to the message object.
    /// The function is useful to add a marker to the map
    /// at a position different from the self-location.
    /// You should not call this function
    /// if you want to bind the current self-location to a message;
    /// this is done by [`location::set()`] and [`send_locations_to_chat()`].
    ///
    /// Typically results in the event [`LocationChanged`] with
    /// `contact_id` set to [`ContactId::SELF`].
    ///
    /// `latitude` is the North-south position of the location.
    /// `longitude` is the East-west position of the location.
    ///
    /// [`location::set()`]: crate::location::set
    /// [`send_locations_to_chat()`]: crate::location::send_locations_to_chat
    /// [`LocationChanged`]: crate::events::EventType::LocationChanged
    pub fn set_location(&mut self, latitude: f64, longitude: f64) {
        if latitude == 0.0 && longitude == 0.0 {
            return;
        }

        self.param.set_float(Param::SetLatitude, latitude);
        self.param.set_float(Param::SetLongitude, longitude);
    }

    /// Returns the message timestamp for display in the UI
    /// as a unix timestamp in seconds.
    pub fn get_timestamp(&self) -> i64 {
        if 0 != self.timestamp_sent {
            self.timestamp_sent
        } else {
            self.timestamp_sort
        }
    }

    /// Returns the message ID.
    pub fn get_id(&self) -> MsgId {
        self.id
    }

    /// Returns the rfc724 message ID
    /// May be empty
    pub fn rfc724_mid(&self) -> &str {
        &self.rfc724_mid
    }

    /// Returns the ID of the contact who wrote the message.
    pub fn get_from_id(&self) -> ContactId {
        self.from_id
    }

    /// Returns the chat ID.
    pub fn get_chat_id(&self) -> ChatId {
        self.chat_id
    }

    /// Returns the type of the message.
    pub fn get_viewtype(&self) -> Viewtype {
        self.viewtype
    }

    /// Forces the message to **keep** [Viewtype::Sticker]
    /// e.g the message will not be converted to a [Viewtype::Image].
    pub fn force_sticker(&mut self) {
        self.param.set_int(Param::ForceSticker, 1);
    }

    /// Returns the state of the message.
    pub fn get_state(&self) -> MessageState {
        self.state
    }

    /// Returns the message receive time as a unix timestamp in seconds.
    pub fn get_received_timestamp(&self) -> i64 {
        self.timestamp_rcvd
    }

    /// Returns the timestamp of the message for sorting.
    pub fn get_sort_timestamp(&self) -> i64 {
        self.timestamp_sort
    }

    /// Returns the text of the message.
    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    /// Returns message subject.
    pub fn get_subject(&self) -> &str {
        &self.subject
    }

    /// Returns original filename (as shown in chat).
    ///
    /// To get the full path, use [`Self::get_file()`].
    pub fn get_filename(&self) -> Option<String> {
        if let Some(name) = self.param.get(Param::Filename) {
            return Some(name.to_string());
        }
        self.param
            .get(Param::File)
            .and_then(|file| Path::new(file).file_name())
            .map(|name| name.to_string_lossy().to_string())
    }

    /// Returns the size of the file in bytes, if applicable.
    pub async fn get_filebytes(&self, context: &Context) -> Result<Option<u64>> {
        if let Some(path) = self.param.get_path(Param::File, context)? {
            Ok(Some(get_filebytes(context, &path).await.with_context(
                || format!("failed to get {} size in bytes", path.display()),
            )?))
        } else {
            Ok(None)
        }
    }

    /// Returns width of associated image or video file.
    pub fn get_width(&self) -> i32 {
        self.param.get_int(Param::Width).unwrap_or_default()
    }

    /// Returns height of associated image or video file.
    pub fn get_height(&self) -> i32 {
        self.param.get_int(Param::Height).unwrap_or_default()
    }

    /// Returns duration of associated audio or video file.
    pub fn get_duration(&self) -> i32 {
        self.param.get_int(Param::Duration).unwrap_or_default()
    }

    /// Returns true if padlock indicating message encryption should be displayed in the UI.
    pub fn get_showpadlock(&self) -> bool {
        self.param.get_int(Param::GuaranteeE2ee).unwrap_or_default() != 0
    }

    /// Returns true if message is auto-generated.
    pub fn is_bot(&self) -> bool {
        self.param.get_bool(Param::Bot).unwrap_or_default()
    }

    /// Return the ephemeral timer duration for a message.
    pub fn get_ephemeral_timer(&self) -> EphemeralTimer {
        self.ephemeral_timer
    }

    /// Returns the timestamp of the epehemeral message removal.
    pub fn get_ephemeral_timestamp(&self) -> i64 {
        self.ephemeral_timestamp
    }

    /// Returns message summary for display in the search results.
    pub async fn get_summary(&self, context: &Context, chat: Option<&Chat>) -> Result<Summary> {
        let chat_loaded: Chat;
        let chat = if let Some(chat) = chat {
            chat
        } else {
            let chat = Chat::load_from_db(context, self.chat_id).await?;
            chat_loaded = chat;
            &chat_loaded
        };

        let contact = if self.from_id != ContactId::SELF {
            match chat.typ {
                Chattype::Group | Chattype::Broadcast | Chattype::Mailinglist => {
                    Some(Contact::get_by_id(context, self.from_id).await?)
                }
                Chattype::Single => None,
            }
        } else {
            None
        };

        Summary::new(context, self, chat, contact.as_ref()).await
    }

    // It's a little unfortunate that the UI has to first call `dc_msg_get_override_sender_name` and then if it was `NULL`, call
    // `dc_contact_get_display_name` but this was the best solution:
    // - We could load a Contact struct from the db here to call `dc_get_display_name` instead of returning `None`, but then we had a db
    //   call every time (and this fn is called a lot while the user is scrolling through a group), so performance would be bad
    // - We could pass both a Contact struct and a Message struct in the FFI, but at least on Android we would need to handle raw
    //   C-data in the Java code (i.e. a `long` storing a C pointer)
    // - We can't make a param `SenderDisplayname` for messages as sometimes the display name of a contact changes, and we want to show
    //   the same display name over all messages from the same sender.
    /// Returns the name that should be shown over the message instead of the contact display ame.
    pub fn get_override_sender_name(&self) -> Option<String> {
        self.param
            .get(Param::OverrideSenderDisplayname)
            .map(|name| name.to_string())
    }

    // Exposing this function over the ffi instead of get_override_sender_name() would mean that at least Android Java code has
    // to handle raw C-data (as it is done for msg_get_summary())
    pub(crate) fn get_sender_name(&self, contact: &Contact) -> String {
        self.get_override_sender_name()
            .unwrap_or_else(|| contact.get_display_name().to_string())
    }

    /// Returns true if a message has a deviating timestamp.
    ///
    /// A message has a deviating timestamp when it is sent on
    /// another day as received/sorted by.
    pub fn has_deviating_timestamp(&self) -> bool {
        let cnv_to_local = gm2local_offset();
        let sort_timestamp = self.get_sort_timestamp() + cnv_to_local;
        let send_timestamp = self.get_timestamp() + cnv_to_local;

        sort_timestamp / 86400 != send_timestamp / 86400
    }

    /// Returns true if the message was successfully delivered to the outgoing server or even
    /// received a read receipt.
    pub fn is_sent(&self) -> bool {
        self.state >= MessageState::OutDelivered
    }

    /// Returns true if the message is a forwarded message.
    pub fn is_forwarded(&self) -> bool {
        0 != self.param.get_int(Param::Forwarded).unwrap_or_default()
    }

    /// Returns true if the message is an informational message.
    pub fn is_info(&self) -> bool {
        let cmd = self.param.get_cmd();
        self.from_id == ContactId::INFO
            || self.to_id == ContactId::INFO
            || cmd != SystemMessage::Unknown && cmd != SystemMessage::AutocryptSetupMessage
    }

    /// Returns the type of an informational message.
    pub fn get_info_type(&self) -> SystemMessage {
        self.param.get_cmd()
    }

    /// Returns true if the message is a system message.
    pub fn is_system_message(&self) -> bool {
        let cmd = self.param.get_cmd();
        cmd != SystemMessage::Unknown
    }

    /// Returns true if the message is an Autocrypt Setup Message.
    pub fn is_setupmessage(&self) -> bool {
        if self.viewtype != Viewtype::File {
            return false;
        }

        self.param.get_cmd() == SystemMessage::AutocryptSetupMessage
    }

    /// Returns the first characters of the setup code.
    ///
    /// This is used to pre-fill the first entry field of the setup code.
    pub async fn get_setupcodebegin(&self, context: &Context) -> Option<String> {
        if !self.is_setupmessage() {
            return None;
        }

        if let Some(filename) = self.get_file(context) {
            if let Ok(ref buf) = read_file(context, filename).await {
                if let Ok((typ, headers, _)) = split_armored_data(buf) {
                    if typ == pgp::armor::BlockType::Message {
                        return headers.get(crate::pgp::HEADER_SETUPCODE).cloned();
                    }
                }
            }
        }

        None
    }

    // add room to a webrtc_instance as defined by the corresponding config-value;
    // the result may still be prefixed by the type
    pub(crate) fn create_webrtc_instance(instance: &str, room: &str) -> String {
        let (videochat_type, mut url) = Message::parse_webrtc_instance(instance);

        // make sure, there is a scheme in the url
        if !url.contains(':') {
            url = format!("https://{url}");
        }

        // add/replace room
        let url = if url.contains("$ROOM") {
            url.replace("$ROOM", room)
        } else if url.contains("$NOROOM") {
            // there are some usecases where a separate room is not needed to use a service
            // eg. if you let in people manually anyway, see discussion at
            // <https://support.delta.chat/t/videochat-with-webex/1412/4>.
            // hacks as hiding the room behind `#` are not reliable, therefore,
            // these services are supported by adding the string `$NOROOM` to the url.
            url.replace("$NOROOM", "")
        } else {
            // if there nothing that would separate the room, add a slash as a separator;
            // this way, urls can be given as "https://meet.jit.si" as well as "https://meet.jit.si/"
            let maybe_slash = if url.ends_with('/')
                || url.ends_with('?')
                || url.ends_with('#')
                || url.ends_with('=')
            {
                ""
            } else {
                "/"
            };
            format!("{url}{maybe_slash}{room}")
        };

        // re-add and normalize type
        match videochat_type {
            VideochatType::BasicWebrtc => format!("basicwebrtc:{url}"),
            VideochatType::Jitsi => format!("jitsi:{url}"),
            VideochatType::Unknown => url,
        }
    }

    /// split a webrtc_instance as defined by the corresponding config-value into a type and a url
    pub fn parse_webrtc_instance(instance: &str) -> (VideochatType, String) {
        let instance: String = instance.split_whitespace().collect();
        let mut split = instance.splitn(2, ':');
        let type_str = split.next().unwrap_or_default().to_lowercase();
        let url = split.next();
        match type_str.as_str() {
            "basicwebrtc" => (
                VideochatType::BasicWebrtc,
                url.unwrap_or_default().to_string(),
            ),
            "jitsi" => (VideochatType::Jitsi, url.unwrap_or_default().to_string()),
            _ => (VideochatType::Unknown, instance.to_string()),
        }
    }

    /// Returns videochat URL if the message is a videochat invitation.
    pub fn get_videochat_url(&self) -> Option<String> {
        if self.viewtype == Viewtype::VideochatInvitation {
            if let Some(instance) = self.param.get(Param::WebrtcRoom) {
                return Some(Message::parse_webrtc_instance(instance).1);
            }
        }
        None
    }

    /// Returns videochat type if the message is a videochat invitation.
    pub fn get_videochat_type(&self) -> Option<VideochatType> {
        if self.viewtype == Viewtype::VideochatInvitation {
            if let Some(instance) = self.param.get(Param::WebrtcRoom) {
                return Some(Message::parse_webrtc_instance(instance).0);
            }
        }
        None
    }

    /// Sets or unsets message text.
    pub fn set_text(&mut self, text: String) {
        self.text = text;
    }

    /// Sets the email's subject. If it's empty, a default subject
    /// will be used (e.g. `Message from Alice` or `Re: <last subject>`).
    pub fn set_subject(&mut self, subject: String) {
        self.subject = subject;
    }

    /// Sets the file associated with a message.
    ///
    /// This function does not use the file or check if it exists,
    /// the file will only be used when the message is prepared
    /// for sending.
    pub fn set_file(&mut self, file: impl ToString, filemime: Option<&str>) {
        if let Some(name) = Path::new(&file.to_string()).file_name() {
            if let Some(name) = name.to_str() {
                self.param.set(Param::Filename, name);
            }
        }
        self.param.set(Param::File, file);
        self.param.set_optional(Param::MimeType, filemime);
    }

    /// Creates a new blob and sets it as a file associated with a message.
    pub async fn set_file_from_bytes(
        &mut self,
        context: &Context,
        suggested_name: &str,
        data: &[u8],
        filemime: Option<&str>,
    ) -> Result<()> {
        let blob = BlobObject::create(context, suggested_name, data).await?;
        self.param.set(Param::Filename, suggested_name);
        self.param.set(Param::File, blob.as_name());
        self.param.set_optional(Param::MimeType, filemime);
        Ok(())
    }

    /// Makes message a vCard-containing message using the specified contacts.
    pub async fn make_vcard(&mut self, context: &Context, contacts: &[ContactId]) -> Result<()> {
        ensure!(
            matches!(self.viewtype, Viewtype::File | Viewtype::Vcard),
            "Wrong viewtype for vCard: {}",
            self.viewtype,
        );
        let vcard = contact::make_vcard(context, contacts).await?;
        self.set_file_from_bytes(context, "vcard.vcf", vcard.as_bytes(), None)
            .await
    }

    /// Updates message state from the vCard attachment.
    pub(crate) async fn try_set_vcard(&mut self, context: &Context, path: &Path) -> Result<()> {
        let vcard = fs::read(path).await.context("Could not read {path}")?;
        if let Some(summary) = get_vcard_summary(&vcard) {
            self.param.set(Param::Summary1, summary);
        } else {
            warn!(context, "try_set_vcard: Not a valid DeltaChat vCard.");
            self.viewtype = Viewtype::File;
        }
        Ok(())
    }

    /// Set different sender name for a message.
    /// This overrides the name set by the `set_config()`-option `displayname`.
    pub fn set_override_sender_name(&mut self, name: Option<String>) {
        self.param
            .set_optional(Param::OverrideSenderDisplayname, name);
    }

    /// Sets the dimensions of associated image or video file.
    pub fn set_dimension(&mut self, width: i32, height: i32) {
        self.param.set_int(Param::Width, width);
        self.param.set_int(Param::Height, height);
    }

    /// Sets the duration of associated audio or video file.
    pub fn set_duration(&mut self, duration: i32) {
        self.param.set_int(Param::Duration, duration);
    }

    /// Marks the message as reaction.
    pub(crate) fn set_reaction(&mut self) {
        self.param.set_int(Param::Reaction, 1);
    }

    /// Changes the message width, height or duration,
    /// and stores it into the database.
    pub async fn latefiling_mediasize(
        &mut self,
        context: &Context,
        width: i32,
        height: i32,
        duration: i32,
    ) -> Result<()> {
        if width > 0 && height > 0 {
            self.param.set_int(Param::Width, width);
            self.param.set_int(Param::Height, height);
        }
        if duration > 0 {
            self.param.set_int(Param::Duration, duration);
        }
        self.update_param(context).await?;
        Ok(())
    }

    /// Sets message quote text.
    ///
    /// If `text` is `Some((text_str, protect))`, `protect` specifies whether `text_str` should only
    /// be sent encrypted. If it should, but the message is unencrypted, `text_str` is replaced with
    /// "...".
    pub fn set_quote_text(&mut self, text: Option<(String, bool)>) {
        let Some((text, protect)) = text else {
            self.param.remove(Param::Quote);
            self.param.remove(Param::ProtectQuote);
            return;
        };
        self.param.set(Param::Quote, text);
        self.param.set_optional(
            Param::ProtectQuote,
            match protect {
                true => Some("1"),
                false => None,
            },
        );
    }

    /// Sets message quote.
    ///
    /// Message-Id is used to set Reply-To field, message text is used for quote.
    ///
    /// Encryption is required if quoted message was encrypted.
    ///
    /// The message itself is not required to exist in the database,
    /// it may even be deleted from the database by the time the message is prepared.
    pub async fn set_quote(&mut self, context: &Context, quote: Option<&Message>) -> Result<()> {
        if let Some(quote) = quote {
            ensure!(
                !quote.rfc724_mid.is_empty(),
                "Message without Message-Id cannot be quoted"
            );
            self.in_reply_to = Some(quote.rfc724_mid.clone());

            let text = quote.get_text();
            let text = if text.is_empty() {
                // Use summary, similar to "Image" to avoid sending empty quote.
                quote
                    .get_summary(context, None)
                    .await?
                    .truncated_text(500)
                    .to_string()
            } else {
                text
            };
            self.set_quote_text(Some((
                text,
                quote
                    .param
                    .get_bool(Param::GuaranteeE2ee)
                    .unwrap_or_default(),
            )));
        } else {
            self.in_reply_to = None;
            self.set_quote_text(None);
        }

        Ok(())
    }

    /// Returns quoted message text, if any.
    pub fn quoted_text(&self) -> Option<String> {
        self.param.get(Param::Quote).map(|s| s.to_string())
    }

    /// Returns quoted message, if any.
    pub async fn quoted_message(&self, context: &Context) -> Result<Option<Message>> {
        if self.param.get(Param::Quote).is_some() && !self.is_forwarded() {
            return self.parent(context).await;
        }
        Ok(None)
    }

    /// Returns parent message according to the `In-Reply-To` header
    /// if it exists in the database and is not trashed.
    ///
    /// `References` header is not taken into account.
    pub async fn parent(&self, context: &Context) -> Result<Option<Message>> {
        if let Some(in_reply_to) = &self.in_reply_to {
            if let Some((msg_id, _ts_sent)) = rfc724_mid_exists(context, in_reply_to).await? {
                let msg = Message::load_from_db_optional(context, msg_id).await?;
                return Ok(msg);
            }
        }
        Ok(None)
    }

    /// Force the message to be sent in plain text.
    pub fn force_plaintext(&mut self) {
        self.param.set_int(Param::ForcePlaintext, 1);
    }

    /// Updates `param` column of the message in the database without changing other columns.
    pub async fn update_param(&self, context: &Context) -> Result<()> {
        context
            .sql
            .execute(
                "UPDATE msgs SET param=? WHERE id=?;",
                (self.param.to_string(), self.id),
            )
            .await?;
        Ok(())
    }

    pub(crate) async fn update_subject(&self, context: &Context) -> Result<()> {
        context
            .sql
            .execute(
                "UPDATE msgs SET subject=? WHERE id=?;",
                (&self.subject, self.id),
            )
            .await?;
        Ok(())
    }

    /// Gets the error status of the message.
    ///
    /// A message can have an associated error status if something went wrong when sending or
    /// receiving message itself.  The error status is free-form text and should not be further parsed,
    /// rather it's presence is meant to indicate *something* went wrong with the message and the
    /// text of the error is detailed information on what.
    ///
    /// Some common reasons error can be associated with messages are:
    /// * Lack of valid signature on an e2ee message, usually for received messages.
    /// * Failure to decrypt an e2ee message, usually for received messages.
    /// * When a message could not be delivered to one or more recipients the non-delivery
    ///    notification text can be stored in the error status.
    pub fn error(&self) -> Option<String> {
        self.error.clone()
    }
}

/// State of the message.
/// For incoming messages, stores the information on whether the message was read or not.
/// For outgoing message, the message could be pending, already delivered or confirmed.
#[derive(
    Debug,
    Default,
    Clone,
    Copy,
    PartialEq,
    Eq,
    PartialOrd,
    Ord,
    FromPrimitive,
    ToPrimitive,
    ToSql,
    FromSql,
    Serialize,
    Deserialize,
)]
#[repr(u32)]
pub enum MessageState {
    /// Undefined message state.
    #[default]
    Undefined = 0,

    /// Incoming *fresh* message. Fresh messages are neither noticed
    /// nor seen and are typically shown in notifications.
    InFresh = 10,

    /// Incoming *noticed* message. E.g. chat opened but message not
    /// yet read - noticed messages are not counted as unread but did
    /// not marked as read nor resulted in MDNs.
    InNoticed = 13,

    /// Incoming message, really *seen* by the user. Marked as read on
    /// IMAP and MDN may be sent.
    InSeen = 16,

    /// For files which need time to be prepared before they can be
    /// sent, the message enters this state before
    /// OutPending.
    OutPreparing = 18,

    /// Message saved as draft.
    OutDraft = 19,

    /// The user has pressed the "send" button but the message is not
    /// yet sent and is pending in some way. Maybe we're offline (no
    /// checkmark).
    OutPending = 20,

    /// *Unrecoverable* error (*recoverable* errors result in pending
    /// messages).
    OutFailed = 24,

    /// Outgoing message successfully delivered to server (one
    /// checkmark). Note, that already delivered messages may get into
    /// the OutFailed state if we get such a hint from the server.
    OutDelivered = 26,

    /// Outgoing message read by the recipient (two checkmarks; this
    /// requires goodwill on the receiver's side). Not used in the db for new messages.
    OutMdnRcvd = 28,
}

impl std::fmt::Display for MessageState {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(
            f,
            "{}",
            match self {
                Self::Undefined => "Undefined",
                Self::InFresh => "Fresh",
                Self::InNoticed => "Noticed",
                Self::InSeen => "Seen",
                Self::OutPreparing => "Preparing",
                Self::OutDraft => "Draft",
                Self::OutPending => "Pending",
                Self::OutFailed => "Failed",
                Self::OutDelivered => "Delivered",
                Self::OutMdnRcvd => "Read",
            }
        )
    }
}

impl MessageState {
    /// Returns true if the message can transition to `OutFailed` state from the current state.
    pub fn can_fail(self) -> bool {
        use MessageState::*;
        matches!(
            self,
            OutPreparing | OutPending | OutDelivered | OutMdnRcvd // OutMdnRcvd can still fail because it could be a group message and only some recipients failed.
        )
    }

    /// Returns true for any outgoing message states.
    pub fn is_outgoing(self) -> bool {
        use MessageState::*;
        matches!(
            self,
            OutPreparing | OutDraft | OutPending | OutFailed | OutDelivered | OutMdnRcvd
        )
    }

    /// Returns adjusted message state if the message has MDNs.
    pub(crate) fn with_mdns(self, has_mdns: bool) -> Self {
        if self == MessageState::OutDelivered && has_mdns {
            return MessageState::OutMdnRcvd;
        }
        self
    }
}

/// Returns contacts that sent read receipts and the time of reading.
pub async fn get_msg_read_receipts(
    context: &Context,
    msg_id: MsgId,
) -> Result<Vec<(ContactId, i64)>> {
    context
        .sql
        .query_map(
            "SELECT contact_id, timestamp_sent FROM msgs_mdns WHERE msg_id=?",
            (msg_id,),
            |row| {
                let contact_id: ContactId = row.get(0)?;
                let ts: i64 = row.get(1)?;
                Ok((contact_id, ts))
            },
            |rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
        )
        .await
}

pub(crate) fn guess_msgtype_from_suffix(path: &Path) -> Option<(Viewtype, &str)> {
    let extension: &str = &path.extension()?.to_str()?.to_lowercase();
    let info = match extension {
        // before using viewtype other than Viewtype::File,
        // make sure, all target UIs support that type in the context of the used viewer/player.
        // if in doubt, it is better to default to Viewtype::File that passes handing to an external app.
        // (cmp. <https://developer.android.com/guide/topics/media/media-formats>)
        "3gp" => (Viewtype::Video, "video/3gpp"),
        "aac" => (Viewtype::Audio, "audio/aac"),
        "avi" => (Viewtype::Video, "video/x-msvideo"),
        "avif" => (Viewtype::File, "image/avif"), // supported since Android 12 / iOS 16
        "doc" => (Viewtype::File, "application/msword"),
        "docx" => (
            Viewtype::File,
            "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        ),
        "epub" => (Viewtype::File, "application/epub+zip"),
        "flac" => (Viewtype::Audio, "audio/flac"),
        "gif" => (Viewtype::Gif, "image/gif"),
        "heic" => (Viewtype::File, "image/heic"), // supported since Android 10 / iOS 11
        "heif" => (Viewtype::File, "image/heif"), // supported since Android 10 / iOS 11
        "html" => (Viewtype::File, "text/html"),
        "htm" => (Viewtype::File, "text/html"),
        "ico" => (Viewtype::File, "image/vnd.microsoft.icon"),
        "jar" => (Viewtype::File, "application/java-archive"),
        "jpeg" => (Viewtype::Image, "image/jpeg"),
        "jpe" => (Viewtype::Image, "image/jpeg"),
        "jpg" => (Viewtype::Image, "image/jpeg"),
        "json" => (Viewtype::File, "application/json"),
        "mov" => (Viewtype::Video, "video/quicktime"),
        "m4a" => (Viewtype::Audio, "audio/m4a"),
        "mp3" => (Viewtype::Audio, "audio/mpeg"),
        "mp4" => (Viewtype::Video, "video/mp4"),
        "odp" => (
            Viewtype::File,
            "application/vnd.oasis.opendocument.presentation",
        ),
        "ods" => (
            Viewtype::File,
            "application/vnd.oasis.opendocument.spreadsheet",
        ),
        "odt" => (Viewtype::File, "application/vnd.oasis.opendocument.text"),
        "oga" => (Viewtype::Audio, "audio/ogg"),
        "ogg" => (Viewtype::Audio, "audio/ogg"),
        "ogv" => (Viewtype::File, "video/ogg"),
        "opus" => (Viewtype::File, "audio/ogg"), // supported since Android 10
        "otf" => (Viewtype::File, "font/otf"),
        "pdf" => (Viewtype::File, "application/pdf"),
        "png" => (Viewtype::Image, "image/png"),
        "ppt" => (Viewtype::File, "application/vnd.ms-powerpoint"),
        "pptx" => (
            Viewtype::File,
            "application/vnd.openxmlformats-officedocument.presentationml.presentation",
        ),
        "rar" => (Viewtype::File, "application/vnd.rar"),
        "rtf" => (Viewtype::File, "application/rtf"),
        "spx" => (Viewtype::File, "audio/ogg"), // Ogg Speex Profile
        "svg" => (Viewtype::File, "image/svg+xml"),
        "tgs" => (Viewtype::Sticker, "application/x-tgsticker"),
        "tiff" => (Viewtype::File, "image/tiff"),
        "tif" => (Viewtype::File, "image/tiff"),
        "ttf" => (Viewtype::File, "font/ttf"),
        "txt" => (Viewtype::File, "text/plain"),
        "vcard" => (Viewtype::Vcard, "text/vcard"),
        "vcf" => (Viewtype::Vcard, "text/vcard"),
        "wav" => (Viewtype::Audio, "audio/wav"),
        "weba" => (Viewtype::File, "audio/webm"),
        "webm" => (Viewtype::Video, "video/webm"),
        "webp" => (Viewtype::Image, "image/webp"), // iOS via SDWebImage, Android since 4.0
        "wmv" => (Viewtype::Video, "video/x-ms-wmv"),
        "xdc" => (Viewtype::Webxdc, "application/webxdc+zip"),
        "xhtml" => (Viewtype::File, "application/xhtml+xml"),
        "xls" => (Viewtype::File, "application/vnd.ms-excel"),
        "xlsx" => (
            Viewtype::File,
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        ),
        "xml" => (Viewtype::File, "application/xml"),
        "zip" => (Viewtype::File, "application/zip"),
        _ => {
            return None;
        }
    };
    Some(info)
}

/// Get the raw mime-headers of the given message.
/// Raw headers are saved for incoming messages
/// only if `set_config(context, "save_mime_headers", "1")`
/// was called before.
///
/// Returns an empty vector if there are no headers saved for the given message,
/// e.g. because of save_mime_headers is not set
/// or the message is not incoming.
pub async fn get_mime_headers(context: &Context, msg_id: MsgId) -> Result<Vec<u8>> {
    let (headers, compressed) = context
        .sql
        .query_row(
            "SELECT mime_headers, mime_compressed FROM msgs WHERE id=?",
            (msg_id,),
            |row| {
                let headers = sql::row_get_vec(row, 0)?;
                let compressed: bool = row.get(1)?;
                Ok((headers, compressed))
            },
        )
        .await?;
    if compressed {
        return buf_decompress(&headers);
    }

    let headers2 = headers.clone();
    let compressed = match tokio::task::block_in_place(move || buf_compress(&headers2)) {
        Err(e) => {
            warn!(context, "get_mime_headers: buf_compress() failed: {}", e);
            return Ok(headers);
        }
        Ok(o) => o,
    };
    let update = |conn: &mut rusqlite::Connection| {
        match conn.execute(
            "\
            UPDATE msgs SET mime_headers=?, mime_compressed=1 \
            WHERE id=? AND mime_headers!='' AND mime_compressed=0",
            (compressed, msg_id),
        ) {
            Ok(rows_updated) => ensure!(rows_updated <= 1),
            Err(e) => {
                warn!(context, "get_mime_headers: UPDATE failed: {}", e);
                return Err(e.into());
            }
        }
        Ok(())
    };
    if let Err(e) = context.sql.call_write(update).await {
        warn!(
            context,
            "get_mime_headers: failed to update mime_headers: {}", e
        );
    }

    Ok(headers)
}

/// Deletes requested messages
/// by moving them to the trash chat
/// and scheduling for deletion on IMAP.
pub async fn delete_msgs(context: &Context, msg_ids: &[MsgId]) -> Result<()> {
    let mut modified_chat_ids = BTreeSet::new();
    let mut res = Ok(());

    for &msg_id in msg_ids {
        let msg = Message::load_from_db(context, msg_id).await?;
        if msg.location_id > 0 {
            delete_poi_location(context, msg.location_id).await?;
        }
        let on_server = true;
        msg_id
            .trash(context, on_server)
            .await
            .with_context(|| format!("Unable to trash message {msg_id}"))?;

        context.emit_event(EventType::MsgDeleted {
            chat_id: msg.chat_id,
            msg_id,
        });

        if msg.viewtype == Viewtype::Webxdc {
            context.emit_event(EventType::WebxdcInstanceDeleted { msg_id });
        }

        modified_chat_ids.insert(msg.chat_id);

        let target = context.get_delete_msgs_target().await?;
        let update_db = |trans: &mut rusqlite::Transaction| {
            trans.execute(
                "UPDATE imap SET target=? WHERE rfc724_mid=?",
                (target, msg.rfc724_mid),
            )?;
            trans.execute("DELETE FROM smtp WHERE msg_id=?", (msg_id,))?;
            Ok(())
        };
        if let Err(e) = context.sql.transaction(update_db).await {
            error!(context, "delete_msgs: failed to update db: {e:#}.");
            res = Err(e);
            continue;
        }

        let logging_xdc_id = context
            .debug_logging
            .read()
            .expect("RwLock is poisoned")
            .as_ref()
            .map(|dl| dl.msg_id);

        if let Some(id) = logging_xdc_id {
            if id == msg_id {
                set_debug_logging_xdc(context, None).await?;
            }
        }
    }
    res?;

    for modified_chat_id in modified_chat_ids {
        context.emit_msgs_changed_without_msg_id(modified_chat_id);
        chatlist_events::emit_chatlist_item_changed(context, modified_chat_id);
    }

    if !msg_ids.is_empty() {
        context.emit_msgs_changed_without_ids();
        chatlist_events::emit_chatlist_changed(context);
        // Run housekeeping to delete unused blobs.
        context
            .set_config_internal(Config::LastHousekeeping, None)
            .await?;
    }

    // Interrupt Inbox loop to start message deletion and run housekeeping.
    context.scheduler.interrupt_inbox().await;
    Ok(())
}

/// Marks requested messages as seen.
pub async fn markseen_msgs(context: &Context, msg_ids: Vec<MsgId>) -> Result<()> {
    if msg_ids.is_empty() {
        return Ok(());
    }

    let old_last_msg_id = MsgId::new(context.get_config_u32(Config::LastMsgId).await?);
    let last_msg_id = msg_ids.iter().fold(&old_last_msg_id, std::cmp::max);
    context
        .set_config_internal(Config::LastMsgId, Some(&last_msg_id.to_u32().to_string()))
        .await?;

    let msgs = context
        .sql
        .query_map(
            &format!(
                "SELECT
                    m.id AS id,
                    m.chat_id AS chat_id,
                    m.state AS state,
                    m.download_state as download_state,
                    m.ephemeral_timer AS ephemeral_timer,
                    m.param AS param,
                    m.from_id AS from_id,
                    m.rfc724_mid AS rfc724_mid,
                    c.archived AS archived,
                    c.blocked AS blocked
                 FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id
                 WHERE m.id IN ({}) AND m.chat_id>9",
                sql::repeat_vars(msg_ids.len())
            ),
            rusqlite::params_from_iter(&msg_ids),
            |row| {
                let id: MsgId = row.get("id")?;
                let chat_id: ChatId = row.get("chat_id")?;
                let state: MessageState = row.get("state")?;
                let download_state: DownloadState = row.get("download_state")?;
                let param: Params = row.get::<_, String>("param")?.parse().unwrap_or_default();
                let from_id: ContactId = row.get("from_id")?;
                let rfc724_mid: String = row.get("rfc724_mid")?;
                let visibility: ChatVisibility = row.get("archived")?;
                let blocked: Option<Blocked> = row.get("blocked")?;
                let ephemeral_timer: EphemeralTimer = row.get("ephemeral_timer")?;
                Ok((
                    (
                        id,
                        chat_id,
                        state,
                        download_state,
                        param,
                        from_id,
                        rfc724_mid,
                        visibility,
                        blocked.unwrap_or_default(),
                    ),
                    ephemeral_timer,
                ))
            },
            |rows| rows.collect::<Result<Vec<_>, _>>().map_err(Into::into),
        )
        .await?;

    if msgs
        .iter()
        .any(|(_, ephemeral_timer)| *ephemeral_timer != EphemeralTimer::Disabled)
    {
        start_ephemeral_timers_msgids(context, &msg_ids)
            .await
            .context("failed to start ephemeral timers")?;
    }

    let mut updated_chat_ids = BTreeSet::new();
    let mut archived_chats_maybe_noticed = false;
    for (
        (
            id,
            curr_chat_id,
            curr_state,
            curr_download_state,
            curr_param,
            curr_from_id,
            curr_rfc724_mid,
            curr_visibility,
            curr_blocked,
        ),
        _curr_ephemeral_timer,
    ) in msgs
    {
        if curr_download_state != DownloadState::Done {
            if curr_state == MessageState::InFresh {
                // Don't mark partially downloaded messages as seen or send a read receipt since
                // they are not really seen by the user.
                update_msg_state(context, id, MessageState::InNoticed).await?;
                updated_chat_ids.insert(curr_chat_id);
            }
        } else if curr_state == MessageState::InFresh || curr_state == MessageState::InNoticed {
            update_msg_state(context, id, MessageState::InSeen).await?;
            info!(context, "Seen message {}.", id);

            markseen_on_imap_table(context, &curr_rfc724_mid).await?;

            // Read receipts for system messages are never sent. These messages have no place to
            // display received read receipt anyway.  And since their text is locally generated,
            // quoting them is dangerous as it may contain contact names. E.g., for original message
            // "Group left by me", a read receipt will quote "Group left by <name>", and the name can
            // be a display name stored in address book rather than the name sent in the From field by
            // the user.
            //
            // We also don't send read receipts for contact requests.
            // Read receipts will not be sent even after accepting the chat.
            if curr_blocked == Blocked::Not
                && curr_param.get_bool(Param::WantsMdn).unwrap_or_default()
                && curr_param.get_cmd() == SystemMessage::Unknown
                && context.should_send_mdns().await?
            {
                context
                    .sql
                    .execute(
                        "INSERT INTO smtp_mdns (msg_id, from_id, rfc724_mid) VALUES(?, ?, ?)",
                        (id, curr_from_id, curr_rfc724_mid),
                    )
                    .await
                    .context("failed to insert into smtp_mdns")?;
                context.scheduler.interrupt_smtp().await;
            }
            updated_chat_ids.insert(curr_chat_id);
        }
        archived_chats_maybe_noticed |=
            curr_state == MessageState::InFresh && curr_visibility == ChatVisibility::Archived;
    }

    for updated_chat_id in updated_chat_ids {
        context.emit_event(EventType::MsgsNoticed(updated_chat_id));
        chatlist_events::emit_chatlist_item_changed(context, updated_chat_id);
    }
    if archived_chats_maybe_noticed {
        context.on_archived_chats_maybe_noticed();
    }

    Ok(())
}

pub(crate) async fn update_msg_state(
    context: &Context,
    msg_id: MsgId,
    state: MessageState,
) -> Result<()> {
    ensure!(
        state != MessageState::OutMdnRcvd,
        "Update msgs_mdns table instead!"
    );
    ensure!(state != MessageState::OutFailed, "use set_msg_failed()!");
    let error_subst = match state >= MessageState::OutPending {
        true => ", error=''",
        false => "",
    };
    context
        .sql
        .execute(
            &format!("UPDATE msgs SET state=? {error_subst} WHERE id=?"),
            (state, msg_id),
        )
        .await?;
    Ok(())
}

// as we do not cut inside words, this results in about 32-42 characters.
// Do not use too long subjects - we add a tag after the subject which gets truncated by the clients otherwise.
// It should also be very clear, the subject is _not_ the whole message.
// The value is also used for CC:-summaries

// Context functions to work with messages

pub(crate) async fn set_msg_failed(
    context: &Context,
    msg: &mut Message,
    error: &str,
) -> Result<()> {
    if msg.state.can_fail() {
        msg.state = MessageState::OutFailed;
        warn!(context, "{} failed: {}", msg.id, error);
    } else {
        warn!(
            context,
            "{} seems to have failed ({}), but state is {}", msg.id, error, msg.state
        )
    }
    msg.error = Some(error.to_string());

    let exists = context
        .sql
        .execute(
            "UPDATE msgs SET state=?, error=? WHERE id=?;",
            (msg.state, error, msg.id),
        )
        .await?
        > 0;
    context.emit_event(EventType::MsgFailed {
        chat_id: msg.chat_id,
        msg_id: msg.id,
    });
    if exists {
        chatlist_events::emit_chatlist_item_changed(context, msg.chat_id);
    }
    Ok(())
}

/// The number of messages assigned to unblocked chats
pub async fn get_unblocked_msg_cnt(context: &Context) -> usize {
    match context
        .sql
        .count(
            "SELECT COUNT(*) \
         FROM msgs m  LEFT JOIN chats c ON c.id=m.chat_id \
         WHERE m.id>9 AND m.chat_id>9 AND c.blocked=0;",
            (),
        )
        .await
    {
        Ok(res) => res,
        Err(err) => {
            error!(context, "get_unblocked_msg_cnt() failed. {:#}", err);
            0
        }
    }
}

/// Returns the number of messages in contact request chats.
pub async fn get_request_msg_cnt(context: &Context) -> usize {
    match context
        .sql
        .count(
            "SELECT COUNT(*) \
         FROM msgs m LEFT JOIN chats c ON c.id=m.chat_id \
         WHERE c.blocked=2;",
            (),
        )
        .await
    {
        Ok(res) => res,
        Err(err) => {
            error!(context, "get_request_msg_cnt() failed. {:#}", err);
            0
        }
    }
}

/// Estimates the number of messages that will be deleted
/// by the options `delete_device_after` or `delete_server_after`.
///
/// This is typically used to show the estimated impact to the user
/// before actually enabling deletion of old messages.
///
/// If `from_server` is true,
/// estimate deletion count for server,
/// otherwise estimate deletion count for device.
///
/// Count messages older than the given number of `seconds`.
///
/// Returns the number of messages that are older than the given number of seconds.
/// This includes e-mails downloaded due to the `show_emails` option.
/// Messages in the "saved messages" folder are not counted as they will not be deleted automatically.
pub async fn estimate_deletion_cnt(
    context: &Context,
    from_server: bool,
    seconds: i64,
) -> Result<usize> {
    let self_chat_id = ChatIdBlocked::lookup_by_contact(context, ContactId::SELF)
        .await?
        .map(|c| c.id)
        .unwrap_or_default();
    let threshold_timestamp = time() - seconds;

    let cnt = if from_server {
        context
            .sql
            .count(
                "SELECT COUNT(*)
             FROM msgs m
             WHERE m.id > ?
               AND timestamp < ?
               AND chat_id != ?
               AND EXISTS (SELECT * FROM imap WHERE rfc724_mid=m.rfc724_mid);",
                (DC_MSG_ID_LAST_SPECIAL, threshold_timestamp, self_chat_id),
            )
            .await?
    } else {
        context
            .sql
            .count(
                "SELECT COUNT(*)
             FROM msgs m
             WHERE m.id > ?
               AND timestamp < ?
               AND chat_id != ?
               AND chat_id != ? AND hidden = 0;",
                (
                    DC_MSG_ID_LAST_SPECIAL,
                    threshold_timestamp,
                    self_chat_id,
                    DC_CHAT_ID_TRASH,
                ),
            )
            .await?
    };
    Ok(cnt)
}

/// See [`rfc724_mid_exists_ex()`].
pub(crate) async fn rfc724_mid_exists(
    context: &Context,
    rfc724_mid: &str,
) -> Result<Option<(MsgId, i64)>> {
    Ok(rfc724_mid_exists_ex(context, rfc724_mid, "1")
        .await?
        .map(|(id, ts_sent, _)| (id, ts_sent)))
}

/// Returns [MsgId] and "sent" timestamp of the most recent message with given `rfc724_mid`
/// (Message-ID header) and bool `expr` result if such messages exists in the db.
///
/// * `expr`: SQL expression additionally passed into `SELECT`. Evaluated to `true` iff it is true
///   for all messages with the given `rfc724_mid`.
pub(crate) async fn rfc724_mid_exists_ex(
    context: &Context,
    rfc724_mid: &str,
    expr: &str,
) -> Result<Option<(MsgId, i64, bool)>> {
    let rfc724_mid = rfc724_mid.trim_start_matches('<').trim_end_matches('>');
    if rfc724_mid.is_empty() {
        warn!(context, "Empty rfc724_mid passed to rfc724_mid_exists");
        return Ok(None);
    }

    let res = context
        .sql
        .query_row_optional(
            &("SELECT id, timestamp_sent, MIN(".to_string()
                + expr
                + ") FROM msgs WHERE rfc724_mid=?
              HAVING COUNT(*) > 0 -- Prevent MIN(expr) from returning NULL when there are no rows.
              ORDER BY timestamp_sent DESC"),
            (rfc724_mid,),
            |row| {
                let msg_id: MsgId = row.get(0)?;
                let timestamp_sent: i64 = row.get(1)?;
                let expr_res: bool = row.get(2)?;
                Ok((msg_id, timestamp_sent, expr_res))
            },
        )
        .await?;

    Ok(res)
}

/// Given a list of Message-IDs, returns the most relevant message found in the database.
///
/// Relevance here is `(download_state == Done, index)`, where `index` is an index of Message-ID in
/// `mids`. This means Message-IDs should be ordered from the least late to the latest one (like in
/// the References header).
/// Only messages that are not in the trash chat are considered.
pub(crate) async fn get_by_rfc724_mids(
    context: &Context,
    mids: &[String],
) -> Result<Option<Message>> {
    let mut latest = None;
    for id in mids.iter().rev() {
        let Some((msg_id, _)) = rfc724_mid_exists(context, id).await? else {
            continue;
        };
        let Some(msg) = Message::load_from_db_optional(context, msg_id).await? else {
            continue;
        };
        if msg.download_state == DownloadState::Done {
            return Ok(Some(msg));
        }
        latest.get_or_insert(msg);
    }
    Ok(latest)
}

/// Returns the 1st part of summary text (i.e. before the dash if any) for a valid DeltaChat vCard.
pub(crate) fn get_vcard_summary(vcard: &[u8]) -> Option<String> {
    let vcard = str::from_utf8(vcard).ok()?;
    let contacts = deltachat_contact_tools::parse_vcard(vcard);
    let [c] = &contacts[..] else {
        return None;
    };
    if !deltachat_contact_tools::may_be_valid_addr(&c.addr) {
        return None;
    }
    Some(c.display_name().to_string())
}

/// How a message is primarily displayed.
#[derive(
    Debug,
    Default,
    Display,
    Clone,
    Copy,
    PartialEq,
    Eq,
    FromPrimitive,
    ToPrimitive,
    FromSql,
    ToSql,
    Serialize,
    Deserialize,
)]
#[repr(u32)]
pub enum Viewtype {
    /// Unknown message type.
    #[default]
    Unknown = 0,

    /// Text message.
    /// The text of the message is set using dc_msg_set_text() and retrieved with dc_msg_get_text().
    Text = 10,

    /// Image message.
    /// If the image is a GIF and has the appropriate extension, the viewtype is auto-changed to
    /// `Gif` when sending the message.
    /// File, width and height are set via dc_msg_set_file(), dc_msg_set_dimension
    /// and retrieved via dc_msg_set_file(), dc_msg_set_dimension().
    Image = 20,

    /// Animated GIF message.
    /// File, width and height are set via dc_msg_set_file(), dc_msg_set_dimension()
    /// and retrieved via dc_msg_get_file(), dc_msg_get_width(), dc_msg_get_height().
    Gif = 21,

    /// Message containing a sticker, similar to image.
    /// NB: When sending, the message viewtype may be changed to `Image` by some heuristics like
    /// checking for transparent pixels. Use `Message::force_sticker()` to disable them.
    ///
    /// If possible, the ui should display the image without borders in a transparent way.
    /// A click on a sticker will offer to install the sticker set in some future.
    Sticker = 23,

    /// Message containing an Audio file.
    /// File and duration are set via dc_msg_set_file(), dc_msg_set_duration()
    /// and retrieved via dc_msg_get_file(), dc_msg_get_duration().
    Audio = 40,

    /// A voice message that was directly recorded by the user.
    /// For all other audio messages, the type #DC_MSG_AUDIO should be used.
    /// File and duration are set via dc_msg_set_file(), dc_msg_set_duration()
    /// and retrieved via dc_msg_get_file(), dc_msg_get_duration()
    Voice = 41,

    /// Video messages.
    /// File, width, height and durarion
    /// are set via dc_msg_set_file(), dc_msg_set_dimension(), dc_msg_set_duration()
    /// and retrieved via
    /// dc_msg_get_file(), dc_msg_get_width(),
    /// dc_msg_get_height(), dc_msg_get_duration().
    Video = 50,

    /// Message containing any file, eg. a PDF.
    /// The file is set via dc_msg_set_file()
    /// and retrieved via dc_msg_get_file().
    File = 60,

    /// Message is an invitation to a videochat.
    VideochatInvitation = 70,

    /// Message is an webxdc instance.
    Webxdc = 80,

    /// Message containing shared contacts represented as a vCard (virtual contact file)
    /// with email addresses and possibly other fields.
    /// Use `parse_vcard()` to retrieve them.
    Vcard = 90,
}

impl Viewtype {
    /// Whether a message with this [`Viewtype`] should have a file attachment.
    pub fn has_file(&self) -> bool {
        match self {
            Viewtype::Unknown => false,
            Viewtype::Text => false,
            Viewtype::Image => true,
            Viewtype::Gif => true,
            Viewtype::Sticker => true,
            Viewtype::Audio => true,
            Viewtype::Voice => true,
            Viewtype::Video => true,
            Viewtype::File => true,
            Viewtype::VideochatInvitation => false,
            Viewtype::Webxdc => true,
            Viewtype::Vcard => true,
        }
    }
}

/// Returns text for storing in the `msgs.txt_normalized` column (to make case-insensitive search
/// possible for non-ASCII messages).
pub(crate) fn normalize_text(text: &str) -> Option<String> {
    if text.is_ascii() {
        return None;
    };
    Some(text.to_lowercase()).filter(|t| t != text)
}

#[cfg(test)]
mod tests {
    use num_traits::FromPrimitive;

    use super::*;
    use crate::chat::{
        self, add_contact_to_chat, marknoticed_chat, send_text_msg, ChatItem, ProtectionStatus,
    };
    use crate::chatlist::Chatlist;
    use crate::config::Config;
    use crate::reaction::send_reaction;
    use crate::receive_imf::receive_imf;
    use crate::test_utils as test;
    use crate::test_utils::{TestContext, TestContextManager};

    #[test]
    fn test_guess_msgtype_from_suffix() {
        assert_eq!(
            guess_msgtype_from_suffix(Path::new("foo/bar-sth.mp3")),
            Some((Viewtype::Audio, "audio/mpeg"))
        );
        assert_eq!(
            guess_msgtype_from_suffix(Path::new("foo/file.html")),
            Some((Viewtype::File, "text/html"))
        );
        assert_eq!(
            guess_msgtype_from_suffix(Path::new("foo/file.xdc")),
            Some((Viewtype::Webxdc, "application/webxdc+zip"))
        );
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_parse_webrtc_instance() {
        let (webrtc_type, url) = Message::parse_webrtc_instance("basicwebrtc:https://foo/bar");
        assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
        assert_eq!(url, "https://foo/bar");

        let (webrtc_type, url) = Message::parse_webrtc_instance("bAsIcwEbrTc:url");
        assert_eq!(webrtc_type, VideochatType::BasicWebrtc);
        assert_eq!(url, "url");

        let (webrtc_type, url) = Message::parse_webrtc_instance("https://foo/bar?key=val#key=val");
        assert_eq!(webrtc_type, VideochatType::Unknown);
        assert_eq!(url, "https://foo/bar?key=val#key=val");

        let (webrtc_type, url) = Message::parse_webrtc_instance("jitsi:https://j.si/foo");
        assert_eq!(webrtc_type, VideochatType::Jitsi);
        assert_eq!(url, "https://j.si/foo");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_create_webrtc_instance() {
        // webrtc_instance may come from an input field of the ui, be pretty tolerant on input
        let instance = Message::create_webrtc_instance("https://meet.jit.si/", "123");
        assert_eq!(instance, "https://meet.jit.si/123");

        let instance = Message::create_webrtc_instance("https://meet.jit.si", "456");
        assert_eq!(instance, "https://meet.jit.si/456");

        let instance = Message::create_webrtc_instance("meet.jit.si", "789");
        assert_eq!(instance, "https://meet.jit.si/789");

        let instance = Message::create_webrtc_instance("bla.foo?", "123");
        assert_eq!(instance, "https://bla.foo?123");

        let instance = Message::create_webrtc_instance("jitsi:bla.foo#", "456");
        assert_eq!(instance, "jitsi:https://bla.foo#456");

        let instance = Message::create_webrtc_instance("bla.foo#room=", "789");
        assert_eq!(instance, "https://bla.foo#room=789");

        let instance = Message::create_webrtc_instance("https://bla.foo#room", "123");
        assert_eq!(instance, "https://bla.foo#room/123");

        let instance = Message::create_webrtc_instance("bla.foo#room$ROOM", "123");
        assert_eq!(instance, "https://bla.foo#room123");

        let instance = Message::create_webrtc_instance("bla.foo#room=$ROOM&after=cont", "234");
        assert_eq!(instance, "https://bla.foo#room=234&after=cont");

        let instance = Message::create_webrtc_instance("  meet.jit .si ", "789");
        assert_eq!(instance, "https://meet.jit.si/789");

        let instance = Message::create_webrtc_instance(" basicwebrtc: basic . stuff\n ", "12345ab");
        assert_eq!(instance, "basicwebrtc:https://basic.stuff/12345ab");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_create_webrtc_instance_noroom() {
        // webrtc_instance may come from an input field of the ui, be pretty tolerant on input
        let instance = Message::create_webrtc_instance("bla.foo$NOROOM", "123");
        assert_eq!(instance, "https://bla.foo");

        let instance = Message::create_webrtc_instance(" bla . foo $NOROOM ", "456");
        assert_eq!(instance, "https://bla.foo");

        let instance = Message::create_webrtc_instance(" $NOROOM bla . foo  ", "789");
        assert_eq!(instance, "https://bla.foo");

        let instance = Message::create_webrtc_instance(" bla.foo  / $NOROOM ? a = b ", "123");
        assert_eq!(instance, "https://bla.foo/?a=b");

        // $ROOM has a higher precedence
        let instance = Message::create_webrtc_instance("bla.foo/?$NOROOM=$ROOM", "123");
        assert_eq!(instance, "https://bla.foo/?$NOROOM=123");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_get_width_height() {
        let t = test::TestContext::new().await;

        // test that get_width() and get_height() are returning some dimensions for images;
        // (as the device-chat contains a welcome-images, we check that)
        t.update_device_chats().await.ok();
        let device_chat_id = ChatId::get_for_contact(&t, ContactId::DEVICE)
            .await
            .unwrap();

        let mut has_image = false;
        let chatitems = chat::get_chat_msgs(&t, device_chat_id).await.unwrap();
        for chatitem in chatitems {
            if let ChatItem::Message { msg_id } = chatitem {
                if let Ok(msg) = Message::load_from_db(&t, msg_id).await {
                    if msg.get_viewtype() == Viewtype::Image {
                        has_image = true;
                        // just check that width/height are inside some reasonable ranges
                        assert!(msg.get_width() > 100);
                        assert!(msg.get_height() > 100);
                        assert!(msg.get_width() < 4000);
                        assert!(msg.get_height() < 4000);
                    }
                }
            }
        }
        assert!(has_image);
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_quote() {
        let d = test::TestContext::new().await;
        let ctx = &d.ctx;

        ctx.set_config(Config::ConfiguredAddr, Some("self@example.com"))
            .await
            .unwrap();

        let chat = d.create_chat_with_contact("", "dest@example.com").await;

        let mut msg = Message::new_text("Quoted message".to_string());

        // Send message, so it gets a Message-Id.
        assert!(msg.rfc724_mid.is_empty());
        let msg_id = chat::send_msg(ctx, chat.id, &mut msg).await.unwrap();
        let msg = Message::load_from_db(ctx, msg_id).await.unwrap();
        assert!(!msg.rfc724_mid.is_empty());

        let mut msg2 = Message::new(Viewtype::Text);
        msg2.set_quote(ctx, Some(&msg))
            .await
            .expect("can't set quote");
        assert_eq!(msg2.quoted_text().unwrap(), msg.get_text());

        let quoted_msg = msg2
            .quoted_message(ctx)
            .await
            .expect("error while retrieving quoted message")
            .expect("quoted message not found");
        assert_eq!(quoted_msg.get_text(), msg2.quoted_text().unwrap());
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_no_quote() {
        let mut tcm = TestContextManager::new();
        let alice = &tcm.alice().await;
        let bob = &tcm.bob().await;

        tcm.send_recv_accept(alice, bob, "Hi!").await;
        let msg = tcm
            .send_recv(
                alice,
                bob,
                "On 2024-08-28, Alice wrote:\n> A quote.\nNot really.",
            )
            .await;

        assert!(msg.quoted_text().is_none());
        assert!(msg.quoted_message(bob).await.unwrap().is_none());
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_unencrypted_quote_encrypted_message() -> Result<()> {
        let mut tcm = TestContextManager::new();

        let alice = &tcm.alice().await;
        let bob = &tcm.bob().await;

        let alice_group = alice
            .create_group_with_members(ProtectionStatus::Unprotected, "Group chat", &[bob])
            .await;
        let sent = alice.send_text(alice_group, "Hi! I created a group").await;
        let bob_received_message = bob.recv_msg(&sent).await;

        let bob_group = bob_received_message.chat_id;
        bob_group.accept(bob).await?;
        let sent = bob.send_text(bob_group, "Encrypted message").await;
        let alice_received_message = alice.recv_msg(&sent).await;
        assert!(alice_received_message.get_showpadlock());

        // Alice adds contact without key so chat becomes unencrypted.
        let alice_flubby_contact_id =
            Contact::create(alice, "Flubby", "flubby@example.org").await?;
        add_contact_to_chat(alice, alice_group, alice_flubby_contact_id).await?;

        // Alice quotes encrypted message in unencrypted chat.
        let mut msg = Message::new_text("unencrypted".to_string());
        msg.set_quote(alice, Some(&alice_received_message)).await?;
        chat::send_msg(alice, alice_group, &mut msg).await?;

        let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
        assert_eq!(bob_received_message.quoted_text().unwrap(), "...");
        assert_eq!(bob_received_message.get_showpadlock(), false);

        // Alice replaces a quote of encrypted message with a quote of unencrypted one.
        let mut msg1 = Message::new(Viewtype::Text);
        msg1.set_quote(alice, Some(&alice_received_message)).await?;
        msg1.set_quote(alice, Some(&msg)).await?;
        chat::send_msg(alice, alice_group, &mut msg1).await?;

        let bob_received_message = bob.recv_msg(&alice.pop_sent_msg().await).await;
        assert_eq!(bob_received_message.quoted_text().unwrap(), "unencrypted");
        assert_eq!(bob_received_message.get_showpadlock(), false);

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_get_chat_id() {
        // Alice receives a message that pops up as a contact request
        let alice = TestContext::new_alice().await;
        receive_imf(
            &alice,
            b"From: Bob <bob@example.com>\n\
                    To: alice@example.org\n\
                    Chat-Version: 1.0\n\
                    Message-ID: <123@example.com>\n\
                    Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
                    \n\
                    hello\n",
            false,
        )
        .await
        .unwrap();

        // check chat-id of this message
        let msg = alice.get_last_msg().await;
        assert!(!msg.get_chat_id().is_special());
        assert_eq!(msg.get_text(), "hello".to_string());
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_set_override_sender_name() {
        // send message with overridden sender name
        let alice = TestContext::new_alice().await;
        let alice2 = TestContext::new_alice().await;
        let bob = TestContext::new_bob().await;
        let chat = alice.create_chat(&bob).await;
        let contact_id = *chat::get_chat_contacts(&alice, chat.id)
            .await
            .unwrap()
            .first()
            .unwrap();
        let contact = Contact::get_by_id(&alice, contact_id).await.unwrap();

        let mut msg = Message::new_text("bla blubb".to_string());
        msg.set_override_sender_name(Some("over ride".to_string()));
        assert_eq!(
            msg.get_override_sender_name(),
            Some("over ride".to_string())
        );
        assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
        assert_ne!(contact.get_display_name(), "over ride".to_string());
        chat::send_msg(&alice, chat.id, &mut msg).await.unwrap();
        let sent_msg = alice.pop_sent_msg().await;

        // bob receives that message
        let chat = bob.create_chat(&alice).await;
        let contact_id = *chat::get_chat_contacts(&bob, chat.id)
            .await
            .unwrap()
            .first()
            .unwrap();
        let contact = Contact::get_by_id(&bob, contact_id).await.unwrap();
        let msg = bob.recv_msg(&sent_msg).await;
        assert_eq!(msg.chat_id, chat.id);
        assert_eq!(msg.text, "bla blubb");
        assert_eq!(
            msg.get_override_sender_name(),
            Some("over ride".to_string())
        );
        assert_eq!(msg.get_sender_name(&contact), "over ride".to_string());
        assert_ne!(contact.get_display_name(), "over ride".to_string());

        // explicitly check that the message does not create a mailing list
        // (mailing lists may also use `Sender:`-header)
        let chat = Chat::load_from_db(&bob, msg.chat_id).await.unwrap();
        assert_ne!(chat.typ, Chattype::Mailinglist);

        // Alice receives message on another device.
        let msg = alice2.recv_msg(&sent_msg).await;
        assert_eq!(
            msg.get_override_sender_name(),
            Some("over ride".to_string())
        );
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_markseen_msgs() -> Result<()> {
        let alice = TestContext::new_alice().await;
        let bob = TestContext::new_bob().await;
        let alice_chat = alice.create_chat(&bob).await;
        let mut msg = Message::new_text("this is the text!".to_string());

        // alice sends to bob,
        assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 0);
        let sent1 = alice.send_msg(alice_chat.id, &mut msg).await;
        let msg1 = bob.recv_msg(&sent1).await;
        let bob_chat_id = msg1.chat_id;
        let sent2 = alice.send_msg(alice_chat.id, &mut msg).await;
        let msg2 = bob.recv_msg(&sent2).await;
        assert_eq!(msg1.chat_id, msg2.chat_id);
        let chats = Chatlist::try_load(&bob, 0, None, None).await?;
        assert_eq!(chats.len(), 1);
        let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
        assert_eq!(msgs.len(), 2);
        assert_eq!(bob.get_fresh_msgs().await?.len(), 0);

        // that has no effect in contact request
        markseen_msgs(&bob, vec![msg1.id, msg2.id]).await?;

        assert_eq!(Chatlist::try_load(&bob, 0, None, None).await?.len(), 1);
        let bob_chat = Chat::load_from_db(&bob, bob_chat_id).await?;
        assert_eq!(bob_chat.blocked, Blocked::Request);

        let msgs = chat::get_chat_msgs(&bob, bob_chat_id).await?;
        assert_eq!(msgs.len(), 2);
        bob_chat_id.accept(&bob).await.unwrap();

        // bob sends to alice,
        // alice knows bob and messages appear in normal chat
        let msg1 = alice
            .recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
            .await;
        let msg2 = alice
            .recv_msg(&bob.send_msg(bob_chat_id, &mut msg).await)
            .await;
        let chats = Chatlist::try_load(&alice, 0, None, None).await?;
        assert_eq!(chats.len(), 1);
        assert_eq!(chats.get_chat_id(0)?, alice_chat.id);
        assert_eq!(chats.get_chat_id(0)?, msg1.chat_id);
        assert_eq!(chats.get_chat_id(0)?, msg2.chat_id);
        assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
        assert_eq!(alice.get_fresh_msgs().await?.len(), 2);

        // no message-ids, that should have no effect
        markseen_msgs(&alice, vec![]).await?;

        // bad message-id, that should have no effect
        markseen_msgs(&alice, vec![MsgId::new(123456)]).await?;

        assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 2);
        assert_eq!(alice.get_fresh_msgs().await?.len(), 2);

        // mark the most recent as seen
        markseen_msgs(&alice, vec![msg2.id]).await?;

        assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 1);
        assert_eq!(alice.get_fresh_msgs().await?.len(), 1);

        // user scrolled up - mark both as seen
        markseen_msgs(&alice, vec![msg1.id, msg2.id]).await?;

        assert_eq!(alice_chat.id.get_fresh_msg_cnt(&alice).await?, 0);
        assert_eq!(alice.get_fresh_msgs().await?.len(), 0);

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_markseen_not_downloaded_msg() -> Result<()> {
        let mut tcm = TestContextManager::new();
        let alice = &tcm.alice().await;
        alice.set_config(Config::DownloadLimit, Some("1")).await?;
        let bob = &tcm.bob().await;
        let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;

        let file_bytes = include_bytes!("../test-data/image/screenshot.png");
        let mut msg = Message::new(Viewtype::Image);
        msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)
            .await?;
        let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
        let msg = alice.recv_msg(&sent_msg).await;
        assert_eq!(msg.download_state, DownloadState::Available);
        assert!(!msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
        assert_eq!(msg.state, MessageState::InFresh);
        markseen_msgs(alice, vec![msg.id]).await?;
        // A not downloaded message can be seen only if it's seen on another device.
        assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
        // Marking the message as seen again is a no op.
        markseen_msgs(alice, vec![msg.id]).await?;
        assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);

        msg.id
            .update_download_state(alice, DownloadState::InProgress)
            .await?;
        markseen_msgs(alice, vec![msg.id]).await?;
        assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
        msg.id
            .update_download_state(alice, DownloadState::Failure)
            .await?;
        markseen_msgs(alice, vec![msg.id]).await?;
        assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);
        msg.id
            .update_download_state(alice, DownloadState::Undecipherable)
            .await?;
        markseen_msgs(alice, vec![msg.id]).await?;
        assert_eq!(msg.id.get_state(alice).await?, MessageState::InNoticed);

        assert!(
            !alice
                .sql
                .exists("SELECT COUNT(*) FROM smtp_mdns", ())
                .await?
        );

        alice.set_config(Config::DownloadLimit, None).await?;
        // Let's assume that Alice and Bob resolved the problem with encryption.
        let old_msg = msg;
        let msg = alice.recv_msg(&sent_msg).await;
        assert_eq!(msg.chat_id, old_msg.chat_id);
        assert_eq!(msg.download_state, DownloadState::Done);
        assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
        assert!(msg.get_showpadlock());
        // The message state mustn't be downgraded to `InFresh`.
        assert_eq!(msg.state, MessageState::InNoticed);
        markseen_msgs(alice, vec![msg.id]).await?;
        let msg = Message::load_from_db(alice, msg.id).await?;
        assert_eq!(msg.state, MessageState::InSeen);
        assert_eq!(
            alice
                .sql
                .count("SELECT COUNT(*) FROM smtp_mdns", ())
                .await?,
            1
        );
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_msg_seen_on_imap_when_downloaded() -> Result<()> {
        let mut tcm = TestContextManager::new();
        let alice = &tcm.alice().await;
        alice.set_config(Config::DownloadLimit, Some("1")).await?;
        let bob = &tcm.bob().await;
        let bob_chat_id = tcm.send_recv_accept(alice, bob, "hi").await.chat_id;

        let file_bytes = include_bytes!("../test-data/image/screenshot.png");
        let mut msg = Message::new(Viewtype::Image);
        msg.set_file_from_bytes(bob, "a.jpg", file_bytes, None)
            .await?;
        let sent_msg = bob.send_msg(bob_chat_id, &mut msg).await;
        let msg = alice.recv_msg(&sent_msg).await;
        assert_eq!(msg.download_state, DownloadState::Available);
        assert_eq!(msg.state, MessageState::InFresh);

        alice.set_config(Config::DownloadLimit, None).await?;
        let seen = true;
        let rcvd_msg = receive_imf(alice, sent_msg.payload().as_bytes(), seen)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(rcvd_msg.chat_id, msg.chat_id);
        let msg = Message::load_from_db(alice, *rcvd_msg.msg_ids.last().unwrap())
            .await
            .unwrap();
        assert_eq!(msg.download_state, DownloadState::Done);
        assert!(msg.param.get_bool(Param::WantsMdn).unwrap_or_default());
        assert!(msg.get_showpadlock());
        assert_eq!(msg.state, MessageState::InSeen);
        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_get_state() -> Result<()> {
        let alice = TestContext::new_alice().await;
        let bob = TestContext::new_bob().await;
        let alice_chat = alice.create_chat(&bob).await;
        let bob_chat = bob.create_chat(&alice).await;

        // check both get_state() functions,
        // the one requiring a id and the one requiring an object
        async fn assert_state(t: &Context, msg_id: MsgId, state: MessageState) {
            assert_eq!(msg_id.get_state(t).await.unwrap(), state);
            assert_eq!(
                Message::load_from_db(t, msg_id).await.unwrap().get_state(),
                state
            );
        }

        // check outgoing messages states on sender side
        let mut alice_msg = Message::new_text("hi!".to_string());
        assert_eq!(alice_msg.get_state(), MessageState::Undefined); // message not yet in db, assert_state() won't work

        alice_chat
            .id
            .set_draft(&alice, Some(&mut alice_msg))
            .await?;
        let mut alice_msg = alice_chat.id.get_draft(&alice).await?.unwrap();
        assert_state(&alice, alice_msg.id, MessageState::OutDraft).await;

        let msg_id = chat::send_msg(&alice, alice_chat.id, &mut alice_msg).await?;
        assert_eq!(msg_id, alice_msg.id);
        assert_state(&alice, alice_msg.id, MessageState::OutPending).await;

        let payload = alice.pop_sent_msg().await;
        assert_state(&alice, alice_msg.id, MessageState::OutDelivered).await;

        set_msg_failed(&alice, &mut alice_msg, "badly failed").await?;
        assert_state(&alice, alice_msg.id, MessageState::OutFailed).await;

        // check incoming message states on receiver side
        let bob_msg = bob.recv_msg(&payload).await;
        assert_eq!(bob_chat.id, bob_msg.chat_id);
        assert_state(&bob, bob_msg.id, MessageState::InFresh).await;

        marknoticed_chat(&bob, bob_msg.chat_id).await?;
        assert_state(&bob, bob_msg.id, MessageState::InNoticed).await;

        markseen_msgs(&bob, vec![bob_msg.id]).await?;
        assert_state(&bob, bob_msg.id, MessageState::InSeen).await;

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_is_bot() -> Result<()> {
        let alice = TestContext::new_alice().await;

        // Alice receives an auto-generated non-chat message.
        //
        // This could be a holiday notice,
        // in which case the message should be marked as bot-generated,
        // but the contact should not.
        receive_imf(
            &alice,
            b"From: Claire <claire@example.com>\n\
                    To: alice@example.org\n\
                    Message-ID: <789@example.com>\n\
                    Auto-Submitted: auto-generated\n\
                    Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
                    \n\
                    hello\n",
            false,
        )
        .await?;
        let msg = alice.get_last_msg().await;
        assert_eq!(msg.get_text(), "hello".to_string());
        assert!(msg.is_bot());
        let contact = Contact::get_by_id(&alice, msg.from_id).await?;
        assert!(!contact.is_bot());

        // Alice receives a message from Bob the bot.
        receive_imf(
            &alice,
            b"From: Bob <bob@example.com>\n\
                    To: alice@example.org\n\
                    Chat-Version: 1.0\n\
                    Message-ID: <123@example.com>\n\
                    Auto-Submitted: auto-generated\n\
                    Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
                    \n\
                    hello\n",
            false,
        )
        .await?;
        let msg = alice.get_last_msg().await;
        assert_eq!(msg.get_text(), "hello".to_string());
        assert!(msg.is_bot());
        let contact = Contact::get_by_id(&alice, msg.from_id).await?;
        assert!(contact.is_bot());

        // Alice receives a message from Bob who is not the bot anymore.
        receive_imf(
            &alice,
            b"From: Bob <bob@example.com>\n\
                    To: alice@example.org\n\
                    Chat-Version: 1.0\n\
                    Message-ID: <456@example.com>\n\
                    Date: Fri, 29 Jan 2021 21:37:55 +0000\n\
                    \n\
                    hello again\n",
            false,
        )
        .await?;
        let msg = alice.get_last_msg().await;
        assert_eq!(msg.get_text(), "hello again".to_string());
        assert!(!msg.is_bot());
        let contact = Contact::get_by_id(&alice, msg.from_id).await?;
        assert!(!contact.is_bot());

        Ok(())
    }

    #[test]
    fn test_viewtype_derive_display_works_as_expected() {
        assert_eq!(format!("{}", Viewtype::Audio), "Audio");
    }

    #[test]
    fn test_viewtype_values() {
        // values may be written to disk and must not change
        assert_eq!(Viewtype::Unknown, Viewtype::default());
        assert_eq!(Viewtype::Unknown, Viewtype::from_i32(0).unwrap());
        assert_eq!(Viewtype::Text, Viewtype::from_i32(10).unwrap());
        assert_eq!(Viewtype::Image, Viewtype::from_i32(20).unwrap());
        assert_eq!(Viewtype::Gif, Viewtype::from_i32(21).unwrap());
        assert_eq!(Viewtype::Sticker, Viewtype::from_i32(23).unwrap());
        assert_eq!(Viewtype::Audio, Viewtype::from_i32(40).unwrap());
        assert_eq!(Viewtype::Voice, Viewtype::from_i32(41).unwrap());
        assert_eq!(Viewtype::Video, Viewtype::from_i32(50).unwrap());
        assert_eq!(Viewtype::File, Viewtype::from_i32(60).unwrap());
        assert_eq!(
            Viewtype::VideochatInvitation,
            Viewtype::from_i32(70).unwrap()
        );
        assert_eq!(Viewtype::Webxdc, Viewtype::from_i32(80).unwrap());
        assert_eq!(Viewtype::Vcard, Viewtype::from_i32(90).unwrap());
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_send_quotes() -> Result<()> {
        let alice = TestContext::new_alice().await;
        let bob = TestContext::new_bob().await;
        let chat = alice.create_chat(&bob).await;

        let sent = alice.send_text(chat.id, "> First quote").await;
        let received = bob.recv_msg(&sent).await;
        assert_eq!(received.text, "> First quote");
        assert!(received.quoted_text().is_none());
        assert!(received.quoted_message(&bob).await?.is_none());

        let sent = alice.send_text(chat.id, "> Second quote").await;
        let received = bob.recv_msg(&sent).await;
        assert_eq!(received.text, "> Second quote");
        assert!(received.quoted_text().is_none());
        assert!(received.quoted_message(&bob).await?.is_none());

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_get_message_summary_text() -> Result<()> {
        let t = TestContext::new_alice().await;
        let chat = t.get_self_chat().await;
        let msg_id = send_text_msg(&t, chat.id, "foo".to_string()).await?;
        let msg = Message::load_from_db(&t, msg_id).await?;
        let summary = msg.get_summary(&t, None).await?;
        assert_eq!(summary.text, "foo");

        // message summary does not change when reactions are applied (in contrast to chatlist summary)
        send_reaction(&t, msg_id, "🫵").await?;
        let msg = Message::load_from_db(&t, msg_id).await?;
        let summary = msg.get_summary(&t, None).await?;
        assert_eq!(summary.text, "foo");

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_format_flowed_round_trip() -> Result<()> {
        let mut tcm = TestContextManager::new();
        let alice = tcm.alice().await;
        let bob = tcm.bob().await;
        let chat = alice.create_chat(&bob).await;

        let text = "  Foo bar";
        let sent = alice.send_text(chat.id, text).await;
        let received = bob.recv_msg(&sent).await;
        assert_eq!(received.text, text);

        let text = "Foo                         bar                                                             baz";
        let sent = alice.send_text(chat.id, text).await;
        let received = bob.recv_msg(&sent).await;
        assert_eq!(received.text, text);

        let text = "> xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx > A";
        let sent = alice.send_text(chat.id, text).await;
        let received = bob.recv_msg(&sent).await;
        assert_eq!(received.text, text);

        let python_program = "\
def hello():
    return 'Hello, world!'";
        let sent = alice.send_text(chat.id, python_program).await;
        let received = bob.recv_msg(&sent).await;
        assert_eq!(received.text, python_program);

        Ok(())
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_delete_msgs_offline() -> Result<()> {
        let alice = TestContext::new_alice().await;
        let chat = alice
            .create_chat_with_contact("Bob", "bob@example.org")
            .await;
        let mut msg = Message::new_text("hi".to_string());
        assert!(chat::send_msg_sync(&alice, chat.id, &mut msg)
            .await
            .is_err());
        let stmt = "SELECT COUNT(*) FROM smtp WHERE msg_id=?";
        assert!(alice.sql.exists(stmt, (msg.id,)).await?);
        delete_msgs(&alice, &[msg.id]).await?;
        assert!(!alice.sql.exists(stmt, (msg.id,)).await?);

        Ok(())
    }
}
