from __future__ import annotations

import asyncio
import datetime
import functools

from typing import Any, Callable, Coroutine, TypeVar

import discord
from discord import ui, app_commands

from lgrez import commons
from lgrez.blocs import tools


class _DiscordJourneyView(ui.View):
    def __init__(self, journey: DiscordJourney, timeout: float | None = None):
        self.journey = journey
        super().__init__(timeout=timeout)

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return await self.journey._interaction_check(interaction)

    async def on_error(self, _interaction: discord.Interaction, error: Exception, _item: ui.Item) -> None:
        raise error


class _DiscordJourneyModal(ui.Modal):
    def __init__(self, journey: DiscordJourney, title: str, timeout: float | None = None):
        self.journey = journey
        super().__init__(title=title, timeout=timeout)

    async def on_submit(self, interaction: discord.Interaction) -> None:
        self.journey.interaction = interaction
        self.stop()

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return await self.journey._interaction_check(interaction)

    async def on_error(self, _interaction: discord.Interaction, error: Exception, _item: ui.Item) -> None:
        raise error


def _create_button(input: ui.Button | str | discord.Emoji) -> ui.Button:
    if isinstance(input, ui.Button):
        if input.label:
            input.label = input.label[:80]
        return input
    if isinstance(input, discord.Emoji):
        return ui.Button(emoji=input)
    if len(input) <= 2 and not input.isascii():
        return ui.Button(emoji=input)
    else:
        return ui.Button(label=str(input)[:80])


def _create_select_option(input: discord.SelectOption | str) -> discord.SelectOption:
    if isinstance(input, discord.SelectOption):
        return input
    return discord.SelectOption(label=input)


_Key = TypeVar("_Key")


class DiscordJourney:
    """Home-made protocol to make use of interactions easy when writing application commands.

    Args:
        interaction: The interaction that triggered the command.
        ephemeral: If ``True``, all messages are not public and shown only to the interaction triggerer.
        author_only: The only member allowed to interact ; if not passed, everyone can interact.
        timeout: The delay after which the interaction expires, in seconds.
    """

    RUNNING_INTERACTION: discord.Interaction = None
    TIMEOUT_DELAY: float = 2.0

    _catch_next_command_channel: discord.TextChannel | None = None
    _catch_next_command_member: discord.Member | None = None
    _catch_next_command_coro: Coroutine
    _tasks = set()

    def __init__(
        self,
        interaction: discord.Interaction,
        *,
        ephemeral: bool = False,
        author_only: discord.Member | None = None,
        timeout: float | None = None,
        command_author: discord.Member | None = None,
    ) -> None:
        self._thinking = True

        self.interaction = interaction
        self.ephemeral = ephemeral
        self.author_only = author_only
        self.timeout = timeout

        self.member = interaction.user
        if not isinstance(self.member, discord.Member):
            raise ValueError(f"Interaction user is not a guild member (type {type(self.member)})")
        self.command_author = command_author or self.member

        self.channel: discord.TextChannel = interaction.channel
        self.created_at: datetime.datetime = interaction.created_at

        self._stopped: bool = False

    @property
    def interaction(self) -> discord.Interaction:
        """The active interaction of this journey."""
        return self._interaction

    @interaction.setter
    def interaction(self, new_interaction: discord.Interaction) -> None:
        if not isinstance(new_interaction, discord.Interaction):
            raise TypeError(f"DiscordJourney.interaction must be a discord.Interaction! (got {type(new_interaction)})")
        self._interaction = new_interaction
        if self._thinking:
            asyncio.get_running_loop().call_later(self.TIMEOUT_DELAY, self._timeout_interaction, self.interaction)

    def _timeout_interaction(self, interaction: discord.Interaction) -> None:
        async def _timeout():
            if not interaction.response.is_done():
                await interaction.response.defer(thinking=True, ephemeral=self.ephemeral)

        task = asyncio.create_task(_timeout())
        self._tasks.add(task)  # Cf. https://docs.python.org/fr/3/library/asyncio-task.html#asyncio.create_task
        task.add_done_callback(self._tasks.discard)

    def last_interaction(self):
        self._thinking = False

    async def _send_message(
        self,
        content: str = "",
        view: ui.View | None = None,
        code: bool = False,
        prefix: str = "",
        ephemeral: bool = False,
        **kwargs,
    ) -> list[discord.Message]:
        return await tools.send_blocs(
            self.interaction,
            content,
            code=code,
            prefix=prefix,
            view=view,
            ephemeral=ephemeral or self.ephemeral,
            author=self.command_author,
            **kwargs,
        )

    async def send(self, content: str = "", code: bool = False, prefix: str = "", **kwargs) -> list[discord.Message]:
        """Send a message in this journey.

        Args:
            content: The textual contents to send.
            code: Whether to send the messages as code blocks.
            prefix: Is ``code`` is ``True``, optional text to send before the code blocks.
            kwargs: Passed to ``tools.send_blocs``.

        Returns:
            The messages posted.
        """
        return await self._send_message(content, code=code, prefix=prefix, **kwargs)

    def _check_state(self) -> None:
        if self._stopped:
            raise RuntimeError("Journey is stopped")

    async def _interaction_check(self, interaction: discord.Interaction) -> bool:
        if self.author_only and self.author_only != interaction.user:
            return False
        return True

    async def __aenter__(self):
        DiscordJourney.RUNNING_INTERACTION = None
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        if not self.interaction.response.is_done():
            if exc_type:
                DiscordJourney.RUNNING_INTERACTION = self.interaction
            else:
                try:
                    await self.interaction.response.defer()
                except discord.HTTPException:
                    pass
        self._stopped = True

    @staticmethod
    def _clicked_button_cleanup(button: ui.Button, view: ui.View) -> None:
        button.disabled = True

    @staticmethod
    def _not_clicked_button_cleanup(button: ui.Button, view: ui.View) -> None:
        view.remove_item(button)

    async def buttons(
        self,
        content: str,
        buttons: dict[_Key, ui.Button | str | discord.Emoji],
        clicked_button_cleanup: Callable[[ui.Button, ui.View], None] = _clicked_button_cleanup,
        not_clicked_button_cleanup: Callable[[ui.Button, ui.View], None] = _not_clicked_button_cleanup,
        **kwargs,
    ) -> _Key:
        """Send a message with buttons and wait for a click on one of them.

        Args:
            content: The message to send.
            buttons: The buttons to show, indexed by any hashable key, described either with a complete
                ``discord.ui.Button``, a simple label or an emoji.
            clicked_button_cleanup: What to do to the clicked button. Marks it disabled by default.
            not_clicked_button_cleanup: What to do to other buttons. Delete them by default.
            kwargs: Passed to ``journey.send``.

        Returns:
            The key index of the clicked button.
        """
        self._check_state()
        view = _DiscordJourneyView(self, timeout=self.timeout)
        clicked_button = None

        def _get_button_callback(button: ui.Button) -> Callable[[discord.Interaction], Coroutine]:
            async def _callback(interaction: discord.Interaction):
                nonlocal clicked_button
                self.interaction = interaction
                clicked_button = button
                view.stop()

            return _callback

        for key, button in buttons.items():
            button = _create_button(button)
            button.callback = _get_button_callback(button)
            view.add_item(button)
            buttons[key] = button

        *_, message = await self._send_message(content, view, **kwargs)  # Show buttons to user
        try:
            timeout = await view.wait()  # Wait for user click
        except asyncio.CancelledError:
            pass  # Clean view when cancelled
        else:
            if timeout:
                raise RuntimeError("Journey timeout")
        finally:
            # Clean view
            clicked_key = None
            for key, button in buttons.items():
                if button == clicked_button:
                    clicked_key = key
                    clicked_button_cleanup(button, view)
                else:
                    not_clicked_button_cleanup(button, view)
            await message.edit(view=view)

        return clicked_key

    async def yes_no(self, content: str, yes_emoji="✅", no_emoji="❌", **kwargs) -> bool:
        """Send a message with positive and negative buttons and wait for a click on one of them.

        Args:
            content: The message to send.
            yes_emoji: The emoji of the positive button.
            no_emoji: The emoji of the negative button.
            kwargs: Passed to ``journey.buttons``.

        Returns:
            The key index of the clicked button.
        """
        buttons = {
            True: ui.Button(emoji=yes_emoji, style=discord.ButtonStyle.success),
            False: ui.Button(emoji=no_emoji, style=discord.ButtonStyle.secondary),
        }
        return await self.buttons(content, buttons, **kwargs)

    async def ok_cancel(self, content: str, ok_emoji="✅", cancel_emoji="❌", **kwargs) -> None:
        """Send a message with positive and negative buttons, aborts command execution if the latter is clicked.

        Args:
            content: The message to send.
            ok_emoji: The emoji of the positive button.
            cancel_emoji: The emoji of the negative button.
            kwargs: Passed to ``journey.buttons``.

        Raises:
            commons.CommandAbortedError: If the cancel button is clicked.
        """
        ok_button = ui.Button(emoji=ok_emoji, style=discord.ButtonStyle.success)
        cancel_button = ui.Button(emoji=cancel_emoji, style=discord.ButtonStyle.secondary)

        def clicked_button_cleanup(button: ui.Button, view: ui.View) -> None:
            self._clicked_button_cleanup(button, view)
            if button == cancel_button:
                button.label = "Mission aborted."

        if not await self.buttons(
            content, {True: ok_button, False: cancel_button}, clicked_button_cleanup=clicked_button_cleanup, **kwargs
        ):
            await self.interaction.response.defer()
            raise commons.CommandAbortedError()

    async def select(
        self,
        content: str,
        options: dict[_Key, discord.SelectOption | str],
        placeholder: str | None = None,
        validate: str | None = None,
    ) -> _Key:
        """Send a message with a select menu and wait for one choice to be picked.

        Args:
            content: The message to send.
            options: The options of the select menu, indexed by any hashable key, described either with a
                complete ``discord.ui.SelectOption`` or a simple label.
            placeholder: Optional placeholder text, the first text is selected if not passed.
            validate: If passed, add a button next to the select menu with this label, to click on
                to validate the choice.

        Returns:
            The key index of the clicked button.
        """
        self._check_state()
        view = _DiscordJourneyView(self, timeout=self.timeout)

        options = {key: _create_select_option(option) for key, option in options.items()}
        select = ui.Select(options=options.values(), placeholder=placeholder)
        view.add_item(select)

        async def _callback(interaction: discord.Interaction):
            self.interaction = interaction
            view.stop()

        if validate:
            button = ui.Button(label=validate)
            view.add_item(button)
            button.callback = _callback

            async def _noop_callback(interaction: discord.Interaction):
                await interaction.response.defer()

            select.callback = _noop_callback
        else:
            select.callback = _callback

        *_, message = await self._send_message(content, view)  # Show select to user
        try:
            timeout = await view.wait()  # Wait for user click
        except asyncio.CancelledError:
            pass  # Clean view and exit
        else:
            if timeout:
                raise RuntimeError("Journey timeout")

        # Clean view
        if select.values:
            [value] = select.values
            key, option = next((key, option) for key, option in options.items() if option.value == value)
            option.default = True
            select.options = [option]
            select.disabled = True
        else:
            view.remove_item(select)
        if validate:
            view.remove_item(button)
        await message.edit(view=view)

        return key

    async def modal(self, title: str, *items: discord.ui.TextInput | str) -> list[str]:
        """Show a modal and wait for it's completion.

        Args:
            title: The modal title.
            items: The fields to include in the modal, described either with a complete
                ``discord.ui.TextInput`` or a simple label.

        Returns:
            The list of values filled in the fields, in the same order.
        """
        self._check_state()
        modal = _DiscordJourneyModal(self, title=title, timeout=self.timeout)

        inputs = [ui.TextInput(label=item) if isinstance(item, str) else item for item in items]
        for input in inputs:
            modal.add_item(input)

        if self.interaction.response.is_done():
            raise RuntimeError("No active interaction, cannot send modal")

        await self.interaction.response.send_modal(modal)  # Show modal to user
        timeout = await modal.wait()  # Wait for modal completion
        if timeout:
            raise RuntimeError("Journey timeout")
        self._check_state()

        return [input.value for input in inputs]

    async def command_parameters_modal(
        self, command: app_commands.Command, title_prefix: str = ""
    ) -> tuple[Callable[[DiscordJourney], None], dict[str, Any]]:
        """Open a modal allowing to chose parameters values for a command."""
        if command.parameters:
            values = await self.modal(
                f"{title_prefix} /{command.qualified_name}",
                *[
                    ui.TextInput(label=param.display_name, placeholder=param.description, required=param.required)
                    for param in command.parameters
                ],
            )
        else:
            values = []

        callback = command._callback._callable
        parameters = {
            parameter.name: await parameter._Parameter__parent._annotation.transform(self.interaction, value)
            for parameter, value in zip(command.parameters, values)
        }
        return callback, parameters

    async def _catch_next_command(self) -> tuple[discord.Interaction, Callable[[DiscordJourney], Coroutine]]:
        class _Awaitable:
            # Oh, well... let's say it just work?
            # If you really want to dig, begin with https://stackoverflow.com/a/60118660
            def __await__(self):
                while True:
                    result = yield
                    if result:
                        yield
                        return result

        async def _catch_next_command():
            return await _Awaitable()

        self._catch_next_command_channel = self.channel
        self._catch_next_command_member = self.member
        self._catch_next_command_coro = _catch_next_command()

        try:
            return await self._catch_next_command_coro
        finally:  # Stop catching
            self._catch_next_command_channel = None
            self._catch_next_command_member = None
            self._catch_next_command_coro = None

    async def catch_next_command(
        self, next_command_message: str
    ) -> tuple[discord.Interaction, Callable[[DiscordJourney], Coroutine]]:
        """[WIP - does not work?] Catch the next command in the channel (or abort it with a cancel button).

        Args:
            next_command_message: The message describing which command to send next.

        Raises:
            commons.CommandAbortedError: If the cancel button is clicked, or if an exception occurs.

        Returns:
            The interaction that triggered the catched command, and a coroutine function to run it.
        """
        catch_task = asyncio.Task(self._catch_next_command())
        cancel_task = asyncio.Task(
            self.buttons(
                f"{next_command_message} :arrow_down:",
                {"cancel": discord.ui.Button(label="Annuler", emoji="❌")},
                ephemeral=True,
            )
        )
        done, _ = await asyncio.wait([catch_task, cancel_task], return_when=asyncio.FIRST_COMPLETED)

        match list(done):
            case [task] if task == catch_task:
                cancel_task.cancel()  # Remove cancel button
                return catch_task.result()
            case [task] if task == cancel_task:
                catch_task.cancel()  # Stop command catching
                raise commons.CommandAbortedError()
            case []:  # Exception in one of the coroutines
                catch_task.cancel()
                cancel_task.cancel()
                raise commons.CommandAbortedError()
            case _:
                raise RuntimeError(f"heu -> {done}")

    @classmethod
    def _journey_command(cls, callable):
        @functools.wraps(callable)
        async def new_callable(interaction: discord.Interaction, **kwargs):
            async def _run_command(_journey: DiscordJourney):  # Closure
                await callable(_journey, **kwargs)

            if (
                cls._catch_next_command_channel
                and interaction.channel == cls._catch_next_command_channel
                and interaction.user == cls._catch_next_command_member
            ):
                cls._catch_next_command_channel = None
                cls._catch_next_command_member = None
                cls._catch_next_command_coro.send((interaction, _run_command))
                return  # Do not execute command

            async with cls(interaction) as journey:
                await _run_command(journey)

        new_callable._callable = callable

        return new_callable


def journey_command(callable: Callable[[DiscordJourney], None]) -> Callable[[discord.Interaction], None]:
    """Decorator: add the Journey interface to a slash command.

    Replaces the ``interaction: discord.Interaction`` in the callable signature by ``journey: DiscordJourney``.
    """
    return DiscordJourney._journey_command(callable)


_CT = TypeVar("_CT", discord.Message, discord.Member)


def journey_context_menu(callable: Callable[[DiscordJourney, _CT], None]) -> Callable[[discord.Interaction, _CT], None]:
    """Decorator: add the Journey interface to a context menu command.

    Replaces the ``interaction: discord.Interaction`` in the callable signature by ``journey: DiscordJourney``.
    """

    @functools.wraps(callable)
    async def new_callable(interaction: discord.Interaction, arg: _CT):
        async with DiscordJourney(interaction, ephemeral=True) as journey:
            await callable(journey, arg)

    return new_callable
