import asyncio
import logging
import re
import string
import typing
from datetime import datetime, timedelta

import discord
from discord.ext.commands import UserInputError, CommandError

from core.models import Bot, ThreadManagerABC, ThreadABC
from core.utils import is_image_url, days, match_user_id
from core.utils import truncate, ignore, error


logger = logging.getLogger('Modmail')


class Thread(ThreadABC):
    """Represents a discord Modmail thread"""

    def __init__(self, manager: 'ThreadManager',
                 recipient: typing.Union[discord.Member, discord.User, int],
                 channel: typing.Union[discord.DMChannel,
                                       discord.TextChannel] = None):
        self.manager = manager
        self.bot = manager.bot
        if isinstance(recipient, int):
            self._id = recipient
            self._recipient = None
        else:
            if recipient.bot:
                raise CommandError('Recipient cannot be a bot.')
            self._id = recipient.id
            self._recipient = recipient
        self._channel = channel
        self._ready_event = asyncio.Event()
        self._close_task = None

    def __repr__(self):
        return (f'Thread(recipient="{self.recipient or self.id}", '
                f'channel={self.channel.id})')

    async def wait_until_ready(self):
        """Blocks execution until the thread is fully set up."""
        await self._ready_event.wait()

    @property
    def id(self):
        return self._id

    @property
    def close_task(self):
        return self._close_task

    @close_task.setter
    def close_task(self, val):
        self._close_task = val

    @property
    def channel(self):
        return self._channel

    @property
    def recipient(self):
        return self._recipient

    @property
    def ready(self):
        return self._ready_event.is_set()

    @ready.setter
    def ready(self, flag):
        if flag:
            self._ready_event.set()
        else:
            self._ready_event.clear()

    async def setup(self, *, creator=None, category=None):
        """Create the thread channel and other io related initialisation tasks"""

        recipient = self.recipient

        # in case it creates a channel outside of category
        overwrites = {
            self.bot.modmail_guild.default_role:
                discord.PermissionOverwrite(read_messages=False)
        }

        category = category or self.bot.main_category

        if category is not None:
            overwrites = None

        channel = await self.bot.modmail_guild.create_text_channel(
            name=self.manager._format_channel_name(recipient),
            category=category,
            overwrites=overwrites,
            reason='Creating a thread channel'
        )

        self._channel = channel

        log_url, log_data = await asyncio.gather(
            self.bot.api.create_log_entry(recipient, channel,
                                          creator or recipient),
            self.bot.api.get_user_logs(recipient.id)
        )

        log_count = sum(1 for log in log_data if not log['open'])
        info_embed = self.manager._format_info_embed(recipient, log_url,
                                                     log_count,
                                                     discord.Color.green())

        topic = f'User ID: {recipient.id}'
        if creator:
            mention = None
        else:
            mention = self.bot.config.get('mention', '@here')

        _, msg = await asyncio.gather(
            channel.edit(topic=topic),
            channel.send(mention, embed=info_embed)
        )

        self.ready = True

        # Once thread is ready, tell the recipient.
        thread_creation_response = self.bot.config.get(
            'thread_creation_response',
            'The moderation team will get back to you as soon as possible!'
        )

        embed = discord.Embed(
            color=self.bot.mod_color,
            description=thread_creation_response,
            timestamp=datetime.utcnow(),
        )
        embed.set_footer(text='Your message has been sent',
                         icon_url=self.bot.guild.icon_url)
        embed.set_author(name='Thread Created')

        if creator is None:
            self.bot.loop.create_task(recipient.send(embed=embed))

        await msg.pin()

    def _close_after(self, closer, silent, delete_channel, message):
        return self.bot.loop.create_task(
            self._close(closer, silent, delete_channel, message, True)
        )

    async def close(self, *, closer, after=0, silent=False,
                    delete_channel=True, message=None):
        """Close a thread now or after a set time in seconds"""

        # restarts the after timer
        await self.cancel_closure()

        if after > 0:
            # TODO: Add somewhere to clean up broken closures
            #  (when channel is already deleted)
            await self.bot.config.update()
            now = datetime.utcnow()
            items = {
                # 'initiation_time': now.isoformat(),
                'time': (now + timedelta(seconds=after)).isoformat(),
                'closer_id': closer.id,
                'silent': silent,
                'delete_channel': delete_channel,
                'message': message
            }
            self.bot.config.closures[str(self.id)] = items
            await self.bot.config.update()

            self.close_task = self.bot.loop.call_later(
                after, self._close_after, closer,
                silent, delete_channel, message
            )
        else:
            await self._close(closer, silent, delete_channel, message)

    async def _close(self, closer, silent=False, delete_channel=True,
                     message=None, scheduled=False):
        del self.manager.cache[self.id]

        await self.cancel_closure()

        if str(self.id) in self.bot.config.subscriptions:
            del self.bot.config.subscriptions[str(self.id)]

        # Logging
        log_data = await self.bot.api.post_log(self.channel.id, {
            'open': False,
            'closed_at': str(datetime.utcnow()),
            'close_message': message if not silent else None,
            'closer': {
                'id': str(closer.id),
                'name': closer.name,
                'discriminator': closer.discriminator,
                'avatar_url': closer.avatar_url,
                'mod': True
            }
        })

        if log_data is not None and isinstance(log_data, dict):
            if self.bot.self_hosted:
                log_url = f"{self.bot.config.log_url.strip('/')}/" \
                    f"logs/{log_data['key']}"
            else:
                log_url = f"https://logs.modmail.tk/{log_data['key']}"

            if log_data['messages']:
                content = str(log_data['messages'][0]['content'])
                sneak_peak = content.replace('\n', '')
            else:
                sneak_peak = 'No content'

            desc = f"[`{log_data['key']}`]({log_url}): "
            desc += truncate(sneak_peak, max=75 - 13)
        else:
            desc = "Could not resolve log url."

        embed = discord.Embed(description=desc, color=discord.Color.red())

        if self.recipient is not None:
            user = f"{self.recipient} (`{self.id}`)"
        else:
            user = f'`{self.id}`'

        embed.title = user

        event = 'Thread Closed as Scheduled' if scheduled else 'Thread Closed'
        # embed.set_author(name=f'Event: {event}', url=log_url)
        embed.set_footer(text=f'{event} by {closer} ({closer.id})')
        embed.timestamp = datetime.utcnow()

        tasks = [
            self.bot.config.update()
        ]

        if self.bot.log_channel:
            tasks.append(self.bot.log_channel.send(embed=embed))

        # Thread closed message

        embed = discord.Embed(title='Thread Closed',
                              color=discord.Color.red(),
                              timestamp=datetime.utcnow())

        if not message:
            message = f'{closer.mention} has closed this Modmail thread.'
        embed.description = message
        embed.set_footer(text='Replying will create a new thread',
                         icon_url=self.bot.guild.icon_url)

        if not silent and self.recipient is not None:
            tasks.append(self.recipient.send(embed=embed))

        if delete_channel:
            tasks.append(self.channel.delete())

        await asyncio.gather(*tasks)

    async def cancel_closure(self):
        if self.close_task is not None:
            self.close_task.cancel()
            self.close_task = None

        to_update = self.bot.config.closures.pop(str(self.id), None)
        if to_update is not None:
            await self.bot.config.update()

    @staticmethod
    async def _edit_thread_message(channel, message_id, message):
        async for msg in channel.history():
            if not msg.embeds:
                continue
            embed = msg.embeds[0]
            if embed and embed.author and embed.author.url:
                if str(message_id) == str(embed.author.url).split('/')[-1]:
                    embed.description = message
                    await msg.edit(embed=embed)
                    break

    async def edit_message(self, message_id, message):
        await asyncio.gather(
            self._edit_thread_message(self.recipient, message_id, message),
            self._edit_thread_message(self.channel, message_id, message)
        )

    async def note(self, message):
        if not message.content and not message.attachments:
            raise UserInputError

        await asyncio.gather(
            self.bot.api.append_log(message,
                                    self.channel.id,
                                    type_='system'),
            self.send(message, self.channel, note=True)
        )

    async def reply(self, message, anonymous=False):
        if not message.content and not message.attachments:
            raise UserInputError
        if all(not g.get_member(self.id) for g in self.bot.guilds):
            return await message.channel.send(
                embed=discord.Embed(
                    color=discord.Color.red(),
                    description='Your message could not be delivered since'
                                'the recipient shares no servers with the bot'
                ))

        tasks = []

        try:
            await self.send(message,
                            destination=self.recipient,
                            from_mod=True,
                            anonymous=anonymous)
        except Exception:
            logger.info(error('Message delivery failed:'), exc_info=True)
            tasks.append(message.channel.send(
                embed=discord.Embed(
                    color=discord.Color.red(),
                    description='Your message could not be delivered because '
                                'the recipient is only accepting direct '
                                'messages from friends, or the bot was '
                                'blocked by the recipient.'
                )
            ))
        else:
            # Send the same thing in the thread channel.
            tasks.append(
                self.send(message,
                          destination=self.channel,
                          from_mod=True,
                          anonymous=anonymous)
            )

            tasks.append(
                self.bot.api.append_log(message,
                                        self.channel.id,
                                        type_='anonymous' if anonymous else 'thread_message'
                                        ))

        if self.close_task is not None:
            # cancel closing if a thread message is sent.
            await self.cancel_closure()
            tasks.append(
                self.channel.send(
                    embed=discord.Embed(
                        color=discord.Color.red(),
                        description='Scheduled close has been cancelled.'
                    )
                )
            )

        await asyncio.gather(*tasks)

    async def send(self, message, destination=None,
                   from_mod=False, note=False, anonymous=False):
        if self.close_task is not None:
            # cancel closing if a thread message is sent.
            tasks = asyncio.gather(
                self.cancel_closure(),
                self.channel.send(embed=discord.Embed(
                    color=discord.Color.red(),
                    description='Scheduled close has been cancelled.'
                )))
            self.bot.loop.create_task(tasks)

        if not self.ready:
            await self.wait_until_ready()

        if not from_mod and not note:
            self.bot.loop.create_task(
                self.bot.api.append_log(message, self.channel.id)
            )

        destination = destination or self.channel

        author = message.author

        embed = discord.Embed(
            description=message.content,
            timestamp=message.created_at
        )

        system_avatar_url = 'https://discordapp.com/assets/' \
                            'f78426a064bc9dd24847519259bc42af.png'

        if not note:
            if anonymous and from_mod and \
                    not isinstance(destination, discord.TextChannel):
                # Anonymously sending to the user.
                tag = self.bot.config.get('mod_tag',
                                          str(message.author.top_role))
                name = self.bot.config.get('anon_username', tag)
                avatar_url = self.bot.config.get('anon_avatar_url',
                                                 self.bot.guild.icon_url)
            else:
                # Normal message
                name = str(author)
                avatar_url = author.avatar_url

            embed.set_author(name=name,
                             icon_url=avatar_url,
                             url=message.jump_url)
        else:
            # Special note messages
            embed.set_author(name=f'Note ({author.name})',
                             icon_url=system_avatar_url,
                             url=message.jump_url)

        delete_message = not bool(message.attachments)

        attachments = [(a.url, a.filename) for a in message.attachments]

        images = [x for x in attachments if is_image_url(*x)]
        attachments = [x for x in attachments if not is_image_url(*x)]

        image_links = [
            (link, None) for link in re.findall(r'(https?://[^\s]+)',
                                                message.content)
        ]
        image_links = [x for x in image_links if is_image_url(*x)]
        images.extend(image_links)

        embedded_image = False

        prioritize_uploads = any(i[1] is not None for i in images)

        additional_images = []
        additional_count = 1

        for att in images:  # TODO: Logic needs review
            if not prioritize_uploads or (
                    is_image_url(*att) and not
                    embedded_image and
                    att[1]
            ):
                embed.set_image(url=att[0])
                embedded_image = True
            elif att[1] is not None:
                if note:
                    color = discord.Color.blurple()
                elif from_mod:
                    color = self.bot.mod_color
                else:
                    color = self.bot.recipient_color

                img_embed = discord.Embed(color=color)
                img_embed.set_image(url=att[0])
                img_embed.title = att[1]
                img_embed.url = att[0]
                img_embed.set_footer(
                    text=f'Additional Image Upload ({additional_count})'
                )
                img_embed.timestamp = message.created_at
                additional_images.append(destination.send(embed=img_embed))
                additional_count += 1

        file_upload_count = 1

        for att in attachments:
            embed.add_field(name=f'File upload ({file_upload_count})',
                            value=f'[{att[1]}]({att[0]})')
            file_upload_count += 1

        if from_mod:
            # noinspection PyUnresolvedReferences,PyDunderSlots
            embed.color = self.bot.mod_color  # pylint: disable=E0237
            # Anonymous reply sent in thread channel
            if anonymous and isinstance(destination, discord.TextChannel):
                embed.set_footer(text='Anonymous Reply')
            # Normal messages
            elif not anonymous:
                tag = self.bot.config.get('mod_tag',
                                          str(message.author.top_role))
                embed.set_footer(text=tag)  # Normal messages
            else:
                embed.set_footer(
                    text=self.bot.config.get('anon_tag', 'Response')
                )
        elif note:
            # noinspection PyUnresolvedReferences,PyDunderSlots
            embed.color = discord.Color.blurple()  # pylint: disable=E0237
        else:
            embed.set_footer(text=f'Recipient')
            # noinspection PyUnresolvedReferences,PyDunderSlots
            embed.color = self.bot.recipient_color  # pylint: disable=E0237

        await destination.trigger_typing()

        if not from_mod:
            mentions = self.get_notifications()
        else:
            mentions = None

        await destination.send(mentions, embed=embed)
        if additional_images:
            self.ready = False
            await asyncio.gather(*additional_images)
            self.ready = True

        if delete_message:
            self.bot.loop.create_task(ignore(message.delete()))

    def get_notifications(self):
        config = self.bot.config
        key = str(self.id)

        mentions = []
        mentions.extend(config['subscriptions'].get(key, []))

        if key in config['notification_squad']:
            mentions.extend(config['notification_squad'][key])
            del config['notification_squad'][key]
            self.bot.loop.create_task(config.update())

        return ' '.join(mentions)


class ThreadManager(ThreadManagerABC):
    """Class that handles storing, finding and creating Modmail threads."""

    def __init__(self, bot: Bot):
        self.bot = bot
        self.cache = {}

    async def populate_cache(self):
        for channel in self.bot.modmail_guild.text_channels:
            if channel.category != self.bot.main_category and not \
                    self.bot.using_multiple_server_setup:
                continue
            await self.find(channel=channel)

    def __len__(self):
        return len(self.cache)

    def __iter__(self):
        return iter(self.cache.values())

    def __getitem__(self, item):
        return self.cache[item]

    async def find(self, *, recipient=None, channel=None):
        """Finds a thread from cache or from discord channel topics."""
        if recipient is None and channel is not None:
            return await self._find_from_channel(channel)

        thread = None
        try:
            thread = self.cache[recipient.id]
        except KeyError:
            channel = discord.utils.get(
                self.bot.modmail_guild.text_channels,
                topic=f'User ID: {recipient.id}'
            )
            if channel:
                thread = Thread(self, recipient, channel)
                self.cache[recipient.id] = thread
                thread.ready = True
        return thread

    async def _find_from_channel(self, channel):
        """
        Tries to find a thread from a channel channel topic,
        if channel topic doesnt exist for some reason, falls back to
        searching channel history for genesis embed and
        extracts user_id from that.
        """
        user_id = -1

        if channel.topic:
            user_id = match_user_id(channel.topic)

        # BUG: When discord fails to create channel topic.
        # search through message history
        elif channel.topic is None:
            try:
                async for message in channel.history(limit=100):
                    if message.embeds:
                        embed = message.embeds[0]
                        if embed.footer.text:
                            user_id = match_user_id(embed.footer.text)
                            if user_id != -1:
                                break
            except discord.NotFound:
                # When the channel's deleted.
                pass

        if user_id != -1:
            if user_id in self.cache:
                return self.cache[user_id]

            recipient = self.bot.get_user(user_id)
            if recipient is None:
                self.cache[user_id] = thread = Thread(self, user_id, channel)
            else:
                self.cache[user_id] = thread = Thread(self, recipient, channel)
            thread.ready = True

            return thread

    def create(self, recipient, *, creator=None, category=None):
        """Creates a Modmail thread"""
        # create thread immediately so messages can be processed
        thread = Thread(self, recipient)
        self.cache[recipient.id] = thread

        # Schedule thread setup for later
        self.bot.loop.create_task(thread.setup(
            creator=creator,
            category=category
        ))
        return thread

    async def find_or_create(self, recipient):
        return await self.find(recipient=recipient) or self.create(recipient)

    def _format_channel_name(self, author):
        """Sanitises a username for use with text channel names"""
        name = author.name.lower()
        allowed = string.ascii_letters + string.digits + '-'
        new_name = ''.join(l for l in name if l in allowed) or 'null'
        new_name += f'-{author.discriminator}'

        while new_name in [c.name for c in
                           self.bot.modmail_guild.text_channels]:
            new_name += '-x'  # two channels with same name

        return new_name

    def _format_info_embed(self, user, log_url, log_count, color):
        """Get information about a member of a server
        supports users from the guild or not."""
        member = self.bot.guild.get_member(user.id)
        avi = user.avatar_url
        time = datetime.utcnow()

        # key = log_url.split('/')[-1]

        role_names = ''
        if member:
            separate_server = self.bot.guild != self.bot.modmail_guild
            roles = sorted(member.roles, key=lambda c: c.position)
            if separate_server:
                role_names = ', '.join(r.name for r in roles
                                       if r.name != "@everyone")
            else:
                role_names = ' '.join(r.mention for r in roles
                                      if r.name != "@everyone")

        embed = discord.Embed(color=color,
                              description=user.mention,
                              timestamp=time)

        created = str((time - user.created_at).days)
        # if not role_names:
        #     embed.add_field(name='Mention', value=user.mention)
        # embed.add_field(name='Registered', value=created + days(created))
        embed.description += f' was created {days(created)}'

        footer = 'User ID: ' + str(user.id)
        embed.set_footer(text=footer)
        embed.set_author(name=str(user), icon_url=avi, url=log_url)
        # embed.set_thumbnail(url=avi)

        if member:
            joined = str((time - member.joined_at).days)
            # embed.add_field(name='Joined', value=joined + days(joined))
            embed.description += f', joined {days(joined)}'

            if member.nick:
                embed.add_field(name='Nickname',
                                value=member.nick,
                                inline=True)
            if role_names:
                embed.add_field(name='Roles',
                                value=role_names,
                                inline=True)
        else:
            embed.set_footer(text=f'{footer} | Note: this member '
                                  'is not part of this server.')

        if log_count:
            # embed.add_field(name='Past logs', value=f'{log_count}')
            thread = 'thread' if log_count == 1 else 'threads'
            embed.description += f" with **{log_count}** past {thread}."
        else:
            embed.description += '.'

        return embed
