# This file is part of Gajim.
#
# SPDX-License-Identifier: GPL-3.0-only

from __future__ import annotations

from typing import Any
from typing import cast
from typing import Literal

import logging
from collections import defaultdict
from collections.abc import Generator
from datetime import datetime

from gi.repository import Gio
from gi.repository import GLib
from gi.repository import GObject
from gi.repository import Graphene
from gi.repository import Gtk
from nbxmpp.errors import StanzaError
from nbxmpp.protocol import JID
from nbxmpp.structs import MucSubject

import gajim.common.storage.archive.models as mod
from gajim.common import app
from gajim.common import events
from gajim.common import ged
from gajim.common import types
from gajim.common.const import Direction
from gajim.common.helpers import to_user_string
from gajim.common.modules.chat_markers import DisplayedMarkerData
from gajim.common.modules.contacts import BareContact
from gajim.common.modules.contacts import GroupchatContact
from gajim.common.modules.contacts import GroupchatParticipant
from gajim.common.modules.httpupload import HTTPFileTransfer
from gajim.common.storage.archive.const import ChatDirection
from gajim.common.storage.archive.const import MessageType
from gajim.common.storage.archive.models import Message
from gajim.common.types import ChatContactT
from gajim.common.util.datetime import get_start_of_day

from gajim.gtk.conversation.rows.base import BaseRow
from gajim.gtk.conversation.rows.call import CallRow
from gajim.gtk.conversation.rows.command_output import CommandOutputRow
from gajim.gtk.conversation.rows.date import DateRow
from gajim.gtk.conversation.rows.displayed import DisplayedRow
from gajim.gtk.conversation.rows.encryption_info import EncryptionInfoRow
from gajim.gtk.conversation.rows.file_transfer import FileTransferRow
from gajim.gtk.conversation.rows.file_transfer_jingle import FileTransferJingleRow
from gajim.gtk.conversation.rows.info import InfoMessage
from gajim.gtk.conversation.rows.message import MessageRow
from gajim.gtk.conversation.rows.muc_join_left import MUCJoinLeft
from gajim.gtk.conversation.rows.muc_subject import MUCSubject
from gajim.gtk.conversation.rows.scroll_hint import ScrollHintRow
from gajim.gtk.conversation.rows.user_status import UserStatus
from gajim.gtk.conversation.rows.widgets import MessageRowActions
from gajim.gtk.util.misc import check_finalize
from gajim.gtk.util.misc import iterate_listbox_children

log = logging.getLogger("gajim.gtk.conversation_view")


class ConversationView(Gtk.ScrolledWindow):
    __gsignals__ = {
        "request-history": (
            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
            None,
            (bool,),
        ),
        "autoscroll-changed": (GObject.SignalFlags.RUN_LAST, None, (bool,)),
    }

    def __init__(self, message_row_actions: MessageRowActions) -> None:
        Gtk.ScrolledWindow.__init__(self)

        self.set_overlay_scrolling(False)
        self.add_css_class("scrolled-no-border")
        self.add_css_class("no-scroll-indicator")
        self.add_css_class("scrollbar-style")
        self.set_vexpand(True)

        self.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)

        # This is a workaround: as soon as a line break occurs in Gtk.TextView
        # with word-char wrapping enabled, a hyphen character is automatically
        # inserted before the line break. This triggers the hscrollbar to show,
        # see: https://gitlab.gnome.org/GNOME/gtk/-/issues/2384
        # Using set_hscroll_policy(Gtk.Scrollable.Policy.NEVER) would cause bad
        # performance during resize, and prevent the window from being shrunk
        # horizontally under certain conditions (applies to GroupchatControl)
        self.get_hscrollbar().set_visible(False)

        self._message_row_actions = message_row_actions

        self._contact: ChatContactT | None = None
        self._client = None

        self._list_box = Gtk.ListBox()

        # Keeps track of the number of rows shown in ConversationView
        self._row_count: int = 0
        self._max_row_count: int = 100

        # Keeps track of date rows we have added to the list
        self._active_date_rows: set[datetime] = set()

        self._message_id_row_map: dict[str, MessageRow] = {}
        self._stanza_id_row_map: dict[str, MessageRow] = {}

        self._scroll_hint_row = None

        self._current_upper: float = 0
        self._autoscroll: bool = True
        self._request_history_at_upper: float | None = None
        self._upper_complete: bool = False
        self._lower_complete: bool = True
        self._requesting: str | None = None
        self._block_signals = False

        self._signal_handlers_enabled = False
        self._signal_handler_ids = (0, 0)
        # Maps occupnat id -> markers
        self._dm_occupant_markers: dict[str | None, DisplayedMarkerData] = {}
        # Maps message ids -> occupant id -> markers
        self._dm_id_occupant_markers: dict[
            str, dict[str | None, DisplayedMarkerData]
        ] = defaultdict(dict)
        # Maps message ids -> DisplayedRows
        self._dm_rows: dict[str, DisplayedRow] = {}

        self._last_occupant_messages: dict[str, mod.Message] = {}

        self.set_child(self._list_box)

        app.ged.register_event_handler(
            "register-actions", ged.GUI1, self._on_register_actions
        )

    def _on_register_actions(self, _event: events.RegisterActions) -> None:
        app.window.get_action("scroll-view-up").connect(
            "activate", self._on_scroll_view
        )
        app.window.get_action("scroll-view-down").connect(
            "activate", self._on_scroll_view
        )

    def copy_selected_messages(self) -> None:
        format_string = app.settings.get("date_time_format")
        selection_text = ""
        for row in cast(list[BaseRow], self._list_box.get_selected_rows()):
            if isinstance(row, MessageRow):
                timestamp_formatted = row.timestamp.strftime(format_string)
                selection_text += (
                    f"{timestamp_formatted} - {row.name}:\n{row.get_text()}\n"
                )

        self.get_clipboard().set(selection_text)

        self.disable_row_selection()

    def enable_row_selection(self, pk: int | None) -> None:
        self._list_box.set_selection_mode(Gtk.SelectionMode.MULTIPLE)

        if pk is not None:
            row = self.get_row_by_pk(pk)
            self._list_box.select_row(row)

        for row in self.iter_rows():
            row.enable_selection_mode()

    def disable_row_selection(self) -> None:
        self._list_box.unselect_all()
        self._list_box.set_selection_mode(Gtk.SelectionMode.NONE)

        for row in self.iter_rows():
            row.disable_selection_mode()

    def _on_scroll_view(self, action: Gio.SimpleAction, _param: Literal[None]) -> None:
        action_name = action.get_name()
        if action_name == "scroll-view-down":
            self.emit("scroll-child", Gtk.ScrollType.PAGE_DOWN, False)

        elif action_name == "scroll-view-up":
            self.emit("scroll-child", Gtk.ScrollType.PAGE_UP, False)

    def _enable_signal_handlers(self, enable: bool) -> None:
        if self._signal_handlers_enabled == enable:
            return

        vadjustment = self.get_vadjustment()

        if enable:
            upper_id = vadjustment.connect("notify::upper", self._on_adj_upper_changed)
            value_id = vadjustment.connect("notify::value", self._on_adj_value_changed)
            self._signal_handler_ids = (upper_id, value_id)
        else:
            upper_id, value_id = self._signal_handler_ids
            vadjustment.disconnect(upper_id)
            vadjustment.disconnect(value_id)

        self._signal_handlers_enabled = enable

    def clear(self) -> None:
        app.settings.disconnect_signals(self)
        self._enable_signal_handlers(False)
        self._reset()

        self._contact = None
        self._client = None

    def reload(self) -> None:
        if self._contact is None:
            return
        self.switch_contact(self._contact)

    def switch_contact(self, contact: ChatContactT) -> None:
        self._contact = contact
        self._client = app.get_client(contact.account)

        self._enable_signal_handlers(False)
        self._block_signals = True
        self._reset()

        self.disable_row_selection()

        if isinstance(self._contact, GroupchatParticipant):
            show_markers = self._contact.room.settings.get("send_marker")
        else:
            show_markers = self._contact.settings.get("send_marker")

        if show_markers:
            self._load_displayed_marker()

        self._scroll_hint_row = ScrollHintRow(self._contact.account)
        self._list_box.append(self._scroll_hint_row)

        app.settings.disconnect_signals(self)

        app.settings.connect_signal(
            "print_join_left",
            self._on_contact_setting_changed,
            account=contact.account,
            jid=contact.jid,
        )

        app.settings.connect_signal(
            "print_status",
            self._on_contact_setting_changed,
            account=contact.account,
            jid=contact.jid,
        )

        self._block_signals = False
        self._enable_signal_handlers(True)

    def get_autoscroll(self) -> bool:
        return self._autoscroll

    def block_signals(self, value: bool) -> None:
        self._block_signals = value

    def _emit(self, signal_name: str, *args: Any) -> None:
        if not self._block_signals:
            log.debug("emit %s, %s", signal_name, args)
            GLib.idle_add(self.emit, signal_name, *args)

    def _reset_list_box(self) -> None:
        self._list_box.set_selection_mode(Gtk.SelectionMode.NONE)
        # Performance: Disable sort function before removing all rows
        self._list_box.set_sort_func(None)
        check_finalize(self._list_box, only_children=True)
        self._list_box.remove_all()

        self._list_box.set_sort_func(self._sort_func)

    def _reset(self) -> None:
        self._current_upper = 0
        self._autoscroll = True
        self._request_history_at_upper = None
        self._upper_complete = False
        self._lower_complete = True
        self._requesting = None
        self.set_history_complete(True, False)

        self._reset_list_box()

        self._row_count = 0
        self._active_date_rows = set()
        self._message_id_row_map = {}
        self._stanza_id_row_map = {}
        self._scroll_hint_row = None

        self._dm_id_occupant_markers.clear()
        self._dm_occupant_markers.clear()
        self._dm_rows.clear()

    def reset(self) -> None:
        assert self._contact is not None
        self.switch_contact(self._contact)

    def set_history_complete(self, before: bool, complete: bool) -> None:
        if before:
            self._upper_complete = complete
            if self._scroll_hint_row is None:
                return
            self._scroll_hint_row.set_history_complete(complete)
        else:
            self._lower_complete = complete

    def get_lower_complete(self) -> bool:
        return self._lower_complete

    def _scroll_to_pos_idle(self, adj: Gtk.Adjustment, value: float) -> None:
        if value == -1:
            # scroll to end
            value = adj.get_upper() - adj.get_page_size()

        GLib.idle_add(adj.set_value, value)

    def _on_adj_upper_changed(
        self, adj: Gtk.Adjustment, _pspec: GObject.ParamSpec
    ) -> None:
        upper = adj.get_upper()
        diff = upper - self._current_upper

        if diff != 0:
            self._current_upper = upper
            if self._autoscroll:
                self._scroll_to_pos_idle(adj, -1)
            else:
                # Workaround
                # https://gitlab.gnome.org/GNOME/gtk/merge_requests/395
                self.set_kinetic_scrolling(True)
                if self._requesting == "before":
                    self._scroll_to_pos_idle(adj, adj.get_value() + diff)

        if upper == adj.get_page_size():
            # There is no scrollbar
            if not self._block_signals:
                self._emit("request-history", True)
            self._lower_complete = True
            self._autoscroll = True
            self._emit("autoscroll-changed", self._autoscroll)

        self._requesting = None

    def _on_adj_value_changed(
        self, adj: Gtk.Adjustment, _pspec: GObject.ParamSpec
    ) -> None:
        if self._requesting is not None:
            return

        bottom = adj.get_upper() - adj.get_page_size()

        self._autoscroll = bottom - adj.get_value() < 1
        self._emit("autoscroll-changed", self._autoscroll)

        if self._upper_complete:
            self._request_history_at_upper = None
            if self._lower_complete:
                return

        if self._request_history_at_upper == adj.get_upper():
            # Abort here if we already did a history request and the upper
            # did not change. This can happen if we scroll very fast and the
            # value changes while the request has not been fulfilled.
            return

        self._request_history_at_upper = None

        distance = adj.get_page_size()
        if adj.get_value() < distance:
            # Load messages when we are near the top
            if self._upper_complete:
                return
            self._request_history_at_upper = adj.get_upper()
            # Workaround: https://gitlab.gnome.org/GNOME/gtk/merge_requests/395
            self.set_kinetic_scrolling(False)
            if not self._block_signals:
                self._emit("request-history", True)
            self._requesting = "before"

        elif adj.get_upper() - (adj.get_value() + adj.get_page_size()) < distance:
            # ..or near the bottom
            if self._lower_complete:
                return
            # Workaround: https://gitlab.gnome.org/GNOME/gtk/merge_requests/395
            self.set_kinetic_scrolling(False)
            if not self._block_signals:
                self._emit("request-history", False)
            self._requesting = "after"

    @property
    def contact(self) -> types.ChatContactT:
        assert self._contact is not None
        return self._contact

    def _get_row_at_index(self, index: int) -> BaseRow:
        row = self._list_box.get_row_at_index(index)
        assert row is not None
        return cast(BaseRow, row)

    def get_first_row(self) -> MessageRow | CallRow | FileTransferJingleRow | None:
        for row in iterate_listbox_children(self._list_box):
            if isinstance(row, MessageRow | CallRow | FileTransferJingleRow):
                return row
        return None

    def get_last_row(self) -> MessageRow | CallRow | FileTransferJingleRow | None:
        children = reversed(list(iterate_listbox_children(self._list_box)))
        for row in children:
            if isinstance(row, MessageRow | CallRow | FileTransferJingleRow):
                return row
        return None

    def get_first_message_row(self) -> MessageRow | None:
        for row in iterate_listbox_children(self._list_box):
            if isinstance(row, MessageRow):
                return row
        return None

    def get_last_message_row(self) -> MessageRow | None:
        children = reversed(list(iterate_listbox_children(self._list_box)))
        for row in children:
            if isinstance(row, MessageRow):
                return row
        return None

    def get_first_event_row(self) -> InfoMessage | MUCJoinLeft | None:
        for row in iterate_listbox_children(self._list_box):
            if isinstance(row, InfoMessage | MUCJoinLeft):
                return row
        return None

    def get_last_event_row(self) -> InfoMessage | MUCJoinLeft | None:
        children = reversed(list(iterate_listbox_children(self._list_box)))
        for row in children:
            if isinstance(row, InfoMessage | MUCJoinLeft):
                return row
        return None

    @staticmethod
    def _sort_func(row1: BaseRow, row2: BaseRow) -> int:
        if row1.timestamp == row2.timestamp:
            return 0
        return -1 if row1.timestamp < row2.timestamp else 1

    @staticmethod
    def _get_occupant_id(obj: mod.Message | DisplayedMarkerData) -> str | None:
        if isinstance(obj, DisplayedMarkerData):
            if obj.occupant is None:
                # Single chat, we don't store outgoing markers
                return str(ChatDirection.INCOMING)
            return obj.occupant.id

        if obj.type == MessageType.CHAT:
            # In single chat we use the direction to identify the user
            return str(obj.direction)

        if obj.occupant is None:
            return None
        return obj.occupant.id

    def _record_last_occupant_message(
        self, occupant_id: str, message: mod.Message
    ) -> None:
        last_message = self._last_occupant_messages.get(occupant_id)
        if last_message is None or last_message.timestamp < message.timestamp:
            self._last_occupant_messages[occupant_id] = message

    def _load_displayed_marker(self) -> None:
        assert self._contact is not None

        if isinstance(self._contact, GroupchatContact):
            markers = app.storage.archive.get_last_display_markers(
                self._contact.account, self._contact.jid
            )
        else:
            marker = app.storage.archive.get_last_display_marker(
                self._contact.account, self._contact.jid
            )
            if marker is None:
                return

            markers = [marker]

        for marker in markers:
            marker = DisplayedMarkerData.from_model(self._contact.account, marker)
            occupant_id = self._get_occupant_id(marker)
            if occupant_id is None:
                log.warning(
                    "Loaded unexpected marker from database, "
                    "unable to get occupant id: %s",
                    marker,
                )
                continue

            self._dm_occupant_markers[occupant_id] = marker
            self._dm_id_occupant_markers[marker.id][occupant_id] = marker

    def add_muc_subject(
        self, subject: MucSubject, timestamp: float | None = None
    ) -> None:
        muc_subject = MUCSubject(self.contact.account, subject, timestamp)
        self._insert_message(muc_subject)

    def add_muc_user_left(self, event: events.MUCUserLeft, error: bool = False) -> None:
        assert isinstance(self._contact, GroupchatContact)
        if not self._contact.settings.get("print_join_left"):
            return
        join_left = MUCJoinLeft(
            "muc-user-left",
            self._contact.account,
            event.nick,
            reason=event.reason,
            error=error,
            timestamp=event.timestamp,
        )
        self._insert_message(join_left)

    def add_muc_user_joined(self, event: events.MUCUserJoined) -> None:
        assert isinstance(self._contact, GroupchatContact)
        if not self._contact.settings.get("print_join_left"):
            return
        join_left = MUCJoinLeft(
            "muc-user-joined",
            self._contact.account,
            event.nick,
            timestamp=event.timestamp,
        )
        self._insert_message(join_left)

    def add_user_status(self, name: str, show: str, status: str) -> None:
        user_status = UserStatus(self.contact.account, name, show, status)
        self._insert_message(user_status)

    def add_info_message(self, text: str, timestamp: datetime | None = None) -> None:
        message = InfoMessage(self.contact.account, text, timestamp)
        self._insert_message(message)

    def add_file_transfer(self, transfer: HTTPFileTransfer) -> None:
        transfer_row = FileTransferRow(self.contact.account, transfer)
        transfer_row.connect("remove", self._remove_row)
        self._insert_message(transfer_row)

    def add_jingle_file_transfer(
        self,
        event: events.FileRequestReceivedEvent | events.FileRequestSent | None = None,
        message: Message | None = None,
    ) -> None:
        assert isinstance(self._contact, BareContact)
        jingle_transfer_row = FileTransferJingleRow(
            self._contact.account, self._contact, event=event, message=message
        )
        self._insert_message(jingle_transfer_row)

    def add_encryption_info(self, event: events.EncryptionInfo) -> None:
        assert self._contact is not None
        self._insert_message(EncryptionInfoRow(event))

    def add_call_message(
        self,
        event: events.JingleRequestReceived | None = None,
        message: Message | None = None,
    ) -> None:
        assert isinstance(self._contact, BareContact)
        call_row = CallRow(
            self._contact.account, self._contact, event=event, message=message
        )
        self._insert_message(call_row)

    def add_command_output(self, text: str, is_error: bool) -> None:
        command_output_row = CommandOutputRow(self.contact.account, text, is_error)
        self._insert_message(command_output_row)

    def add_message(self, message: mod.Message) -> None:
        message_row = MessageRow.from_db_row(self.contact, message)
        message_row.connect(
            "state-flags-changed", self._on_message_row_state_flags_changed
        )

        # Add all message ids and stanza ids of all message revisions
        for stanza_id in message.iter_stanza_ids():
            self._stanza_id_row_map[stanza_id] = message_row

        for message_id in message.iter_message_ids():
            self._message_id_row_map[message_id] = message_row

        self._insert_message(message_row)

        if occupant_id := self._get_occupant_id(message):
            self._record_last_occupant_message(occupant_id, message)
            self._update_displayed_markers(message)

    def _update_displayed_markers(self, message: mod.Message) -> None:
        if message.direction == ChatDirection.INCOMING:
            self._remove_obsolete_displayed_marker(message)

        displayed_id = message.get_displayed_id()
        if not displayed_id:
            return

        occupant_markers = self._dm_id_occupant_markers.get(displayed_id)
        if not occupant_markers:
            return

        if message.type in (MessageType.GROUPCHAT, MessageType.PM):
            if message.occupant is None:
                # Dont handle messages where we have no occupant-id
                return

            # Ignore markers from the sender for his own messages
            occupant_markers = occupant_markers.copy()
            occupant_markers.pop(message.occupant.id, None)

            markers = list(occupant_markers.values())
            if not markers:
                return

        else:
            marker = occupant_markers.get(str(ChatDirection.INCOMING))
            if marker is None:
                return

            if message.direction == ChatDirection.INCOMING:
                # Ignore markers from the sender for his own messages
                return

            markers = [marker]

        # Check if we have newer messages in the view as the marker points to
        for marker in list(markers):
            occupant_id = self._get_occupant_id(marker)
            if occupant_id is None:
                # Should be impossible
                log.warning("Unable to get occupant id: %s", marker)
                markers.remove(marker)
                continue

            if last_message := self._last_occupant_messages.get(occupant_id):
                if last_message.timestamp > message.timestamp:
                    markers.remove(marker)

        if not markers:
            return

        self._add_or_update_displayed_marker_row(message.timestamp, markers)

    def _remove_obsolete_displayed_marker(self, message: mod.Message) -> None:
        occupant_id = self._get_occupant_id(message)
        assert occupant_id is not None

        current_marker = self._dm_occupant_markers.get(occupant_id)
        if current_marker is None:
            return

        if isinstance(self._contact, GroupchatContact):
            message_row = self._stanza_id_row_map.get(current_marker.id)
        else:
            message_row = self._message_id_row_map.get(current_marker.id)

        if message_row is None:
            return

        # Check if current marker points to newer message in the view
        if message_row.timestamp > message.timestamp:
            return

        self._remove_displayed_marker(occupant_id)

    def update_displayed_markers(self, event: events.DisplayedReceived) -> None:
        marker = DisplayedMarkerData.from_model(event.account, event.marker)

        occupant_id = self._get_occupant_id(marker)
        if occupant_id is None:
            log.warning("Unable to get occupant id: %s", marker)
            return

        self._remove_displayed_marker(occupant_id)

        self._dm_occupant_markers[occupant_id] = marker
        self._dm_id_occupant_markers[marker.id][occupant_id] = marker

        if isinstance(self._contact, GroupchatContact):
            message_row = self._stanza_id_row_map.get(marker.id)
        else:
            message_row = self._message_id_row_map.get(marker.id)

        if message_row is None:
            return

        self._add_or_update_displayed_marker_row(message_row.timestamp, [marker])

    def _remove_displayed_marker(self, occupant_id: str) -> None:
        current_marker = self._dm_occupant_markers.get(occupant_id)
        if current_marker is None:
            return

        del self._dm_occupant_markers[occupant_id]
        del self._dm_id_occupant_markers[current_marker.id][occupant_id]

        row = self._dm_rows.get(current_marker.id)
        if row is None:
            return

        if occupant_id == str(ChatDirection.INCOMING):
            # Single Chat
            del self._dm_rows[current_marker.id]
            self._remove_row(row)

        else:
            row.remove_marker(current_marker)
            if not row.has_markers():
                del self._dm_rows[current_marker.id]
                self._remove_row(row)

    def _add_or_update_displayed_marker_row(
        self, timestamp: datetime, markers: list[DisplayedMarkerData]
    ) -> DisplayedRow | None:
        displayed_id = markers[0].id
        row = self._dm_rows.get(displayed_id)
        if row is not None:
            row.add_markers(markers)
            return None

        assert self._contact is not None
        row = DisplayedRow(self._contact.account, timestamp, markers)
        self._dm_rows[displayed_id] = row
        self._list_box.append(row)

    def _insert_message(self, message: BaseRow) -> None:
        self._list_box.append(message)

        self._add_date_row(message.timestamp)
        self._check_for_merge(message)

    def _add_date_row(self, timestamp: datetime) -> None:
        start_of_day = get_start_of_day(timestamp.astimezone())
        if start_of_day in self._active_date_rows:
            return

        date_row = DateRow(self.contact.account, start_of_day)
        self._active_date_rows.add(start_of_day)
        self._list_box.append(date_row)

        row = self._list_box.get_row_at_index(date_row.get_index() + 1)
        if row is None:
            return

        if not isinstance(row, MessageRow):
            return

        row.set_merged(False)

    def _check_for_merge(self, message: BaseRow) -> None:
        if not isinstance(message, MessageRow):
            return

        if not app.settings.get("chat_merge_consecutive_nickname"):
            return

        ancestor = self._find_ancestor(message)
        if ancestor is None:
            self._update_descendants(message)
        else:
            message.set_merged(message.is_mergeable(ancestor))

    def _find_ancestor(self, message: MessageRow) -> MessageRow | None:
        index = message.get_index()
        while index != 0:
            index -= 1
            row = self._list_box.get_row_at_index(index)
            if row is None:
                return None

            if isinstance(row, DisplayedRow):
                continue

            if not isinstance(row, MessageRow):
                return None

            if not message.is_same_sender(row):
                return None

            if not row.is_merged:
                return row
        return None

    def _update_descendants(self, message: MessageRow) -> None:
        index = message.get_index()
        while True:
            index += 1
            row = self._list_box.get_row_at_index(index)
            if row is None:
                return

            if isinstance(row, DisplayedRow):
                continue

            if not isinstance(row, MessageRow):
                return

            merge = message.is_mergeable(row)
            row.set_merged(merge)
            return

    def _on_message_row_state_flags_changed(
        self, row: MessageRow, previous_flags: Gtk.StateFlags
    ) -> None:
        if self._list_box.get_selection_mode() == Gtk.SelectionMode.MULTIPLE:
            # Message row selection active, hide MessageRowActions
            self._message_row_actions.hide_actions()
            return

        current_flags = self.get_state_flags()

        if (
            current_flags & Gtk.StateFlags.BACKDROP
            or previous_flags & Gtk.StateFlags.BACKDROP
        ):
            # Don't react to backdrop changes and focus changes when in background
            return

        if previous_flags & Gtk.StateFlags.PRELIGHT:
            self._message_row_actions.hide_actions()
        else:
            try:
                success, point = row.compute_point(self, Graphene.Point.zero())
            except GLib.Error:
                return

            if not success:
                return

            self._message_row_actions.update(point.y, row)

    def remove_message(self, pk: int) -> None:
        row = self.get_row_by_pk(pk)
        if row is None:
            return

        self._remove_from_maps(row)
        index = row.get_index()
        self._remove_row(row)
        decendant_row = self._list_box.get_row_at_index(index)
        if isinstance(decendant_row, MessageRow):
            # Unset possible merged state if we delete a 'top level' message.
            # Checks for same sender etc. are not necessary, since we simply
            # unset merged state.
            decendant_row.set_merged(False)

    def _remove_from_maps(self, row: MessageRow) -> None:
        for key, val in dict(self._message_id_row_map).items():
            if val is row:
                del self._message_id_row_map[key]

        for key, val in dict(self._stanza_id_row_map).items():
            if val is row:
                del self._stanza_id_row_map[key]

    def acknowledge_message(self, event: events.MessageAcknowledged) -> None:
        row = self.get_row_by_pk(event.pk)
        if row is None:
            return

        if event.stanza_id is not None:
            self._stanza_id_row_map[event.stanza_id] = row
        row.set_acknowledged(event.pk)
        self._check_for_merge(row)

    def scroll_to_message_and_highlight(self, pk: int) -> None:
        highlight_row = None
        for row in cast(list[BaseRow], iterate_listbox_children(self._list_box)):
            if row.pk == pk:
                highlight_row = row
                break

        if highlight_row is None:
            return

        # Scroll ListBox to row and highlight it
        coordinates = highlight_row.translate_coordinates(self._list_box, 0, 0)
        if coordinates is None:
            return

        _x_coord, y_coord = coordinates
        _mimimum_site, natural_size = highlight_row.get_preferred_size()
        adjustment = self._list_box.get_adjustment()
        assert adjustment is not None
        adjustment.set_value(
            y_coord - (adjustment.get_page_size() - natural_size.height) / 2
        )

        highlight_row.remove_css_class("conversation-row-highlight")
        highlight_row.add_css_class("conversation-row-highlight")

        GLib.timeout_add(1500, self._remove_highligh_class, highlight_row)

    def _remove_highligh_class(self, highlight_row: BaseRow) -> None:
        highlight_row.remove_css_class("conversation-row-highlight")

    def scroll_to_end(self) -> None:
        self._scroll_to_pos_idle(self.get_vadjustment(), -1)

    def _get_row_by_message_id(self, message_id: str) -> MessageRow | None:
        return self._message_id_row_map.get(message_id)

    def _get_row_by_stanza_id(self, stanza_id: str) -> MessageRow | None:
        return self._stanza_id_row_map.get(stanza_id)

    def _get_message_row_by_direction(
        self, pk: int, direction: Direction | None = None
    ) -> MessageRow | None:
        row = self.get_row_by_pk(pk)
        if row is None:
            return None

        if direction is None:
            return row

        index = row.get_index()
        while True:
            if direction == Direction.PREV:
                index -= 1
            else:
                index += 1

            row = self._list_box.get_row_at_index(index)
            if row is None:
                return None

            if isinstance(row, MessageRow):
                return row

    def get_prev_message_row(self, pk: int | None) -> MessageRow | None:
        if pk is None:
            return self.get_last_message_row()
        return self._get_message_row_by_direction(pk, direction=Direction.PREV)

    def get_next_message_row(self, pk: int | None) -> MessageRow | None:
        if pk is None:
            return None
        return self._get_message_row_by_direction(pk, direction=Direction.NEXT)

    def get_row_by_pk(self, pk: int) -> MessageRow | None:
        for row in cast(list[BaseRow], iterate_listbox_children(self._list_box)):
            if not isinstance(row, MessageRow):
                continue
            if pk in {row.pk, row.orig_pk}:
                return row

        return None

    def iter_rows(self) -> Generator[BaseRow, None, None]:
        yield from cast(list[BaseRow], iterate_listbox_children(self._list_box))

    def _remove_row(self, row: BaseRow) -> None:
        check_finalize(row)
        self._list_box.remove(row)

    def _remove_rows_by_type(self, row_type: str) -> None:
        for row in self.iter_rows():
            if row.type == row_type:
                self._remove_row(row)

    def update_call_rows(self) -> None:
        for row in cast(list[BaseRow], iterate_listbox_children(self._list_box)):
            if isinstance(row, CallRow):
                row.update()

    def correct_message(self, event: events.MessageCorrected) -> None:
        message_row = self._get_row_by_message_id(event.correction_id)
        if message_row is None:
            return

        if event.message.id is not None:
            self._message_id_row_map[event.message.id] = message_row

        if event.message.stanza_id is not None:
            self._stanza_id_row_map[event.message.stanza_id] = message_row

        message_row.update_corrections()

    def update_retractions(self, retraction_id: str) -> None:
        if isinstance(self._contact, GroupchatContact):
            message_row = self._get_row_by_stanza_id(retraction_id)
        else:
            message_row = self._get_row_by_message_id(retraction_id)

        if message_row is not None:
            message_row.update_retractions()

    def update_reactions(self, reaction_id: str) -> None:
        if isinstance(self._contact, GroupchatContact):
            message_row = self._get_row_by_stanza_id(reaction_id)
        else:
            message_row = self._get_row_by_message_id(reaction_id)

        if message_row is not None:
            message_row.update_reactions()

    def update_blocked_muc_users(self) -> None:
        assert self._client is not None
        assert self._contact is not None
        blocking_list = self._client.get_module("MucBlocking").get_blocking_list(
            self._contact.jid
        )

        for row in self.iter_rows():
            if not isinstance(row, MessageRow):
                continue
            if row.occupant_id is None:
                continue
            row.set_blocked(row.occupant_id in blocking_list)

    def set_receipt(self, id_: str) -> None:
        message_row = self._get_row_by_message_id(id_)
        if message_row is None:
            return

        message_row.set_receipt(id_)
        self._check_for_merge(message_row)

    def show_error(self, id_: str, error: StanzaError) -> None:
        message_row = self._get_row_by_message_id(id_)
        if message_row is not None:
            message_row.set_error(id_, to_user_string(error))

    def _on_contact_setting_changed(
        self, value: Any, setting: str, _account: str | None, _jid: JID | None
    ) -> None:
        if setting == "print_join_left":
            if value:
                return
            self._remove_rows_by_type("muc-user-joined")
            self._remove_rows_by_type("muc-user-left")

        if setting == "print_status":
            if value:
                return
            self._remove_rows_by_type("muc-user-status")
