"""
Define the TextBuffer class

A text buffer contains a list of intermediate representations of messages
(not xml stanzas, but neither the Lines used in windows.py.

Each text buffer can be linked to multiple windows, that will be rendered
independently by their TextWins.
"""

from __future__ import annotations

import logging

from typing import (
    Type,
    Dict,
    List,
    Optional,
    TYPE_CHECKING,
    Tuple,
    Union,
    Sequence,
)
from dataclasses import dataclass
from datetime import datetime
from poezio.config import config
from poezio.ui.types import (
    BaseMessage,
    Message,
    MucOwnJoinMessage,
    MucOwnLeaveMessage,
    DeletedMessage,
)

if TYPE_CHECKING:
    from poezio.windows.text_win import TextWin
    from poezio.user import User
    from slixmpp import JID


log = logging.getLogger(__name__)


class CorrectionError(Exception):
    """
    Exception raised when the text buffer fails to correct a message.
    There are plenty of reasons for that.
    """
    def __init__(self, found: bool = True, is_message: bool = True,  # noqa: B042
                 same_user: bool = True, delayed: bool = False,
                 no_identity: bool = False, no_match: bool = False,
                 ):
        if not found:
            msg = 'Message not found in text buffer.'
        elif not is_message:
            msg = 'Message in text buffer is not a proper message.'
        elif not same_user:
            msg = 'Message and corrections are from different users.'
        elif delayed:
            msg = 'Cannot correct a delayed message.'
        elif no_identity:
            msg = 'Could not verify the sender’s identity.'
        elif no_match:
            msg = (
                'Messages and corrections have not been'
                'sent by the same fullJID.'
            )
        else:
            msg = 'unexplained correction error'

        super().__init__(msg)


class DeletionError(Exception):
    """
    Error raised when trying to delete a message from the text buffer and
    failing the permission check (either similar user, similar jid, or
    moderated message.
    """
    def __init__(self,  # noqa: B042
                 users: tuple['User', 'User' | None] | None = None,
                 jids: tuple[str, str] | None = None) -> None:
        if users:
            msg = (
                f'Retraction failed: {users[0]} and '
                f'{users[1]} are different users!'
            )
        elif jids:
            msg = (
                f'Retraction failed: {jids[0]} and '
                f'{jids[1]} are different jids!'
            )
        else:
            msg = "no jid, no user, no moderation, should not happen"
        super().__init__(msg)


class AckError(Exception):
    pass


@dataclass(slots=True)
class HistoryGap:
    """Class representing a period of non-presence inside a MUC"""
    leave_message: Optional[BaseMessage]
    join_message: Optional[BaseMessage]
    last_timestamp_before_leave: Optional[datetime]
    first_timestamp_after_join: Optional[datetime]


class TextBuffer:
    """
    This class just keep trace of messages, in a list with various
    information and attributes.
    """
    filtered_instances: tuple[Type[BaseMessage], ...]

    def __init__(self, messages_nb_limit: Optional[int] = None) -> None:

        self.filtered_instances = tuple()
        if messages_nb_limit is None:
            messages_nb_limit = config.getint('max_messages_in_memory')
        self._messages_nb_limit: int = messages_nb_limit
        # Message objects
        self.messages: List[BaseMessage] = []
        # COMPAT: Correction id -> Original message id.
        self.correction_ids: Dict[str, str] = {}
        # we keep track of one or more windows
        # so we can pass the new messages to them, as they are added, so
        # they (the windows) can build the lines from the new message
        self._windows: List[TextWin] = []

    def add_window(self, win: TextWin) -> None:
        self._windows.append(win)

    def filter_classes(self, filtered: tuple[Type[BaseMessage], ...]) -> bool:
        """
        Filter out specific types of messages from the windows (i.e. the real
        display). Those messages are still in the text buffer and will be
        displayed again when the filter is removed.

        note: it is costly to change the filter
        """
        old_filter = self.filtered_instances
        self.filtered_instances = filtered
        if old_filter != filtered:
            for window in self._windows:
                window.rebuild_everything(self)
            return True
        return False

    def filter_messages(self) -> list[BaseMessage]:
        """Return a list of messages, filtered if a filter was defined."""
        if not self.filtered_instances:
            return self.messages
        else:
            return [
                message for message in self.messages
                if not isinstance(message, self.filtered_instances)
            ]

    def find_last_gap_muc(self) -> Optional[HistoryGap]:
        """
        Find the last known history gap contained in buffer.

        The idea is to be able to fetch all messages between a leave
        and a join. It is made quite complicated by the fact that due to
        possible network issues on our side, on the remote side, or on our
        server side (or other bugs in poezio!), we can disappear from a room
        without leaving properly.

        We want to know the last timestamp of the message before we left (in
        case a race condition made us miss something) if any, and the first
        timestamp of the message after our join if any, so that the mam query
        can be properly calibrated.

        """
        leave: Optional[Tuple[int, BaseMessage]] = None
        join: Optional[Tuple[int, BaseMessage]] = None
        for i, item in enumerate(reversed(self.messages)):
            if isinstance(item, MucOwnLeaveMessage):
                leave = (len(self.messages) - i - 1, item)
                break
            elif join and isinstance(item, MucOwnJoinMessage):
                leave = (len(self.messages) - i - 1, item)
                break
            if isinstance(item, MucOwnJoinMessage):
                join = (len(self.messages) - i - 1, item)

        last_timestamp = None
        first_timestamp = datetime.now()

        # Identify the special case when we got disconnected from a chatroom
        # without receiving or sending the relevant presence, therefore only
        # having two joins with no leave, and messages in the middle.
        if leave and join and isinstance(leave[1], MucOwnJoinMessage):
            for i in range(join[0] - 1, leave[0], - 1):
                if isinstance(self.messages[i], Message):
                    leave = (
                        i,
                        self.messages[i]
                    )
                    last_timestamp = self.messages[i].time
                    break
        # If we have a normal gap but messages inbetween, it probably
        # already has history, so abort there without returning it.
        if join and leave:
            for i in range(leave[0] + 1, join[0], 1):
                if isinstance(self.messages[i], Message):
                    return None
        elif not (join or leave):
            return None

        # If a leave message is found, get the last Message timestamp
        # before it.
        if leave is None:
            leave_msg = None
        elif last_timestamp is None:
            leave_msg = leave[1]
            for i in range(leave[0], 0, -1):
                if isinstance(self.messages[i], Message):
                    last_timestamp = self.messages[i].time
                    break
                # If we encounter a join, assume that we can take the "leave"
                # time instead.
                if isinstance(self.messages[i], MucOwnJoinMessage):
                    break
            if not last_timestamp:
                last_timestamp = leave[1].time
        else:
            leave_msg = leave[1]
        # If a join message is found, get the first Message timestamp
        # after it, or the current time.
        if join is None:
            join_msg = None
        else:
            join_msg = join[1]
            for i in range(join[0], len(self.messages)):
                msg = self.messages[i]
                if isinstance(msg, Message) and msg.time < first_timestamp:
                    first_timestamp = msg.time
                    break
        return HistoryGap(
            leave_message=leave_msg,
            join_message=join_msg,
            last_timestamp_before_leave=last_timestamp,
            first_timestamp_after_join=first_timestamp,
        )

    def get_gap_index(self, gap: HistoryGap) -> Optional[int]:
        """Find the first index to insert into inside a gap"""
        if gap.leave_message is None:
            return 0
        for i, msg in enumerate(self.messages):
            if msg is gap.leave_message:
                return i + 1
        return None

    def add_history_messages(self, messages: Sequence[BaseMessage],
                             gap: Optional[HistoryGap] = None) -> None:
        """Insert history messages at their correct place """
        index = 0
        new_index = None
        if gap is not None:
            new_index = self.get_gap_index(gap)
            if new_index is None:  # Not sure what happened, abort
                return
            index = new_index
        # Crappy heuristic: assume if a message has the same text and nickname
        # as the last message we know, we only need to take the messages that
        # come afterwards.
        if gap and isinstance(gap.leave_message, Message):
            leave_msg = gap.leave_message
            for i, message in enumerate(messages[:]):
                if not isinstance(message, Message):
                    continue
                if (message.txt == leave_msg.txt
                        and message.nickname == leave_msg.nickname):
                    messages = messages[i+1:]
                    break

        for message in messages:
            self.messages.insert(index, message)
            index += 1
        for window in self._windows:  # make the associated windows
            window.rebuild_everything(self)

    @property
    def last_message(self) -> Optional[BaseMessage]:
        return self.messages[-1] if self.messages else None

    def add_message(self, msg: BaseMessage) -> int:
        """
        Create a message and add it to the text buffer
        """
        self.messages.append(msg)

        while len(self.messages) > self._messages_nb_limit:
            self.messages.pop(0)

        if isinstance(msg, self.filtered_instances):
            return 0

        ret_val = 0
        show_timestamps = config.getbool('show_timestamps')
        nick_size = config.getbool('max_nick_length')
        for window in self._windows:  # make the associated windows
            # build the lines from the new message
            nb = window.build_new_message(
                msg,
                timestamp=show_timestamps,
                nick_size=nick_size)
            if ret_val == 0:
                ret_val = nb
            if window.pos != 0:
                window.scroll_up(nb)

        return min(ret_val, 1)

    def _find_message(self, orig_id: str) -> Tuple[str, int]:
        """
        Find a message in the text buffer from its message id
        """
        # When looking for a message, ensure the id doesn't appear in a
        # message we've removed from our message list. If so return the index
        # of the corresponding id for the original message instead.
        orig_id = self.correction_ids.get(orig_id, orig_id)

        for i in range(len(self.messages) - 1, -1, -1):
            msg = self.messages[i]
            if msg.identifier == orig_id:
                return (orig_id, i)
        return (orig_id, -1)

    def _find_message_stanza_id(self, orig_id: str) -> Tuple[str, int]:
        """
        Find a message in the text buffer from its message id
        """
        # When looking for a message, ensure the id doesn't appear in a
        # message we've removed from our message list. If so return the index
        # of the corresponding id for the original message instead.
        orig_id = self.correction_ids.get(orig_id, orig_id)

        for i in range(len(self.messages) - 1, -1, -1):
            msg = self.messages[i]
            if hasattr(msg, 'stanza_id'):
                if msg.stanza_id == orig_id:
                    return (orig_id, i)
        return (orig_id, -1)

    def react(self, to: str, by: str, reactions: set[str],
              use_stanza_ids: bool = False) -> bool:
        """
        Add or subtract reactions to/from a message.

        :param to: Identifier of the message
        :param by: An identifier relevant to the current context
        :param reactions: set of reactions to for the entity related to "by"
        :param use_stanza_ids: Whether to use stanza ids (e.g. in a MUC)
        :returns: True if the reactions sets were changed
        """
        if use_stanza_ids:
            _, pos = self._find_message_stanza_id(to)
        else:
            _, pos = self._find_message(to)
        if pos == -1:
            log.debug('Unable to find message %s to add reactions', to)
            return False
        message = self.messages[pos]
        if not isinstance(message, Message):
            log.debug('Message %s is not a Message, should not happen', message)
            return False
        if reactions and not message.reactions:
            message.reactions = {}
        added_reactions = set()
        changed = False
        # Appease mypy
        if message.reactions is None:
            return False
        # Update existing id sets
        for emoji, ids in list(message.reactions.items()):
            if emoji in reactions:
                if by not in ids:
                    changed |= True
                    ids.add(by)
                added_reactions.add(emoji)
            elif by in ids:
                ids.discard(by)
                changed |= True
                # Remove set when empty
                if not ids:
                    del message.reactions[emoji]
        # Reaction is the first of its kind
        for emoji in (reactions - added_reactions):
            message.reactions[emoji] = {by}
            changed |= True

        # Force message refresh
        if changed:
            for window in self._windows:
                window.modify_message(message.identifier, message=message)
        return changed

    def ack_message(self, old_id: str, jid: JID) -> Union[None, bool, Message]:
        """Mark a message as acked"""
        return self._edit_ack(1, old_id, jid)

    def nack_message(self, error: str, old_id: str,
                     jid: JID) -> Union[None, bool, Message]:
        """Mark a message as errored"""
        return self._edit_ack(-1, old_id, jid, append=error)

    def _edit_ack(self, value: int, old_id: str, jid: JID,
                  append: str = '') -> Union[None, bool, Message]:
        """
        Edit the ack status of a message, and optionally
        append some text.
        """
        _, i = self._find_message(old_id)
        if i == -1:
            return None
        msg = self.messages[i]
        if not isinstance(msg, Message):
            return None
        if msg.ack == 1:  # Message was already acked
            return False
        if msg.jid is None or msg.jid.bare != jid.bare:
            raise AckError('Wrong JID for message id %s (was %s, expected %s)'
                           % (old_id, msg.jid, jid))

        msg.ack = value
        if append:
            msg.txt += append
        # The message itself has not changed but we modify it in the buffer
        # to display the ack element without a full rebuild
        for window in self._windows:
            window.modify_message(old_id, msg)
        return msg

    def delete_message(self, orig_id: str, *,
                       jid: Optional[JID] = None,
                       user: Optional[User] = None,
                       muc: bool = False,
                       moderation: bool = False,
                       tombstone: Optional[str] = None) -> bool:
        """
        Remove a message from the buffer, either due to XEP-0424 or XEP-0425.
        0425 sets the moderation flag, bypassing other checks, while
        otherwise MUC user or bare jid has to match.

        In the MUC case we need to search by stanza-id, but in the other cases
        we need to search by message id.

        Optionally add a tombstone.
        """
        if muc:
            orig_id, i = self._find_message_stanza_id(orig_id)
        else:
            orig_id, i = self._find_message(orig_id)

        if i == -1:
            log.debug(
                'Message %s not found in text_buffer, abort deletion.',
                orig_id)
            return False
        message: Message | BaseMessage = self.messages[i]
        if moderation:
            # Nothing to do, all moderation requests that come through here
            # are supposed to be valid.
            pass
        elif user and isinstance(message, Message):
            if user != message.user:
                raise DeletionError(users=(user, message.user))
        elif jid and isinstance(message, Message):
            if message.jid and jid.bare != message.jid.bare:
                raise DeletionError(jids=(jid.bare, message.jid.bare))
        else:
            raise DeletionError()
        tombstone_msg = None
        if tombstone:
            old_message: Message | None
            if isinstance(message, Message):
                old_message = message
            else:
                old_message = None

            tombstone_msg = DeletedMessage(tombstone, old_message=old_message)
            self.messages[i] = tombstone_msg
        else:
            del self.messages[i]
        for window in self._windows:
            window.modify_message(message.identifier, message=tombstone_msg)
        return True

    def modify_message(self,
                       txt: str,
                       orig_id: str,
                       new_id: str,
                       highlight: bool = False,
                       time: Optional[datetime] = None,
                       user: Optional[User] = None,
                       jid: Optional[JID] = None) -> Message:
        """
        Correct a message in a text buffer.

        Version 1.1.0 of Last Message Correction (0308) added clarifications
        that break the way poezio handles corrections. Instead of linking
        corrections to the previous correction/message as we were doing, we
        are now required to link all corrections to the original messages.
        """

        orig_id, i = self._find_message(orig_id)

        if i == -1:
            log.debug(
                'Message %s not found in text_buffer, abort replacement.',
                orig_id)
            raise CorrectionError(found=False)

        msg = self.messages[i]
        if not isinstance(msg, Message):
            raise CorrectionError(is_message=False)
        if msg.user and msg.user != user:
            raise CorrectionError(same_user=False)
        elif msg.delayed:
            raise CorrectionError(delayed=True)
        elif not msg.user and (msg.jid is None or jid is None):
            raise CorrectionError(no_identity=True)
        elif not msg.user and msg.jid != jid:
            raise CorrectionError(no_match=True)

        if not time:
            time = datetime.now()

        self.correction_ids[new_id] = orig_id
        message = Message(
            txt=txt,
            time=time,
            nickname=msg.nickname,
            nick_color=msg.nick_color,
            user=msg.user,
            identifier=orig_id,
            highlight=highlight,
            old_message=msg,
            revisions=msg.revisions + 1,
            jid=jid)
        self.messages[i] = message
        for window in self._windows:
            window.modify_message(orig_id, message)
        log.debug('Replacing message %s with %s.', orig_id, new_id)
        return message

    def del_window(self, win: TextWin) -> None:
        self._windows.remove(win)

    def find_last_message(self) -> Optional[Message]:
        """Find the last real message received in this buffer"""
        for message in reversed(self.messages):
            if isinstance(message, Message):
                return message
        return None

    def find_first_message(self) -> Optional[Message]:
        """Find the first real message received in this buffer"""
        for message in self.messages:
            if isinstance(message, Message):
                return message
        return None

    def __del__(self) -> None:
        size = len(self.messages)
        log.debug('** Deleting %s messages from textbuffer', size)
