import warnings
from colorsys import rgb_to_hsv
from typing import Optional, Union

import discord as di
import discord.errors
from discord import ClientException
from slidge import LegacyBookmarks, LegacyMUC, LegacyParticipant, MucType
from slidge.util.types import Avatar, Hat, HoleBound
from slidge.util.util import SlidgeLogger
from slixmpp.exceptions import XMPPError

from . import config
from .contact import Contact
from .session import Session
from .util import MessageMixin, StatusMixin


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

    async def fill(self):
        for channel in self.session.discord.get_all_channels():
            if isinstance(channel, di.TextChannel):
                try:
                    await self.by_legacy_id(channel.id)
                except XMPPError as e:
                    self.log.debug("Skipping %s because of %s", channel, e)

        for private_channel in self.session.discord.private_channels:
            self.log.debug("Private channel: %s", private_channel)
            if isinstance(private_channel, di.DMChannel):
                continue
            try:
                muc = await self.by_legacy_id(private_channel.id)
            except XMPPError as e:
                self.log.debug("Skipping %s because of %s", private_channel, e)
                continue

            await muc.add_to_bookmarks()


class Participant(StatusMixin, MessageMixin, LegacyParticipant):  # type:ignore[misc]
    session: Session
    contact: Contact
    log: SlidgeLogger

    @property
    def discord_user(self) -> Union[di.User, di.ClientUser]:  # type:ignore
        if self.contact is None:
            return self.session.discord.user  # type:ignore

        return self.contact.discord_user

    def update_role_channel(self, chan: di.TextChannel, member: di.Member) -> None:
        self.update_status(member.status, member.activity)

        self.set_hats(
            [
                Hat(
                    f"http://slidge.im/slidcord/{role.id}",
                    role.name,
                    hue=rgb_to_hsv(*(x / 255 for x in role.colour.to_rgb()))[0] * 360
                    if role.colour.value
                    else None,
                )
                for role in member.roles[1:]  # first role is @everyone, useless
            ]
        )
        if chan.guild.owner == self.discord_user:
            self.role = "moderator"
            self.affiliation = "owner"
        else:
            permissions = chan.permissions_for(member)
            if (
                permissions.kick_members
                or permissions.ban_members
                or permissions.manage_messages
            ):
                self.role = "moderator"
                self.affiliation = "admin"
            elif not permissions.send_messages:
                self.role = "visitor"
                self.affiliation = "none"

    def update_role_group(self, chan: di.GroupChannel) -> None:
        self.role = "moderator"
        if chan.owner == self.discord_user:
            self.affiliation = "owner"
        else:
            self.affiliation = "admin"


class MUC(LegacyMUC[int, int, Participant, int]):
    session: Session

    async def get_discord_channel(self) -> Union[di.TextChannel, di.GroupChannel]:
        await self.session.discord.wait_until_ready()
        channel = self.session.discord.get_channel(self.legacy_id)  # type: ignore
        if not isinstance(channel, (di.GroupChannel, di.TextChannel)):
            raise XMPPError(
                "bad-request", f"This is not a valid textual discord channel: {channel}"
            )
        return channel

    async def get_user_participant(self, **kwargs) -> "Participant":
        p = await super().get_user_participant()
        p.discord_id = self.session.discord.user.id  # type:ignore
        return p

    async def fill_participants(self):
        chan = await self.get_discord_channel()
        members = await self.members()
        if isinstance(chan, di.TextChannel):
            for m in members:
                p = await self.get_participant_by_legacy_id(m.id)
                assert isinstance(m, di.Member)
                p.update_role_channel(chan, m)
                yield p
        else:
            for m in members:
                p = await self.get_participant_by_legacy_id(m.id)
                p.update_role_group(chan)
                yield p
            p = await self.get_user_participant()
            p.role = "moderator"
            if chan.owner_id == self.session.contacts.user_legacy_id:
                p.affiliation = "owner"
            else:
                p.affiliation = "admin"
            yield p

    async def members(self, fetch: bool = True) -> list[di.Member] | list[di.User]:
        chan = await self.get_discord_channel()
        if isinstance(chan, di.GroupChannel):
            return chan.recipients
        else:
            if chan.guild.chunked:
                return chan.members  # type: ignore
            else:
                async with self.session.guild_members_fetch_lock:
                    return await self.__fetch_guild_members(chan, fetch)

    async def __fetch_guild_members(
        self, chan: di.TextChannel, fetch: bool
    ) -> list[di.Member]:
        if (
            config.ALWAYS_FETCH_GUILD_MEMBERS
            and chan.guild.id not in self.session.guild_members_fetched
            and fetch
        ):
            try:
                await chan.guild.fetch_members(cache=True)
            except ClientException as e:
                self.session.log.debug(
                    "Fetching guild members failed, attempting to specify the room: %s",
                    e,
                )
                await chan.guild.fetch_members(channels=[chan], cache=True)
            self.session.guild_members_fetched.add(chan.guild.id)
        return chan.members

    async def user_member(self):
        try:
            me = next(
                m
                for m in await self.members(fetch=False)
                if m.id == self.session.discord.user.id  # type:ignore
            )
        except StopIteration:
            return None
        return me

    async def update_info(self):
        chan = await self.get_discord_channel()
        if not chan:
            raise XMPPError(
                "item-not-found", f"Can't retrieve info discord on {self.legacy_id}"
            )
        if isinstance(chan, di.GroupChannel):
            self.type = MucType.GROUP
            await self._update_group(chan)
        elif isinstance(chan, di.TextChannel):
            self.type = MucType.CHANNEL_NON_ANONYMOUS
            await self._update_guild_channel(chan)

    async def _update_guild_channel(self, chan: di.TextChannel):
        me = await self.user_member()
        if not me:
            raise XMPPError(
                "registration-required", f"You are not a member of {self.name}"
            )

        if not chan.permissions_for(me).read_messages:
            raise XMPPError(
                "forbidden", f"You are not allowed to read messages in {self.name}"
            )

        if chan.category:
            self.name = (
                f"{chan.guild.name}/{chan.position:02d}/{chan.category}/{chan.name}"
            )
        else:
            self.name = f"{chan.guild.name}/{chan.position:02d}/{chan.name}"
        if chan.topic:
            self.subject = chan.topic
        self.n_participants = chan.guild.approximate_member_count
        if icon := chan.guild.icon:
            self.avatar = Avatar(url=icon.url, unique_id=icon.key)
        else:
            self.avatar = None

    async def _update_group(self, chan: di.GroupChannel):
        if chan.name:
            self.name = chan.name
        else:
            recipients = [
                x
                for x in chan.recipients
                if x.id != self.session.discord.user.id  # type:ignore
            ]

            if len(recipients) == 0:
                self.name = "Unnamed private group"
            else:
                self.name = ", ".join(map(lambda x: x.name, recipients))
        self.n_participants = len(chan.nicks)

    async def backfill(
        self,
        after: Optional[HoleBound] = None,
        before: Optional[HoleBound] = None,
    ):
        try:
            await self.history(after, before)
        except discord.errors.HTTPException as e:
            self.log.warning("Could not fetch history of %r: %r", self.name, e)

    async def history(self, after: Optional[HoleBound], before: Optional[HoleBound]):
        if not config.MUC_BACK_FILL:
            return

        chan = await self.get_discord_channel()

        async for msg in chan.history(
            before=None if before is None else before.timestamp,
            after=None if after is None else after.timestamp,
            limit=config.MUC_BACK_FILL,
        ):
            author = msg.author
            if author.id == self.session.discord.user.id:  # type:ignore
                p = await self.get_user_participant()
            else:
                try:
                    p = await self.get_participant_by_contact(
                        await self.session.contacts.by_discord_user(author)
                    )
                except XMPPError:
                    # deleted users
                    p = await self.get_participant(author.name)
            await p.send_message(msg, archive_only=True)

    async def get_participant_by_discord_user(self, user: Union[di.User, di.Member]):
        if user.discriminator == "0000":
            # a webhook, eg Github#0000
            # FIXME: avatars for contact-less participants
            p = await self.get_participant(user.display_name)
            p.DISCO_CATEGORY = "bot"
            return p
        elif user.system:
            return self.get_system_participant()
        try:
            return await self.get_participant_by_legacy_id(user.id)
        except XMPPError as e:
            warnings.warn(
                (
                    f"Could not get participant with contact for {user}: {e}"
                    "falling back to a 'contact-less' participant."
                )
            )
            return await self.get_participant(user.display_name)

    async def create_thread(self, xmpp_id: str) -> int:
        ch = await self.get_discord_channel()
        if isinstance(ch, di.GroupChannel):
            raise XMPPError("bad-request")

        try:
            thread_id = int(xmpp_id)
        except ValueError:
            pass
        else:
            if thread_id in (t.id for t in ch.threads):
                return thread_id

        thread = await ch.create_thread(name=xmpp_id, type=di.ChannelType.public_thread)
        return thread.id
