import io
import typing
from collections.abc import AsyncIterator

import nio
from slidge.db.meta import JSONSerializable
from slidge.group import LegacyBookmarks, LegacyMUC, LegacyParticipant, MucType
from slidge.util.types import (
    ChatState,
    HoleBound,
    MucAffiliation,
    XMPPAttachmentMessage,
    XMPPMessage,
)
from slixmpp import Presence
from slixmpp.exceptions import XMPPError

from matridge.contact import Contact
from matridge.matrix import Client

from . import config
from .util import MatrixMixin, get_content, server_timestamp_to_datetime

if typing.TYPE_CHECKING:
    from .session import Session


class Participant(MatrixMixin, LegacyParticipant[Contact]):
    session: "Session"
    muc: "MUC"
    contact: Contact

    def set_affiliation_from_power_level(self, power_level: int) -> None:
        if power_level == 100:
            self.affiliation = "owner"
            self.role = "moderator"
        elif power_level >= 50:
            self.affiliation = "admin"
            self.role = "moderator"
        else:
            self.affiliation = "member"
            self.role = "participant"

    async def on_kick(self, reason: str | None) -> None:
        """Kick a user from the Matrix room."""
        user_id = self.contact.legacy_id
        resp = await self.session.matrix.room_kick(
            self.contact.legacy_id, user_id, reason
        )
        if isinstance(resp, nio.RoomKickError):
            raise XMPPError("internal-server-error", str(resp))

    async def on_set_affiliation(
        self,
        affiliation: MucAffiliation,
        reason: str | None,
        nickname: str | None,
    ) -> None:
        """Handle XMPP affiliation changes and translate to Matrix."""
        user_id = self.contact.legacy_id
        room_id = self.muc.legacy_id

        if affiliation == "member":
            resp = await self.session.matrix.room_invite(room_id, user_id)
            if isinstance(resp, nio.RoomInviteError):
                raise XMPPError("internal-server-error", str(resp))
            await self.muc._set_power_level(user_id, 0)
        elif affiliation == "outcast":
            resp = await self.session.matrix.room_ban(room_id, user_id, reason)
            if isinstance(resp, nio.RoomBanError):
                raise XMPPError("internal-server-error", str(resp))
        elif affiliation == "none":
            # Remove privileges first to avoid leaving user unbanned with power.
            await self.muc._set_power_level(user_id, 0)

            if await self.muc._is_banned(user_id):
                resp = await self.session.matrix.room_unban(room_id, user_id)
                if isinstance(resp, nio.RoomUnbanError):
                    raise XMPPError("internal-server-error", str(resp))
            else:
                resp = await self.session.matrix.room_kick(room_id, user_id, reason)
                if isinstance(resp, nio.RoomKickError):
                    raise XMPPError("internal-server-error", str(resp))
        elif affiliation == "owner":
            await self.muc._set_power_level(user_id, 100)
        elif affiliation == "admin":
            await self.muc._set_power_level(user_id, 50)
        else:
            self.log.warning("Affiliation %s not implemented.", affiliation)


class MUC(LegacyMUC[Participant]):
    session: "Session"
    _ignore_unverified_devices: bool | None = None

    @property
    def matrix(self) -> Client:
        return self.session.matrix

    def serialize_extra_attributes(self) -> JSONSerializable | None:
        """Save custom room settings to database."""
        if self._ignore_unverified_devices is None:
            return None
        return {"ignore_unverified_devices": self._ignore_unverified_devices}

    def deserialize_extra_attributes(self, data: JSONSerializable) -> None:
        """Restore custom room settings from database."""
        if ignore_unverified_devices := data.get("ignore_unverified_devices"):
            assert isinstance(ignore_unverified_devices, bool | None)
            self._ignore_unverified_devices = ignore_unverified_devices

    @property
    def ignore_unverified_devices(self) -> bool:
        """Get effective setting: room-specific or global fallback."""
        if self._ignore_unverified_devices is not None:
            return self._ignore_unverified_devices
        # Fallback to global setting
        return bool(self.session.user.preferences.get("trust_everything", False))

    @ignore_unverified_devices.setter
    def ignore_unverified_devices(self, value: bool) -> None:
        """Set room-specific setting and persist to database."""
        self._ignore_unverified_devices = value
        self.commit()

    async def get_room(self) -> nio.MatrixRoom:
        if room := self.session.matrix.rooms.get(self.legacy_id):
            return room
        # FIXME: invited_rooms is not persisted across restarts.
        if room := self.session.matrix.invited_rooms.get(self.legacy_id):
            return room
        raise XMPPError("item-not-found", f"No room named {self.legacy_id}")

    async def get_message(self, msg_id: str) -> nio.Event | None:
        return await self.session.matrix.get_event(self.legacy_id, msg_id)

    async def update_info(self) -> None:
        room = await self.get_room()

        if new := room.replacement_room:
            localpart = await self.session.bookmarks.legacy_id_to_jid_username(new)
            raise XMPPError(
                "redirect", f"xmpp:{localpart}@{self.xmpp.boundjid.bare}?join"
            )
        self.log.debug("Children: %s", room.children)
        if room.children:
            raise XMPPError("bad-request", "This is not a real room but a 'space'")

        self.user_nick = room.user_name(self.session.matrix.user_id)

        self.log.debug("User nick: %s", self.user_nick)
        self.name = room.display_name
        self.log.debug("Avatar: %s", room.room_avatar_url)
        self.n_participants = room.member_count
        self.subject = room.topic

        if room.join_rule == "public":
            self.type = MucType.CHANNEL_NON_ANONYMOUS
        else:
            self.type = MucType.GROUP

        if room.room_avatar_url:
            self.avatar = await self.session.matrix.mxc_to_http(room.room_avatar_url)

        elif room.member_count == 2:
            # if 1:1 set room avatar to other users's avatar
            for user_id, user in list(room.users.items()):
                if user_id == self.session.matrix.user_id:
                    continue
                if user.avatar_url is None:
                    break
                self.avatar = await self.session.matrix.mxc_to_http(user.avatar_url)
                break

    async def fill_participants(self) -> AsyncIterator[Participant]:
        room = await self.get_room()
        if not room.members_synced:
            resp = await self.session.matrix.joined_members(self.legacy_id)
            if isinstance(resp, nio.JoinedMembersResponse):
                self.log.debug(
                    "Joined members response: %s participants", len(resp.members)
                )
            else:
                self.log.debug("Joined members error: %s", resp)

        power_levels = room.power_levels.users
        i = 0

        for user_id, user in list(room.users.items()):
            power_level = power_levels.get(user_id, 0)
            if power_level < 50 and i > config.MAX_PARTICIPANTS_FETCH:
                continue
            try:
                p = await self.get_participant_by_legacy_id(user.user_id)
            except XMPPError:
                continue
            p.set_affiliation_from_power_level(power_level)
            if power_level < 50:
                i += 1
            yield p

    async def backfill(
        self,
        after: HoleBound | None = None,
        before: HoleBound | None = None,
    ) -> None:
        after_date = None if after is None else after.timestamp
        before_date = None if before is None else before.timestamp
        self.log.info("Backfilling from {%s} until {%s}.", after_date, before_date)

        total = 0
        undecryptable = 0
        skipped = 0
        filled = 0
        reactions = 0

        async for event in self.session.matrix.fetch_history(self.legacy_id):
            total += 1
            if not isinstance(event, (nio.RoomMessage | nio.ReactionEvent)):
                self.log.debug("Not back-filling with %s", type(event))
                if isinstance(event, nio.MegolmEvent):
                    undecryptable += 1
                continue

            when = server_timestamp_to_datetime(event)
            if after_date is not None and when <= after_date:
                # Events are yielded in reverse chronological order.
                # If this one is too old, then all subsequent ones are too.
                break
            if before_date is not None and when >= before_date:
                continue

            try:
                participant = await self.session.matrix.get_participant(
                    await self.get_room(), event
                )
            except XMPPError as e:
                # Maybe deleted profiles?
                self.log.warning(
                    "Failed to fetch participant %s, using matrix ID as nickname: %s",
                    event.sender,
                    e,
                )
                participant = await self.get_participant(event.sender)

            if isinstance(event, nio.RoomMessage):
                await participant.send_matrix_message(event, archive_only=True)
                filled += 1
            elif isinstance(event, nio.ReactionEvent):
                msg_id = await self.session.matrix.get_original_id(
                    self.legacy_id,
                    event.reacts_to,
                )
                participant.react(msg_id, [event.key], archive_only=True)
                reactions += 1

            if filled >= config.MAX_HISTORY_FETCH:
                break

        self.log.info(
            "Backfilled: %d events, %d filled, %d reactions, %d undecryptable, %d unhandled.",
            total,
            filled,
            reactions,
            undecryptable,
            skipped,
        )

    async def join(self, join_presence: Presence) -> None:
        """Called when user joins the room. Join on Matrix side if invited."""
        response = await self.session.matrix.join(self.legacy_id)
        if isinstance(response, nio.JoinResponse):
            self.log.debug(f"Successfully joined Matrix room {self.legacy_id}")
        else:
            self.log.warning(f"Failed to join Matrix room {self.legacy_id}: {response}")
        await super().join(join_presence)

    async def _is_banned(self, user_id: str) -> bool:
        resp = await self.session.matrix.room_get_state_event(
            self.legacy_id,
            "m.room.member",
            user_id,
        )
        if isinstance(resp, nio.RoomGetStateEventError):
            # M_NOT_FOUND means user has no membership state in this room.
            if resp.status_code == "M_NOT_FOUND":
                return False
            raise XMPPError("internal-server-error", str(resp))

        # If we don't compare types first, mypy things this returns Any.
        membership = resp.content.get("membership")
        return isinstance(membership, str) and membership == "ban"

    async def _set_power_level(self, user_id: str, power_level: int) -> None:
        """Set a user's power level in the Matrix room."""
        # This seems overwrites any concurrent changes (even to other users) submitted
        # by another operator. This is a limitation in Matrix's API.

        resp = await self.session.matrix.room_get_state_event(
            self.legacy_id, "m.room.power_levels", ""
        )
        if isinstance(resp, nio.RoomGetStateEventError):
            raise XMPPError("internal-server-error", str(resp))

        content = resp.content
        users = content.setdefault("users", {})
        users[user_id] = power_level

        resp = await self.session.matrix.room_put_state(
            self.legacy_id, "m.room.power_levels", content
        )
        if isinstance(resp, nio.RoomPutStateError):
            raise XMPPError("internal-server-error", str(resp))

    async def on_message(self, message: XMPPMessage) -> str | None:
        if message.attachments:
            return await self.__on_attachment(message)

        if message.replace:
            content = {
                "msgtype": "m.text",
                "body": "* " + message.body,
                "m.new_content": get_content(message.body, message.mentions),
                "m.relates_to": {
                    "rel_type": "m.replace",
                    "event_id": message.replace,
                },
            }
        else:
            content = get_content(message.body, message.mentions)
        await self.__relates_to(content, message)
        return await self.__room_send(content)

    async def __relates_to(
        self,
        content: dict[str, typing.Any],
        message: XMPPMessage,
    ) -> None:
        relates_to = dict[str, typing.Any]()
        if message.reply:
            relates_to["m.in_reply_to"] = {
                "event_id": await self.matrix.get_original_id(
                    self.legacy_id, message.reply.msg_id
                )
            }
        if message.thread:
            relates_to["rel_type"] = "m.thread"
            relates_to["event_id"] = message.thread
        if relates_to:
            content["m.relates_to"] = relates_to

    async def __room_send(
        self,
        content: dict[str, typing.Any],
        message_type: str = "m.room.message",
    ) -> str:
        await self.matrix.room_typing(self.legacy_id, False)
        async with self.session.send_lock:
            response = await self.matrix.room_send(
                self.legacy_id,
                message_type=message_type,
                content=content,
                ignore_unverified_devices=self.ignore_unverified_devices,
            )
            return await self.__handle_response(response)

    async def __handle_response(self, response: nio.Response) -> str:
        self.log.debug("Send response: %s", response)
        if isinstance(response, nio.RoomSendError):
            raise XMPPError("internal-server-error", str(response))
        assert isinstance(response, nio.RoomSendResponse)
        self.session.events_to_ignore.add(response.event_id)
        assert isinstance(response.event_id, str)
        return response.event_id

    async def __on_attachment(self, message: XMPPAttachmentMessage) -> str:
        if message.replace:
            raise XMPPError(
                "feature-not-implemented",
                "Replacing a message with a new message containing attachments is not supported",
            )

        # TODO: handle multiple attachments in here, when:
        # - an XMPP client actually implements that;
        # - and slidge core supports that too.
        url = message.attachments[0].url
        filename = url.split("/")[-1]
        async with message.attachments[0].get() as http_response:
            content_type = http_response.content_type
            resp, _ = await self.matrix.upload(
                io.BytesIO(await http_response.read()),
                content_type,
                filename,
                filesize=http_response.content_length,
            )
        self.log.debug("Upload response: %s %r", type(resp), resp)
        if not isinstance(resp, nio.UploadResponse):
            raise XMPPError("internal-server-error", str(resp))
        content = {
            "msgtype": "m.image" if content_type.startswith("image") else "m.file",
            "body": message.body or filename,
            "url": resp.content_uri,
        }
        await self.__relates_to(content, message)
        return await self.__room_send(content)

    async def on_chat_state(self, chat_state: ChatState, thread: str | None) -> None:
        match chat_state:
            case "composing":
                await self.matrix.room_typing(self.legacy_id)
            case "paused":
                await self.matrix.room_typing(self.legacy_id, False)

    async def on_displayed(self, legacy_msg_id: str, thread: str | None) -> None:
        resp = await self.matrix.update_receipt_marker(self.legacy_id, legacy_msg_id)
        self.log.debug("Displayed response: %s", resp)

    async def on_react(
        self, legacy_msg_id: str, emojis: list[str], thread: str | None
    ) -> None:
        new_emojis = set(emojis)
        with self.matrix.reactions.session() as session:
            old_emojis = await self.matrix.reactions.get(
                session,
                self.legacy_id,
                legacy_msg_id,
                self.matrix.user_id,
                with_event_ids=True,
            )
            for old_emoji, event in old_emojis.items():
                if old_emoji in new_emojis:
                    new_emojis.remove(old_emoji)
                else:
                    await self.on_retract(event, thread=None)
                    self.matrix.reactions.remove(session, self.legacy_id, event)

            for emoji in new_emojis:
                content = {
                    "m.relates_to": {
                        "rel_type": "m.annotation",
                        "event_id": legacy_msg_id,
                        "key": emoji,
                    },
                }

                i: str = await self.__room_send(content, "m.reaction")
                await self.matrix.reactions.add(
                    session,
                    self.legacy_id,
                    legacy_msg_id,
                    self.matrix.user_id,
                    emoji,
                    i,
                )
            session.commit()

    async def on_retract(self, legacy_msg_id: str, thread: str | None) -> None:
        resp = await self.matrix.room_redact(self.legacy_id, legacy_msg_id)
        self.log.debug("Redact response: %s", resp)
        if isinstance(resp, nio.RoomRedactError):
            raise XMPPError("internal-server-error", str(resp))
        assert isinstance(resp, nio.RoomRedactResponse)
        self.session.events_to_ignore.add(resp.event_id)

    async def on_leave(self) -> None:
        resp = await self.matrix.room_leave(self.legacy_id)
        self.log.debug("Leave response: %s", resp)
        # This prevents the room being re-listed when the user calls the
        # "groups" command which in turn calls Bookmarks.fill().
        del self.matrix.rooms[self.legacy_id]


class Bookmarks(LegacyBookmarks[MUC]):
    session: "Session"

    async def fill(self) -> None:
        self.log.debug("Filling rooms")
        self.log.debug(f"Joined rooms: {self.session.matrix.rooms.keys()}")
        self.log.debug(f"Invited rooms: {self.session.matrix.invited_rooms.keys()}")
        for room in self.session.matrix.rooms:
            try:
                muc = await self.by_legacy_id(room)
                await muc.add_to_bookmarks()
                self.log.debug(f"Successfully processed room {room}: {muc}")
            except XMPPError as e:
                self.log.debug(
                    "%s is not a group chat or trouble getting it: %r", room, e
                )

        # Relay Matrix invitations as XMPP room invites.
        for room_id in self.session.matrix.invited_rooms:
            try:
                muc = await self.by_legacy_id(room_id)
                self.log.debug(f"Relaying Matrix invitation to XMPP for {room_id}")
                self.session.send_gateway_invite(muc)
            except XMPPError as e:
                self.log.debug("Failed to relay invitation for %s: %r", room_id, e)
