import asyncio
import logging
import re
import textwrap
from abc import abstractmethod
from collections import defaultdict, deque
from dataclasses import dataclass
from typing import Optional

import dateutil.parser
import discord
from discord import Color, DMChannel, Embed, HTTPException, Message, errors
from discord.ext.commands import Cog, Context

from bot.api import ResponseCodeError
from bot.bot import Bot
from bot.cogs.moderation import ModLog
from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons
from bot.pagination import LinePaginator
from bot.utils import CogABCMeta, messages
from bot.utils.time import time_since

log = logging.getLogger(__name__)

URL_RE = re.compile(r"(https?://[^\s]+)")


@dataclass
class MessageHistory:
    """Represents a watch channel's message history."""

    last_author: Optional[int] = None
    last_channel: Optional[int] = None
    message_count: int = 0


class WatchChannel(metaclass=CogABCMeta):
    """ABC with functionality for relaying users' messages to a certain channel."""

    @abstractmethod
    def __init__(
        self,
        bot: Bot,
        destination: int,
        webhook_id: int,
        api_endpoint: str,
        api_default_params: dict,
        logger: logging.Logger
    ) -> None:
        self.bot = bot

        self.destination = destination  # E.g., Channels.big_brother_logs
        self.webhook_id = webhook_id  # E.g.,  Webhooks.big_brother
        self.api_endpoint = api_endpoint  # E.g., 'bot/infractions'
        self.api_default_params = api_default_params  # E.g., {'active': 'true', 'type': 'watch'}
        self.log = logger  # Logger of the child cog for a correct name in the logs

        self._consume_task = None
        self.watched_users = defaultdict(dict)
        self.message_queue = defaultdict(lambda: defaultdict(deque))
        self.consumption_queue = {}
        self.retries = 5
        self.retry_delay = 10
        self.channel = None
        self.webhook = None
        self.message_history = MessageHistory()

        self._start = self.bot.loop.create_task(self.start_watchchannel())

    @property
    def modlog(self) -> ModLog:
        """Provides access to the ModLog cog for alert purposes."""
        return self.bot.get_cog("ModLog")

    @property
    def consuming_messages(self) -> bool:
        """Checks if a consumption task is currently running."""
        if self._consume_task is None:
            return False

        if self._consume_task.done():
            exc = self._consume_task.exception()
            if exc:
                self.log.exception(
                    "The message queue consume task has failed with:",
                    exc_info=exc
                )
            return False

        return True

    async def start_watchchannel(self) -> None:
        """Starts the watch channel by getting the channel, webhook, and user cache ready."""
        await self.bot.wait_until_guild_available()

        try:
            self.channel = await self.bot.fetch_channel(self.destination)
        except HTTPException:
            self.log.exception(f"Failed to retrieve the text channel with id `{self.destination}`")

        try:
            self.webhook = await self.bot.fetch_webhook(self.webhook_id)
        except discord.HTTPException:
            self.log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`")

        if self.channel is None or self.webhook is None:
            self.log.error("Failed to start the watch channel; unloading the cog.")

            message = textwrap.dedent(
                f"""
                An error occurred while loading the text channel or webhook.

                TextChannel: {"**Failed to load**" if self.channel is None else "Loaded successfully"}
                Webhook: {"**Failed to load**" if self.webhook is None else "Loaded successfully"}

                The Cog has been unloaded.
                """
            )

            await self.modlog.send_log_message(
                title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel",
                text=message,
                ping_everyone=True,
                icon_url=Icons.token_removed,
                colour=Color.red()
            )

            self.bot.remove_cog(self.__class__.__name__)
            return

        if not await self.fetch_user_cache():
            await self.modlog.send_log_message(
                title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel",
                text="Could not retrieve the list of watched users from the API and messages will not be relayed.",
                ping_everyone=True,
                icon_url=Icons.token_removed,
                colour=Color.red()
            )

    async def fetch_user_cache(self) -> bool:
        """
        Fetches watched users from the API and updates the watched user cache accordingly.

        This function returns `True` if the update succeeded.
        """
        try:
            data = await self.bot.api_client.get(self.api_endpoint, params=self.api_default_params)
        except ResponseCodeError as err:
            self.log.exception("Failed to fetch the watched users from the API", exc_info=err)
            return False

        self.watched_users = defaultdict(dict)

        for entry in data:
            user_id = entry.pop('user')
            self.watched_users[user_id] = entry

        return True

    @Cog.listener()
    async def on_message(self, msg: Message) -> None:
        """Queues up messages sent by watched users."""
        if msg.author.id in self.watched_users:
            if not self.consuming_messages:
                self._consume_task = self.bot.loop.create_task(self.consume_messages())

            self.log.trace(f"Received message: {msg.content} ({len(msg.attachments)} attachments)")
            self.message_queue[msg.author.id][msg.channel.id].append(msg)

    async def consume_messages(self, delay_consumption: bool = True) -> None:
        """Consumes the message queues to log watched users' messages."""
        if delay_consumption:
            self.log.trace(f"Sleeping {BigBrotherConfig.log_delay} seconds before consuming message queue")
            await asyncio.sleep(BigBrotherConfig.log_delay)

        self.log.trace("Started consuming the message queue")

        # If the previous consumption Task failed, first consume the existing comsumption_queue
        if not self.consumption_queue:
            self.consumption_queue = self.message_queue.copy()
            self.message_queue.clear()

        for user_channel_queues in self.consumption_queue.values():
            for channel_queue in user_channel_queues.values():
                while channel_queue:
                    msg = channel_queue.popleft()

                    self.log.trace(f"Consuming message {msg.id} ({len(msg.attachments)} attachments)")
                    await self.relay_message(msg)

        self.consumption_queue.clear()

        if self.message_queue:
            self.log.trace("Channel queue not empty: Continuing consuming queues")
            self._consume_task = self.bot.loop.create_task(self.consume_messages(delay_consumption=False))
        else:
            self.log.trace("Done consuming messages.")

    async def webhook_send(
        self,
        content: Optional[str] = None,
        username: Optional[str] = None,
        avatar_url: Optional[str] = None,
        embed: Optional[Embed] = None,
    ) -> None:
        """Sends a message to the webhook with the specified kwargs."""
        username = messages.sub_clyde(username)
        try:
            await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed)
        except discord.HTTPException as exc:
            self.log.exception(
                "Failed to send a message to the webhook",
                exc_info=exc
            )

    async def relay_message(self, msg: Message) -> None:
        """Relays the message to the relevant watch channel."""
        limit = BigBrotherConfig.header_message_limit

        if (
            msg.author.id != self.message_history.last_author
            or msg.channel.id != self.message_history.last_channel
            or self.message_history.message_count >= limit
        ):
            self.message_history = MessageHistory(last_author=msg.author.id, last_channel=msg.channel.id)

            await self.send_header(msg)

        cleaned_content = msg.clean_content

        if cleaned_content:
            # Put all non-media URLs in a code block to prevent embeds
            media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")}
            for url in URL_RE.findall(cleaned_content):
                if url not in media_urls:
                    cleaned_content = cleaned_content.replace(url, f"`{url}`")
            await self.webhook_send(
                cleaned_content,
                username=msg.author.display_name,
                avatar_url=msg.author.avatar_url
            )

        if msg.attachments:
            try:
                await messages.send_attachments(msg, self.webhook)
            except (errors.Forbidden, errors.NotFound):
                e = Embed(
                    description=":x: **This message contained an attachment, but it could not be retrieved**",
                    color=Color.red()
                )
                await self.webhook_send(
                    embed=e,
                    username=msg.author.display_name,
                    avatar_url=msg.author.avatar_url
                )
            except discord.HTTPException as exc:
                self.log.exception(
                    "Failed to send an attachment to the webhook",
                    exc_info=exc
                )

        self.message_history.message_count += 1

    async def send_header(self, msg: Message) -> None:
        """Sends a header embed with information about the relayed messages to the watch channel."""
        user_id = msg.author.id

        guild = self.bot.get_guild(GuildConfig.id)
        actor = guild.get_member(self.watched_users[user_id]['actor'])
        actor = actor.display_name if actor else self.watched_users[user_id]['actor']

        inserted_at = self.watched_users[user_id]['inserted_at']
        time_delta = self._get_time_delta(inserted_at)

        reason = self.watched_users[user_id]['reason']

        if isinstance(msg.channel, DMChannel):
            # If a watched user DMs the bot there won't be a channel name or jump URL
            # This could technically include a GroupChannel but bot's can't be in those
            message_jump = "via DM"
        else:
            message_jump = f"in [#{msg.channel.name}]({msg.jump_url})"

        footer = f"Added {time_delta} by {actor} | Reason: {reason}"
        embed = Embed(description=f"{msg.author.mention} {message_jump}")
        embed.set_footer(text=textwrap.shorten(footer, width=128, placeholder="..."))

        await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url)

    async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None:
        """
        Gives an overview of the watched user list for this channel.

        The optional kwarg `update_cache` specifies whether the cache should
        be refreshed by polling the API.
        """
        if update_cache:
            if not await self.fetch_user_cache():
                await ctx.send(f":x: Failed to update {self.__class__.__name__} user cache, serving from cache")
                update_cache = False

        lines = []
        for user_id, user_data in self.watched_users.items():
            inserted_at = user_data['inserted_at']
            time_delta = self._get_time_delta(inserted_at)
            lines.append(f"• <@{user_id}> (added {time_delta})")

        lines = lines or ("There's nothing here yet.",)
        embed = Embed(
            title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})",
            color=Color.blue()
        )
        await LinePaginator.paginate(lines, ctx, embed, empty=False)

    @staticmethod
    def _get_time_delta(time_string: str) -> str:
        """Returns the time in human-readable time delta format."""
        date_time = dateutil.parser.isoparse(time_string).replace(tzinfo=None)
        time_delta = time_since(date_time, precision="minutes", max_units=1)

        return time_delta

    def _remove_user(self, user_id: int) -> None:
        """Removes a user from a watch channel."""
        self.watched_users.pop(user_id, None)
        self.message_queue.pop(user_id, None)
        self.consumption_queue.pop(user_id, None)

    def cog_unload(self) -> None:
        """Takes care of unloading the cog and canceling the consumption task."""
        self.log.trace("Unloading the cog")
        if self._consume_task and not self._consume_task.done():
            self._consume_task.cancel()
            try:
                self._consume_task.result()
            except asyncio.CancelledError as e:
                self.log.exception(
                    "The consume task was canceled. Messages may be lost.",
                    exc_info=e
                )