from __future__ import annotations
from typing import Union, Optional, AnyStr, Any
from collections.abc import Callable

import asyncio
import re
from functools import partial, wraps
from telethon import events, Button
from telethon.tl import types
from telethon.tl.patched import Message, MessageService
from telethon.tl.functions.bots import SetBotCommandsRequest
from telethon.tl.functions.channels import GetParticipantRequest
from telethon.errors import FloodError, MessageNotModifiedError, UserNotParticipantError, QueryIdInvalidError, \
    EntitiesTooLongError, MessageTooLongError

from src import env, log, db, locks
from src.i18n import i18n, ALL_LANGUAGES
from . import inner
from src.exceptions import UserBlockedErrors

logger = log.getLogger('RSStT.command')


# ANONYMOUS_ADMIN = 1087968824  # no need for MTProto, user_id will be `None` for anonymous admins


def parse_command(command: str, max_split: int = 0) -> list[AnyStr]:
    return re.split(r'\s+', command.strip(), maxsplit=max_split)


async def parse_command_get_sub_or_user_and_param(command: str,
                                                  user_id: int,
                                                  max_split: int = 0,
                                                  allow_setting_user_default: bool = False) \
        -> tuple[Optional[db.Sub], Optional[str]]:
    args = parse_command(command, max_split=max_split)
    sub_or_user = param = None
    if len(args) >= 1 and args[1] == 'default' and allow_setting_user_default:
        sub_or_user = await db.User.get_or_none(id=user_id)
    if len(args) >= 2 and args[1].isdecimal() and int(args[1]) >= 1:
        sub_id = int(args[1])
        sub_or_user = await db.Sub.get_or_none(id=sub_id, user_id=user_id)
    if len(args) > 2:
        param = args[2]
    return sub_or_user, param


def parse_callback_data_with_page(callback_data: bytes) -> tuple[str, int]:
    """
    callback data = command={params}[|{page}]

    :param callback_data: callback data
    :return: params, page
    """
    callback_data = callback_data.decode().strip()
    params_and_page = callback_data.split('|')
    params = params_and_page[0].split('=')[-1]
    page = int(params_and_page[1]) if len(params_and_page) > 1 else 1
    return params, page


def parse_customization_callback_data(callback_data: bytes) \
        -> tuple[Optional[int], Optional[str], Optional[Union[int, str]], int]:
    """
    callback data = command[={id}[,{action}[,{param}]]][|{page_number}] or command[={action}[,{param}]]

    :param callback_data: callback data
    :return: id, action, param
    """
    callback_data = callback_data.decode().strip()
    args = callback_data.split('|')
    page = int(args[1]) if len(args) > 1 else 1
    args = args[0].split('=', 1)
    if len(args) == 1:
        return None, None, None, page
    args = args[-1].split(',', 2)

    _id: Optional[int] = None
    if args[0].lstrip('-').isdecimal():
        _id = int(args[0])
        args = args[1:]
    elif len(args) >= 3:
        args = args[1:]
    action: Optional[str] = args[0] if len(args) >= 1 else None
    param: Optional[Union[int, str]] = args[1] if len(args) >= 2 else None
    if param and param.lstrip('-').isdecimal():
        param = int(param)

    return _id, action, param, page


async def respond_or_answer(event: Union[events.NewMessage.Event, Message,
                                         events.CallbackQuery.Event,
                                         events.InlineQuery.Event],
                            msg: str,
                            alert: bool = True,
                            cache_time: int = 120,
                            *args,
                            **kwargs):
    """
    Respond to a ``NewMessage`` event, or answer to an unanswered ``CallbackQuery`` event.

    :param event: a telethon Event object, NewMessage or CallbackQuery
    :param msg: the message to send
    :param alert: alert or not? (only for CallbackQuery)
    :param cache_time: cache the answer for how many seconds on the server side? (only for CallbackQuery)
    :param args: additional params (only for NewMessage)
    :param kwargs: additional params (only for NewMessage)
    """
    try:
        # noinspection PyProtectedMember
        if isinstance(event, events.CallbackQuery.Event) and not event._answered:
            # answering callback query is of a tolerant rate limit, no lock needed
            try:
                await event.answer(msg, alert=alert, cache_time=cache_time)
                return  # return if answering successfully
            except QueryIdInvalidError:  # callback query expired
                pass  # respond instead
        elif isinstance(event, events.InlineQuery.Event):
            # noinspection PyProtectedMember
            if event._answered:
                return
            await event.answer(switch_pm=msg, switch_pm_param=str(event.id), private=True)
            return  # return if answering successfully

        async with locks.user_flood_lock(event.chat_id):
            pass  # wait for flood wait

        await event.respond(msg, *args, **kwargs)
    except UserBlockedErrors:
        pass  # silently ignore


def command_gatekeeper(func: Optional[Callable] = None,
                       *,
                       only_manager: bool = False,
                       only_in_private_chat: bool = False,
                       allow_in_others_private_chat: bool = False,
                       allow_in_old_fashioned_groups: bool = False,
                       ignore_tg_lang: bool = False,
                       timeout: Optional[int] = 60,
                       quiet: bool = False):
    if func is None:
        return partial(command_gatekeeper,
                       only_manager=only_manager,
                       only_in_private_chat=only_in_private_chat,
                       allow_in_old_fashioned_groups=allow_in_old_fashioned_groups,
                       ignore_tg_lang=ignore_tg_lang,
                       timeout=timeout,
                       quiet=quiet)

    @wraps(func)
    async def wrapper(event: Union[events.NewMessage.Event, Message,
                                   events.CallbackQuery.Event,
                                   events.InlineQuery.Event,
                                   events.ChatAction.Event],
                      # Note: `events.ChatAction.Event` only have ChatGetter, do not have SenderGetter like others
                      *args, **kwargs):
        # placeholders
        lang = None
        command: Optional[str] = None
        sender: Optional[Union[types.User, types.Channel]] = None
        sender_fullname: Optional[str] = None
        chat_title: Optional[str] = None
        participant_type: Optional[str] = None
        sender_id: Optional[int] = None

        chat_id = event.chat_id
        flood_lock = locks.user_flood_lock(chat_id)
        pending_callbacks = locks.user_pending_callbacks(chat_id)
        is_callback = isinstance(event, events.CallbackQuery.Event)
        is_inline = isinstance(event, events.InlineQuery.Event)
        is_chat_action = isinstance(event, events.ChatAction.Event)

        def describe_user():
            chat_info = None
            if (chat_title or chat_id) and chat_id != sender_id:
                chat_info = f'{chat_title or chat_id}' + (f' ({chat_id})' if chat_title and chat_id else '')
            return f'{sender_fullname} ({sender_id}' \
                   + (f', {participant_type}' if participant_type and chat_info else '') \
                   + (f', {type(event.query.peer_type).__name__}' if is_inline else '') \
                   + ')' \
                   + (f' in {chat_info}' if chat_info else '')

        async def execute():
            callback_msg_id = event.message_id if is_callback else None
            log_level = log.DEBUG if quiet else log.INFO
            # skip if already executing a callback for this msg
            if callback_msg_id and callback_msg_id in pending_callbacks:
                logger.log(log_level,
                           f'Skipped {describe_user()} to use {command}: already executing a callback for this msg')
                await respond_or_answer(event, i18n[lang]['callback_already_running_prompt'], cache_time=0)
                raise events.StopPropagation

            if callback_msg_id:
                pending_callbacks.add(callback_msg_id)
            try:
                if lang_in_db is None:
                    await db.User.get_or_create(id=chat_id, lang='null')  # create the user if it doesn't exist
                logger.log(log_level, f'Allow {describe_user()} to use {command}')
                async with flood_lock:
                    pass  # wait for flood wait
                await asyncio.wait_for(func(event, *args, lang=lang, **kwargs), timeout=timeout)
            except asyncio.TimeoutError as _e:
                logger.error(f'Cancel {command} for {describe_user()} due to timeout ({timeout}s)', exc_info=_e)
                await respond_or_answer(event, 'ERROR: ' + i18n[lang]['operation_timeout_error'])
            finally:
                if callback_msg_id:
                    try:
                        pending_callbacks.remove(callback_msg_id)
                    except KeyError:
                        pass

        try:
            command = (event.raw_text
                       if hasattr(event, 'raw_text') and event.raw_text else
                       f'(Callback){event.data.decode()}'
                       if is_callback else
                       f'(Inline){event.text}'
                       if is_inline else
                       f'(ChatAction, {event.action_message and type(event.action_message.action).__name__})'
                       if is_chat_action else
                       '(no command, other message)')
            command_header = parse_command(command)[0]
            if command_header.startswith('/') and '@' in command_header:
                mention = command_header.split('@')[-1]
                if mention != env.bot_peer.username:
                    raise events.StopPropagation  # none of my business!

            if is_chat_action and event.action_message:  # service message
                action_message: MessageService = event.action_message
                sender_id = action_message.sender_id
                sender = await action_message.get_sender()
            elif not is_chat_action:  # message or callback
                sender_id = event.sender_id  # `None` if the sender is an anonymous admin in a group
                sender = await event.get_sender()  # ditto

            sender_fullname = (
                sender.title
                if isinstance(sender, types.Channel) else
                (sender.first_name + (f' {sender.last_name}' if sender.last_name else ''))
                if sender is not None else
                '__anonymous_admin__'
                if not is_chat_action else
                '__chat_action__'
            )

            # get the user's lang
            lang_in_db = await db.User.get_or_none(id=chat_id).values_list('lang', flat=True)
            lang = lang_in_db if lang_in_db != 'null' else None
            if not lang and not ignore_tg_lang:
                lang = sender.lang_code if hasattr(sender, 'lang_code') else None
                if not lang and sender_id != chat_id:
                    lang = db.User.get_or_none(id=sender_id).values_list('lang', flat=True)

            if is_inline:
                query: types.UpdateBotInlineQuery = event.query
                if (
                        (not allow_in_others_private_chat
                         and isinstance(query.peer_type, types.InlineQueryPeerTypePM))
                        or
                        (only_in_private_chat
                         and not isinstance(query.peer_type, (types.InlineQueryPeerTypeSameBotPM,
                                                              types.InlineQueryPeerTypePM)))
                        or
                        (not allow_in_old_fashioned_groups
                         and isinstance(query.peer_type, types.InlineQueryPeerTypeChat))
                ):
                    # Redirect to the private chat with the bot
                    await respond_or_answer(event, i18n[lang]['permission_denied_switch_pm'])
                    logger.warning(f'Redirected {describe_user()} (using {command}) to the private chat with the bot')
                    raise events.StopPropagation

            if (only_manager or not env.MULTIUSER) and sender_id != env.MANAGER:
                if is_chat_action:  # chat action, bypassing
                    raise events.StopPropagation
                if not env.MULTIUSER and not only_manager and sender_id is None:  # anonymous admin
                    await respond_or_answer(event, i18n[lang]['promote_to_admin_prompt'])
                else:
                    await respond_or_answer(event, i18n[lang]['permission_denied_not_bot_manager'])
                logger.warning(f'Refused {describe_user()} to use {command} '
                               f'because the command can only be used by a bot manager')
                raise events.StopPropagation

            if is_inline:
                await execute()
                raise events.StopPropagation

            if (
                    event.is_private or  # deal with commands in private chats
                    (
                            (event.is_channel  # deal with commands in channels
                             # a supergroup is also a channel, but we only expect a "real" channel here
                             and not event.is_group)
                            # if receiving a callback, we must verify that the sender is an admin. jump below
                            and not is_callback
                    )
                    and not only_manager and not only_in_private_chat
            ):  # we can deal with private chats and channels in the same way
                await execute()
                raise events.StopPropagation

            if (
                    (
                            event.is_group or  # deal with commands in groups
                            (event.is_channel and is_callback)  # deal with callback in channels
                    )
                    and not only_manager
            ):  # receiving a command in a group, or, a callback in a channel
                if isinstance(sender, types.Channel):
                    raise events.StopPropagation  # channel messages are none of my business

                # supergroup is a special form of channel
                input_chat: Optional[Union[types.InputChannel,
                                           types.InputPeerChannel,
                                           types.InputPeerChat]] = await event.get_input_chat()
                chat: Optional[Union[types.Chat, types.Channel]] = await event.get_chat()
                chat_title = chat and chat.title

                if only_in_private_chat:
                    await respond_or_answer(event, i18n[lang]['permission_denied_not_in_private_chat'])
                    logger.warning(f'Refused {describe_user()} to use {command} '
                                   f'because the command can only be used in a private chat')
                    raise events.StopPropagation

                if isinstance(input_chat, types.InputPeerChat):
                    if allow_in_old_fashioned_groups:
                        # old-fashioned groups lacks of permission management, no need to check
                        await execute()
                        raise events.StopPropagation
                    # oops, the group hasn't been migrated to a supergroup. a migration is needed
                    guide_msg, guide_buttons = get_group_migration_help_msg(lang)
                    await event.respond(guide_msg, buttons=guide_buttons)
                    logger.warning(f'Refused {describe_user()} to use {command} '
                                   f'because a group migration to supergroup is needed')
                    raise events.StopPropagation

                # check if self is in the group/chanel and if is an admin
                self_is_admin = True  # bypass check if the event is a callback query, set to True for convenience
                if is_callback:
                    try:
                        self_participant: types.channels.ChannelParticipant = await env.bot(
                            GetParticipantRequest(input_chat, env.bot_input_peer))
                        self_is_admin = isinstance(self_participant.participant,
                                                   (types.ChannelParticipantAdmin, types.ChannelParticipantCreator))
                    except UserNotParticipantError:  # I am not a participant of the group/channel, none of my business!
                        await respond_or_answer(event, i18n[lang]['permission_denied_bot_not_member'], cache_time=15)
                        raise events.StopPropagation

                # user permission check
                if is_chat_action:
                    is_admin = True
                    participant_type = 'ChatAction, bypassing admin check'
                elif sender_id is not None:  # a "real" user triggering the command
                    input_sender: types.InputPeerUser = await event.get_input_sender()
                    try:
                        participant: types.channels.ChannelParticipant = await env.bot(
                            GetParticipantRequest(input_chat, input_sender))
                    except UserNotParticipantError:
                        await respond_or_answer(event,
                                                i18n[lang]['permission_denied_not_member'] if self_is_admin else
                                                i18n[lang]['promote_to_admin_prompt'],
                                                cache_time=15)
                        logger.warning(f'Refused Refused {describe_user()} to use {command} '
                                       f'because they is not a participant')
                        raise events.StopPropagation
                    is_admin = isinstance(participant.participant,
                                          (types.ChannelParticipantAdmin, types.ChannelParticipantCreator))
                    participant_type = type(participant.participant).__name__
                else:  # an anonymous admin
                    is_admin = True
                    participant_type = 'AnonymousAdmin'

                if not is_admin:
                    await respond_or_answer(event, i18n[lang]['permission_denied_not_chat_admin'], cache_time=15)
                    logger.warning(
                        f'Refused {describe_user()} to use {command}')
                    raise events.StopPropagation

                await execute()
                raise events.StopPropagation

        except events.StopPropagation as e:
            raise e
        except Exception as e:
            logger.error(
                f'Uncaught error occurred when {sender_fullname} '
                + f'({sender_id}'
                + (f', {participant_type}' if participant_type else '')
                + f') '
                + f'attempting to use {command}'
                + (f' in {chat_title} ({chat_id})' if chat_id != sender_id else ''),
                exc_info=e
            )
            try:
                if isinstance(e, FloodError):
                    # blocking other commands to be executed and messages to be sent
                    if hasattr(e, 'seconds') and e.seconds is not None:
                        await locks.user_flood_wait(chat_id, e.seconds)  # acquire a flood wait
                    async with locks.user_flood_lock(chat_id):
                        pass  # wait for flood wait (if another request has acquired a flood wait)
                    await respond_or_answer(event, 'ERROR: ' + i18n[lang]['flood_wait_prompt'])
                    await env.bot(e.request)  # resend
                # usually occurred because the user hits the same button during auto flood wait
                elif isinstance(e, MessageNotModifiedError):
                    await respond_or_answer(event, 'ERROR: ' + i18n[lang]['edit_conflict_prompt'])
                elif isinstance(e, (EntitiesTooLongError, MessageTooLongError)):
                    await respond_or_answer(event, 'ERROR: ' + i18n[lang]['message_too_long_prompt'])
                else:
                    await respond_or_answer(event, 'ERROR: ' + i18n[lang]['uncaught_internal_error'])
            except (FloodError, MessageNotModifiedError):
                pass  # we can do nothing but be a pessimism to drop it
            except Exception as e:
                logger.error('Uncaught error occurred when dealing with an uncaught error', exc_info=e)
            finally:
                raise events.StopPropagation

    return wrapper


def escape_html(raw: Any) -> str:
    raw = str(raw)
    return raw.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')


class NewFileMessage(events.NewMessage):
    def __init__(self, chats=None, *, blacklist_chats=False, func=None, incoming=None, outgoing=None, from_users=None,
                 forwards=None, pattern=None, filename_pattern: str = None):
        self.filename_pattern = re.compile(filename_pattern).match
        super().__init__(chats, blacklist_chats=blacklist_chats, func=func, incoming=incoming, outgoing=outgoing,
                         from_users=from_users, forwards=forwards, pattern=pattern)

    def filter(self, event: Union[events.NewMessage.Event, Message]):
        document: types.Document = event.message.document
        if not document:
            return
        if self.filename_pattern:
            filename = None
            for attr in document.attributes:
                if isinstance(attr, types.DocumentAttributeFilename):
                    filename = attr.file_name
                    break
            if not self.filename_pattern(filename or ''):
                return
        return super().filter(event)


class ReplyMessage(events.NewMessage):
    def __init__(self, chats=None, *, blacklist_chats=False, incoming=None, outgoing=None, from_users=None,
                 forwards=None, pattern=None, reply_to_peer_id: int = None):
        self.reply_to_peer_id = reply_to_peer_id or env.bot_id
        super().__init__(chats, blacklist_chats=blacklist_chats, func=self.__reply_verify, incoming=incoming,
                         outgoing=outgoing, from_users=from_users, forwards=forwards, pattern=pattern)

    async def __reply_verify(self, event: Union[events.NewMessage.Event, Message]):
        if event.is_reply:
            reply_to_msg: Optional[Message] = await event.get_reply_message()
            if reply_to_msg is not None and self.reply_to_peer_id == reply_to_msg.sender_id:
                return True
        return False


class PrivateMessage(events.NewMessage):
    def __init__(self, chats=None, *, blacklist_chats=False, incoming=None, outgoing=None, from_users=None,
                 forwards=None, pattern=None):
        super().__init__(chats, blacklist_chats=blacklist_chats, func=self.__in_private_chat, incoming=incoming,
                         outgoing=outgoing, from_users=from_users, forwards=forwards, pattern=pattern)

    @staticmethod
    def __in_private_chat(event: Union[events.NewMessage.Event, Message]):
        return event.is_private


class AddedToGroupAction(events.ChatAction):
    """Chat actions that are triggered when the bot has been added to a group."""

    def __init__(self, chats=None, *, blacklist_chats=False):
        super().__init__(chats, blacklist_chats=blacklist_chats, func=self.__added_to_group)

    @staticmethod
    def __added_to_group(event: events.ChatAction.Event):
        if not event.is_group:
            return False
        if event.created:
            return True  # group created or migrated
        if event.user_added or isinstance(event.action_message.action, types.MessageActionChatAddUser):
            return env.bot_id in event.user_ids  # added to a group
        return False


class GroupMigratedAction(events.ChatAction):
    """
    Chat actions that are triggered when a group has been migrated to a supergroup.

    After a group migration, below updates will be sent:
    UpdateChannel(*),
    UpdateNewChannelMessage(message=MessageService(action=MessageActionChannelMigrateFrom(*)),
    UpdateNewChannelMessage(message=MessageService(action=MessageActionChatMigrateTo(*))

    This class only listens to the latest one.
    """

    @classmethod
    def build(cls, update, others=None, self_id=None):
        if (isinstance(update, (
                types.UpdateNewMessage, types.UpdateNewChannelMessage))
                and isinstance(update.message, types.MessageService)):
            msg = update.message
            action = update.message.action
            if isinstance(action, types.MessageActionChannelMigrateFrom):
                return cls.Event(msg)


def get_commands_list(lang: Optional[str] = None, manager: bool = False) -> list[types.BotCommand]:
    commands = [
        types.BotCommand(command="sub", description=i18n[lang]['cmd_description_sub']),
        types.BotCommand(command="unsub", description=i18n[lang]['cmd_description_unsub']),
        types.BotCommand(command="unsub_all", description=i18n[lang]['cmd_description_unsub_all']),
        types.BotCommand(command="list", description=i18n[lang]['cmd_description_list']),
        types.BotCommand(command="set", description=i18n[lang]['cmd_description_set']),
        types.BotCommand(command="set_default", description=i18n[lang]['cmd_description_set_default']),
        types.BotCommand(command="import", description=i18n[lang]['cmd_description_import']),
        types.BotCommand(command="export", description=i18n[lang]['cmd_description_export']),
        types.BotCommand(command="activate_subs", description=i18n[lang]['cmd_description_activate_subs']),
        types.BotCommand(command="deactivate_subs", description=i18n[lang]['cmd_description_deactivate_subs']),
        types.BotCommand(command="version", description=i18n[lang]['cmd_description_version']),
        types.BotCommand(command="lang", description=i18n[lang]['cmd_description_lang']),
        types.BotCommand(command="help", description=i18n[lang]['cmd_description_help']),
    ]

    if manager:
        commands.extend(
            (
                types.BotCommand(command="test", description=i18n[lang]['cmd_description_test']),
                types.BotCommand(command="set_option", description=i18n[lang]['cmd_description_set_option']),
            )
        )

    return commands


async def set_bot_commands(scope: Union[types.BotCommandScopeDefault,
                                        types.BotCommandScopePeer,
                                        types.BotCommandScopePeerAdmins,
                                        types.BotCommandScopeUsers,
                                        types.BotCommandScopeChats,
                                        types.BotCommandScopePeerUser,
                                        types.BotCommandScopeChatAdmins],
                           lang_code: str,
                           commands: list[types.BotCommand]):
    await env.bot(
        SetBotCommandsRequest(scope=scope, lang_code=lang_code, commands=commands)
    )


async def send_success_and_failure_msg(message: Union[Message, events.NewMessage.Event, events.CallbackQuery.Event],
                                       success_msg: str,
                                       failure_msg: str,
                                       success_count: int,
                                       failure_count: int,
                                       *_,
                                       lang: Optional[str] = None,
                                       edit: bool = False,
                                       **__) -> Union[Message, events.NewMessage.Event, events.CallbackQuery.Event]:
    for i in range(4):
        success_msg_short = (
                success_msg.split('\n', 1)[0] + '\n'
                + i18n[lang]['n_subscriptions_in_total'] % success_count
        ) if success_count else ''
        failure_msg_short = (
                failure_msg.split('\n', 1)[0] + '\n'
                + i18n[lang]['n_subscriptions_in_total'] % failure_count
        ) if failure_count else ''

        if i == 0:
            msg_html = (
                    success_msg
                    + ('\n\n' if success_msg and failure_msg else '')
                    + failure_msg
            )

        elif i == 1:
            msg_html = (
                    success_msg_short
                    + ('\n\n' if success_msg_short and failure_msg else '')
                    + failure_msg
            )

        elif i == 2:
            msg_html = (
                    success_msg
                    + ('\n\n' if success_msg and failure_msg_short else '')
                    + failure_msg_short
            )

        else:
            msg_html = (
                    success_msg_short
                    + ('\n\n' if success_msg_short and failure_msg_short else '')
                    + failure_msg_short
            )

        try:
            msg = await (message.edit(msg_html, parse_mode='html') if edit
                         else message.respond(msg_html, parse_mode='html'))
            return msg if msg is not None else message
        except (EntitiesTooLongError, MessageTooLongError) as e:
            if i < 3:
                continue
            raise e


def get_group_migration_help_msg(lang: Optional[str] = None) \
        -> tuple[str, tuple[tuple[types.KeyboardButtonCallback, ...], ...]]:
    msg = i18n[lang]['group_upgrade_needed_prompt']
    buttons = inner.utils.arrange_grid(
        (
            Button.inline(i18n[_lang]['lang_native_name'], data=f'get_group_migration_help={_lang}')
            for _lang in ALL_LANGUAGES if _lang != lang
        ),
        columns=3)
    return msg, buttons
