
""" The :mod:`dubious.pory` module defines the main class that will act as the bot: :class:`Pory`.

    It also includes a special subclass, :class:`ConfiguredPory`, that makes per-guild (read: per-server) configuration easier. """


from __future__ import annotations

import dataclasses as dc
import json
import multiprocessing as mp
import traceback as tb
import types
import typing as t
import pprint

import flask
from nacl.exceptions import BadSignatureError
from nacl.signing import VerifyKey

from dubious import callback as cb
from dubious.discord import api, disc, req


class CommandFailure(Exception):
    """ This class is a front-facing :class:`Exception` class to be thrown when an error is made by a user of the bot.
        
        It will be caught by :class:`Pory` and its :func:`~CommandFailure.error_embed` method will be used to decorate an appropriate error message.
        
        Can be subclassed to alter the :func:`~CommandFailure.error_embed` method. """

    message: str
    def __init__(self, message: str) -> None:
        super().__init__()
        self.message = message

    def error_embed(self):
        """ Returns an :class:`.Embed` object which will be sent as the front-facing error message. """
        return api.Embed(color=0xFF3300, description=str(self.message))

def _match_response_type(response: api.InteractionCallbackData | None, ixn_type: api.InteractionType) -> api.InteractionCallbackType:
    match response:
        case api.ResponseMessage():
            return api.InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE
        case api.ResponseModal():
            return api.InteractionCallbackType.MODAL
    match ixn_type:
        case api.InteractionType.PING:
            return api.InteractionCallbackType.PONG
        case api.InteractionType.APPLICATION_COMMAND | api.InteractionType.MODAL_SUBMIT:
            return api.InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE
        case api.InteractionType.MESSAGE_COMPONENT:
            return api.InteractionCallbackType.DEFERRED_UPDATE_MESSAGE
    raise Exception()

@dc.dataclass
class Pory:
    """ This class defines bot functionality.

        Once an instance is created, commands can be added using its :attr:`.on_command` :class:`.CommandGroup` as a decorator around the desired callback function.

        Also contains various utility functions for things like sending messages to specific channels and sending followup responses to interactions. """

    app_id: dc.InitVar[str]
    """ The application ID shown in the ``General Information`` section of the `application's settings <https://discord.com/developers/applications>`_. This value is converted into a :class:`.Snowflake` object and stored in :attr:`.id`. """
    public_key: str
    """ The public key shown in the ``General Information`` section of the `application's settings <https://discord.com/developers/applications>`_. """
    token: str
    """ The token generated by the *Reset Token* option in the ``Bot`` section of the `application's settings <https://discord.com/developers/applications>`_. """

    id: disc.Snowflake = dc.field(init=False)
    """ The application ID for the bot as a :class:`.Snowflake`."""

    def __post_init__(self, app_id: str):
        self.id = disc.Snowflake(app_id)

    on_command: cb.CommandGroup = dc.field(default_factory=cb.CommandGroup.new, init=False)
    """ :class:`.CommandGroup` for callbacks to register as `Slash Commands <https://discord.com/developers/docs/interactions/application-commands#slash-commands>`_. """

    on_message: cb.CommandGroup = dc.field(default_factory=cb.CommandGroup.new, init=False)
    """ :class:`.CommandGroup` for callbacks to register as `Message Commands <https://discord.com/developers/docs/interactions/application-commands#message-commands>`_. """

    on_user: cb.CommandGroup = dc.field(default_factory=cb.CommandGroup.new, init=False)
    """ :class:`.CommandGroup` for callbacks to register as `User Commands <https://discord.com/developers/docs/interactions/application-commands#user-commands>`_. """

    on_modal: cb.CallbackGroup = dc.field(default_factory=cb.CallbackGroup, init=False)
    """ :class:`.CallbackGroup` for internal callbacks that corespond to Modal (popup) interactions. """

    on_component: cb.CallbackGroup = dc.field(default_factory=cb.CallbackGroup, init=False)
    """ :class:`.CallbackGroup` for internal callbacks that corespond to `Message Component interactions <https://discord.com/developers/docs/interactions/message-components>`_. """
    
    def flask(self):
        """ The entry point for the app. Starts a :class:`~flask.Flask` instance and accepts ``POST`` requests to the ``/interactions`` endpoint. """

        app = flask.Flask(__name__)
        
        self._sync_commands()
        verify_key = VerifyKey(bytes.fromhex(self.public_key))

        @app.route("/")
        def index():
            return "why are you here"

        @app.route("/interactions", methods=["POST"])
        def _():
            signature = flask.request.headers["X-Signature-Ed25519"]
            timestamp = flask.request.headers["X-Signature-Timestamp"]
            body = flask.request.data.decode("utf-8")

            try:
                verify_key.verify(f"{timestamp}{body}".encode(), bytes.fromhex(signature))
            except BadSignatureError:
                flask.abort(401, "Invalid request signature")

            if not flask.request.json:
                return flask.abort(405, "Bad request")
            ixn_json = flask.request.json
            ixn = disc.cast(api.Interaction, ixn_json)
            res = self._handle(ixn)
            return flask.jsonify(dc.asdict(res))

        return app

    def _sync_commands(self):
        """ Collects the bot's Discord-registered commands with an API request and compares each of them with the commands defined in code with the bot, updating as needed. """

        # Collect local commands
        prepared_on_command = self.on_command.get_commands(as_type=api.ApplicationCommandType.CHAT_INPUT)
        prepared_on_message = self.on_message.get_commands(as_type=api.ApplicationCommandType.MESSAGE)
        prepared_on_user = self.on_user.get_commands(as_type=api.ApplicationCommandType.USER)

        # Collect registered commands
        registered_commands: list[api.ApplicationCommand] = []
        guilds = req.GetCurrentUserGuilds().do_with(self.token)
        for guild in guilds:
            registered_commands += req.GetGuildApplicationCommands(self.id, guild.id).do_with(self.token)
        registered_commands += req.GetGlobalApplicationCommands(self.id).do_with(self.token)

        # Compare
        for registered in registered_commands:
            match registered.type:
                case api.ApplicationCommandType.CHAT_INPUT:
                    prepared = prepared_on_command
                case api.ApplicationCommandType.MESSAGE:
                    prepared = prepared_on_message
                case api.ApplicationCommandType.USER:
                    prepared = prepared_on_user
                case _:
                    raise Exception(f"While syncing commands, encountered an unknown ApplicationCommandType: {registered.type}")
            self._sync_if_exists(registered, prepared)

        # Create local commands in Discord if they weren't found
        for to_register in (
            list(prepared_on_command.values()) +
            list(prepared_on_message.values()) +
            list(prepared_on_user.values())
        ):
            self._create_command(to_register)

    def _sync_if_exists(self, registered: api.ApplicationCommand, prepared: dict[str, cb.Command]):
        """ Compares a pre-existing :class:`.api.ApplicationCommand` retrieved from the Discord API, to a collection of local yet-to-be-registered :class:`.Command`s.
        
            If the pre-existing command matches one of the local commands, check if they match, and if they don't, update the command with an API request.
            
            If the pre-existing command doesn't match, delete the command with an API request.
            
            This function alters the ``prepared`` argument, popping items which have been found. """

        if not registered.name in prepared:
            if not registered.guild_id:
                print(f"Deleting registered command: {registered.name}")
                req.DeleteGlobalApplicationCommand(self.id, registered.id).do_with(self.token)
            else:
                print(f"Deleting registered command in guild {registered.guild_id}: {registered.name}")
                req.DeleteGuildApplicationCommand(self.id, registered.guild_id, registered.id).do_with(self.token)

        elif registered.guild_id == prepared[registered.name].guild_id:
            if not prepared[registered.name]._compare_with(registered):
                if not registered.guild_id:
                    print(f"Updating registered command: {registered.name}")
                    req.EditGlobalApplicationCommand(self.id, registered.id, disc.cast(req.EditGlobalApplicationCommand.Form, registered))
                else:
                    print(f"Updating registered command in guild {registered.guild_id}: {registered.name}")
                    req.EditGuildApplicationCommand(self.id, registered.guild_id, registered.id, disc.cast(req.EditGuildApplicationCommand.Form, registered))
            prepared.pop(registered.name)

    def _create_command(self, to_register: cb.Command):
        """ Sends :class:`dubious.discord.req.CreateGlobalApplicationCommand` requests to the Discord API to register a new command. """

        if not to_register.guild_id:
            print(f"Creating new command: {to_register.name}")
            req.CreateGlobalApplicationCommand(self.id, form=disc.cast(req.CreateGlobalApplicationCommand.Form, to_register)).do_with(self.token)
        else:
            print(f"Creating new command in guild {to_register.guild_id}: {to_register.name}")
            req.CreateGuildApplicationCommand(self.id, to_register.guild_id, form=disc.cast(req.CreateGuildApplicationCommand.Form, to_register)).do_with(self.token)

    def _handle(self, ixn: api.Interaction):
        """ Handles all incoming interaction POSTs to the Flask server, after their data have been cast to an :class:`.Interaction`.
            
            For :class:`.Interaction`\\ s that match a callback defined in the :class:`Pory`'s callbacks, it gets the coresponding callback and runs it. """

        match ixn:
            case api.Interaction(
                type=api.InteractionType.PING as ixn_type,
                data=None as data
            ):
                callback = None

            case api.Interaction(
                type=api.InteractionType.APPLICATION_COMMAND as ixn_type,
                data=api.ApplicationCommandData(name=command_name) as data
            ):
                callback = self._get_callback_for_command(command_name)

            case api.Interaction(
                type=api.InteractionType.MODAL_SUBMIT as ixn_type,
                data=api.ModalSubmitData(custom_id=modal_id) as data
            ):
                callback = self._get_callback_for_modal(modal_id)

            case api.Interaction(
                type=api.InteractionType.MESSAGE_COMPONENT as ixn_type,
                data=api.MessageComponentData(custom_id=component_id) as data
            ):
                callback = self._get_callback_for_component(component_id)

            case _:
                raise Exception(f"Tried to handle an unknown type of interaction. Interaction is as follows:\nf{pprint.pformat(ixn, compact=True)}")

        response = None
        if isinstance(callback, cb.Callback):
            try:
                response = callback.do(ixn, data)
            except CommandFailure as failure:
                # wrap errors made by users of the bot in an embed
                response = api.ResponseMessage(
                    embeds=[failure.error_embed()],
                    flags=api.MessageFlag.EPHEMERAL
                )
            except Exception:
                # wrap errors made in code in a simple embed, then print the error and continue
                response = api.ResponseMessage(
                    embeds=[CommandFailure(f"When running this command, an exception occurred with the bot code. Let the bot's creator know.").error_embed()],
                    flags=api.MessageFlag.EPHEMERAL
                )
                tb.print_exc()
        return api.InteractionResponse(_match_response_type(response, ixn_type), data=response)
    
    def _get_callback_for_command(self, command_name: str):
        return next(filter(
            lambda on: command_name in on._options,
            (self.on_command, self.on_message, self.on_user)
        ))._options[command_name]
    
    def _get_callback_for_modal(self, modal_id: str):
        return self.on_modal._options[modal_id]
    
    def _get_callback_for_component(self, component_id: str):
        return self.on_component._options[component_id]

    def create_cancel_button(self, ixn_id: api.Snowflake, token: str, in_progress_message: str, button_text: str):
        lock = mp.Event()
        @self.on_component(
            name=ixn_id
        )
        def _():
            lock.set()
            self.delete_response(token)
            self.on_component._options.pop(ixn_id)

        return api.ResponseMessage(
            content=in_progress_message,
            components=[api.ActionRow([
                api.Button(
                    api.ButtonStyle.DANGER,
                    label=button_text,
                    custom_id=ixn_id
                )
            ])]
        ), lock
    
    def send_followup(self, token: str, followup: req.ExecuteWebhook.Form):
        """ Using an interaction message token, sends a followup response after the initial response.
        
            Shouldn't be used before an :attr:`on_command` callback returns / yields. """
        
        return req.ExecuteWebhook(self.id, token, form=followup).do_with(self.token)

    def edit_response(self, token: str, edit: req.EditWebhookMessage.Form, response_id: str="@original"):
        """ Using an interaction message token, edits one of the responses (specified by ID) to that interaction. Defaults to the initial response. """

        return req.EditWebhookMessage(self.id, token, response_id, form=edit).do_with(self.token)

    def delete_response(self, token: str, response_id: str="@original"):
        """ Using an interaction message token, deletes one of the responses (specified by ID) to that interaction. Defaults to the initial response. """

        req.DeleteWebhookMessage(self.id, token, response_id).do_with(self.token)
    
    def send(self, channel_id: api.Snowflake, message: req.CreateMessage.Form):
        """ Sends a standalone message.
        
            The argument ``message`` is a :class:`.req.CreateMessage.Form`. """

        return req.CreateMessage(channel_id, message).do_with(self.token)
    
    def edit(self, channel_id: api.Snowflake, message_id: api.Snowflake, message: req.EditMessage.Form):
        """ Edits a standalone message.
        
            The argument ``message`` is a :class:`.req.EditMessage.Form`. """

        return req.EditMessage(channel_id, message_id, message).do_with(self.token)

    def delete(self, channel_id: api.Snowflake, message_id: api.Snowflake):
        """ Deletes a standalone message. """

        req.DeleteMessage(channel_id, message_id).do_with(self.token)

    def history(self, channel_id: api.Snowflake, limit: int=200, chunk_size: int=50):
        """ Iterates over a channel's messages, starting from the earliest message.
        
            The number of messages retrieved depends on ``limit``. The :class:`.req.GetChannelMessages` request will retrieve a chunk of messages from the channel, the size of which is determined by ``chunk_size``, until the limit is reached. A higher chunk size means less total requests, but those requests will take more time. """

        message: api.Message | None = None
        # limit goes down per message retrieved
        while limit:
            # can't request more than the limit - if the chunk_size would go over the limit, set it instead to the limit
            current_chunk_size = chunk_size if limit > chunk_size else limit
            messages = req.GetChannelMessages(channel_id, req.GetChannelMessages.Query(
                before = message.id if message else None,
                limit = current_chunk_size
            )).do_with(self.token)
            while messages:
                message = messages.pop(0)
                yield message
                limit -= 1
                current_chunk_size -= 1
            if current_chunk_size:
                limit = 0
    
    def send_error(self, channel_id: api.Snowflake, error_text: str):
        """ Sends an error embed with the given text to the given channel. """

        error = CommandFailure(error_text)
        self.send(channel_id, req.CreateMessage.Form(embeds=[error.error_embed()]))

class ChannelSnowflake(api.Snowflake):
    """ This class only serves to differentiate the type of :class:`~.disc.Snowflake` that's stored in a :attr:`.ConfiguredPory.config_model`'s field. Represents the ID of a channel in the guild. """

class RoleSnowflake(api.Snowflake):
    """ This class only serves to differentiate the type of :class`~.disc.Snowflake` that's stored in a :attr:`.ConfiguredPory.config_model`'s field. Represents the ID of a role in the guild. """

t_ConfigType = t.TypeVar("t_ConfigType")
""" Represents the config dataclass used with :class:`.ConfiguredPory`. """

@dc.dataclass
class ConfiguredPory(Pory, t.Generic[t_ConfigType]):
    """ This class adds to :class:`Pory` by wrapping a :attr:`.config_model` dataclass, storing it per guild as json in a given location, and allowing the bot to do things per guild based on that guild's dataclass. A guild's config dataclass instance can be retrieved using :meth:`.get_current_config`. More info on defining a configuration dataclass can be found in :attr:`.config_model`.
    
        It automatically registers a /config slash command that users with Manage Channel permissions can use to configure the dataclass instance for the guild they're in. """

    config_path: str
    """ The path to where the config file will be stored. """
    config_model: type[t_ConfigType]
    """ The dataclass with which to structure the bot.
    
        The dataclass won't recieve any arguments on instantiation - each of its fields must have a default value (or default_factory for fields that store lists).

        :class:`.ConfiguredPory` will leaf through the dataclass's fields to find each field that stores one of the above subclasses of :class:`~.disc.Snowflake`: :class:`.ChannelSnowflake` and :class:`.RoleSnowflake`. It will also find fields that store lists of those snowflake subclasses. For each of the snowflake fields, a subcommand group will be added to the /config slash command that coresponds to that field. Subcommands are then added to that group that will alter that field when used. """

    guild_configs: dict[api.Snowflake, t_ConfigType] = dc.field(default_factory=dict)
    """ The json file, parsed into instances of :attr:`.config_model` per guild. """

    def load(self):
        """ Reads the config file from disk. """
        with open(self.config_path, "r") as f:
            data = json.load(f)
        for guild_id in data:
            self.guild_configs[api.Snowflake(guild_id)] = disc.cast(self.config_model, data[guild_id])
    
    def write(self):
        """ Writes the current config for all guilds to the disk. """
        with open(self.config_path, "w") as f:
            json.dump(disc.uncast(self.guild_configs), f)

    def __post_init__(self, app_id: str):
        super().__post_init__(app_id)

        self.load()

        config = self.on_command.group(
            "config",
            "Configures a channel to do something related to this bot.",
            [api.Permission.MANAGE_CHANNELS]
        )
        
        for field in dc.fields(self.config_model):
            ftype_root = t.get_origin(field.type)
            if ftype_root:
                if not issubclass(ftype_root, (list, types.UnionType)): continue
                if not api.Snowflake in t.get_args(field.type): continue
            elif not field.type is api.Snowflake: continue
            setters = config.group(
                f"{field.name}",
                f"Configure a {'list of channels' if t.get_origin(field.type) else 'channel'} to be related to {field.name}."
            )
            
            # Had to move the function definitions to their own functions!
            # When the functions were defined in this method, the for loop would
            #  iterate past the field the function was meant to be defined for.
            # When the functions were called, they would use the variables in the
            #  scope of the loop's final iteration instead of the variables in the
            #  scope where they were defined.
            # Moving them to a scope that doesn't change when the for-loop changes
            #  fixes this (freezes the field.name, in other words).

            if t.get_origin(field.type) == list:
                setters(self._make_add(field.name))
                setters(self._make_remove(field.name))
            else:
                setters(self._make_set(field.name))
                setters(self._make_unset(field.name))

            
    def get_current_config(self, guild_id: api.Snowflake | None):
        """ Gets the :attr:`.config_model` instance for a specified guild. If ``guild_id`` is ``None``, raises an appropriate :class:`.CommandFailure`.
        
            This method is usually used inside of interaction callbacks. The ``guild_id`` can be retrieved by adding ``guild_id`` to the callback signature as a keyword-specific argument (i.e. an argument that comes after ``*,``). See :func:`~dubious.callback.do_callback` for more details. """

        if guild_id is None:
            raise CommandFailure("You can't use this command outside of a guild.")
        if not self.guild_configs.get(guild_id):
            self.guild_configs[guild_id] = self.config_model()
        return self.guild_configs[guild_id]

    def _make_add(self, name: str):
        def add(channel: api.Channel, *, guild_id: api.Snowflake | None):
            config = self.get_current_config(guild_id)
            channels = getattr(config, name)
            if not isinstance(channels, list):
                raise CommandFailure("Something went wrong: one of the assigned channel lists is a single value instead of a list. Let the bot creator know.")
            channels.append(channel.id)
            self.write()
            return api.ResponseMessage(content=f"Channel <#{channel.id}> added to {name} channels.")
        return add

    def _make_remove(self, name: str):
        def remove(channel: api.Channel, *, guild_id: api.Snowflake | None):
            config = self.get_current_config(guild_id)
            channels = getattr(config, name)
            if not isinstance(channels, list):
                raise CommandFailure("Something went wrong: one of the assigned channel lists is a single value instead of a list. Let the bot creator know.")
            if not channel.id in channels:
                raise CommandFailure(f"That channel isn't a part of the {name} channels.")
            channels.remove(channel.id)
            self.write()
            return api.ResponseMessage(content=f"Channel <#{channel.id}> removed from the {name} channels.")
        return remove
    
    def _make_set(self, name: str):
        def set(channel: api.Channel, *, guild_id: api.Snowflake | None):
            config = self.get_current_config(guild_id)
            setattr(config, name, channel.id)
            self.write()
            return api.ResponseMessage(content=f"Channel <#{channel.id}> has been set as the {name} channel.")
        return set

    def _make_unset(self, name: str):
        def unset(*, guild_id: api.Snowflake | None):
            config = self.get_current_config(guild_id)
            setattr(config, name, None)
            self.write()
            return api.ResponseMessage(content=f"The {name} channel has been set to None.")
        return unset

def get_message_link(guild_id: disc.Snowflake, message: api.Message):
    return f"https://discord.com/channels/{guild_id}/{message.channel_id}/{message.id}"