from __future__ import annotations

import contextlib
import asyncio
import re
import time
from abc import ABCMeta
from typing import AsyncIterator, Tuple, Optional, Union, List, Dict, Literal

import discord
from discord.ext.commands import CogMeta as DPYCogMeta
from redbot.core import checks, commands, bank, modlog
from redbot.core.config import Config
from redbot.core.utils.chat_formatting import (
    box,
    pagify,
    warning,
    humanize_list,
    error,
    info,
)
from redbot.core.utils.predicates import MessagePredicate
from dateutil import parser
from datetime import datetime

from .events import EventMixin
from .exceptions import (
    RoleManagementException,
    PermissionOrHierarchyException,
    MissingRequirementsException,
    ConflictingRoleException,
)
from .massmanager import MassManagementMixin
from .utils import UtilMixin, variation_stripper_re, parse_timedelta, parse_seconds

try:
    from redbot.core.commands import GuildContext
except ImportError:
    from redbot.core.commands import Context as GuildContext  # type: ignore

# This previously used ``(type(commands.Cog), type(ABC))``
# This was changed to be explicit so that mypy
# would be slightly happier about it.
# This does introduce a potential place this
# can break in the future, but this would be an
# Upstream breaking change announced in advance
class CompositeMetaClass(DPYCogMeta, ABCMeta):
    """
    This really only exists because of mypy
    wanting mixins to be individually valid classes.
    """

    pass  # MRO is fine on __new__ with super() use
    # no need to manually ensure both get handled here.


MIN_SUB_TIME = 3600
SLEEP_TIME = 300
MAX_EMBED = 25


class RoleManagement(
    UtilMixin,
    MassManagementMixin,
    EventMixin,
    commands.Cog,
    metaclass=CompositeMetaClass,
):
    """
    Cog for role management
    """

    __author__ = "mikeshardmind(Sinbad), DiscordLiz, Brandons209"
    __version__ = "325.0.0"

    def format_help_for_context(self, ctx):
        pre_processed = super().format_help_for_context(ctx)
        return f"{pre_processed}\nCog Version: {self.__version__}"

    def __init__(self, bot):
        self.bot = bot
        self.config = Config.get_conf(
            self, identifier=78631113035100160, force_registration=True
        )
        self.config.register_global(
            handled_variation=False, handled_full_str_emoji=False
        )
        self.config.register_role(
            exclusive_to={},
            requires_any=[],
            requires_all=[],
            add_with=[],
            sticky=False,
            self_removable=False,
            self_role=False,
            protected=False,
            cost=0,
            subscription=0,
            subscribed_users={},
            dm_msg=None,
            age_verification=None,
        )  # subscribed_users maps str(user.id)-> end time in unix timestamp
        self.config.register_member(roles=[], forbidden=[], birthday=None)
        self.config.init_custom("REACTROLE", 2)
        self.config.register_custom(
            "REACTROLE", roleid=None, channelid=None, guildid=None
        )  # ID : Message.id, str(React)
        self.config.register_guild(
            notify_channel=None, s_roles=[], free_roles=[], join_roles=[], age_log=False
        )
        self._ready = asyncio.Event()
        self._start_task: Optional[asyncio.Task] = None
        self.loop = asyncio.get_event_loop()
        self._sub_task = self.loop.create_task(self.sub_checker())
        # remove selfrole commands since we are going to override them
        self.bot.remove_command("selfrole")
        super().__init__()

    def cog_unload(self):
        if self._start_task:
            self._start_task.cancel()
        if self._sub_task:
            self._sub_task.cancel()

    def init(self):
        self._start_task = asyncio.create_task(self.initialization())
        self._start_task.add_done_callback(lambda f: f.result())

    async def initialization(self):
        data: Dict[str, Dict[str, Dict[str, Union[int, bool, List[int]]]]]
        await self.bot.wait_until_red_ready()
        if not await self.config.handled_variation():
            data = await self.config.custom("REACTROLE").all()
            to_adjust = {}
            for message_id, emojis_to_data in data.items():
                for emoji_key in emojis_to_data:
                    new_key, c = variation_stripper_re.subn("", emoji_key)
                    if c:
                        to_adjust[(message_id, emoji_key)] = new_key

            for (message_id, emoji_key), new_key in to_adjust.items():
                data[message_id][new_key] = data[message_id][emoji_key]
                data[message_id].pop(emoji_key, None)

            await self.config.custom("REACTROLE").set(data)
            await self.config.handled_variation.set(True)

        if not await self.config.handled_full_str_emoji():
            data = await self.config.custom("REACTROLE").all()
            to_adjust = {}
            pattern = re.compile(r"^(<?a?:)?([A-Za-z0-9_]+):([0-9]+)(\:?>?)$")
            # Am not a fan....
            for message_id, emojis_to_data in data.items():
                for emoji_key in emojis_to_data:
                    new_key, c = pattern.subn(r"\3", emoji_key)
                    if c:
                        to_adjust[(message_id, emoji_key)] = new_key

            for (message_id, emoji_key), new_key in to_adjust.items():
                data[message_id][new_key] = data[message_id][emoji_key]
                data[message_id].pop(emoji_key, None)

            await self.config.custom("REACTROLE").set(data)
            await self.config.handled_full_str_emoji.set(True)

        # register casetype for age
        age_case = {
            "name": "Date of Birth Added",
            "default_setting": True,
            "image": "🧓",
            "case_str": "Date of Birth Added",
        }

        try:
            await modlog.register_casetypes([age_case])
        except RuntimeError:
            pass

        self._ready.set()

    async def wait_for_ready(self):
        await self._ready.wait()

    async def cog_before_invoke(self, ctx):
        await self.wait_for_ready()
        if ctx.guild:
            await self.maybe_update_guilds(ctx.guild)

    # makes it a bit more readable
    async def sub_helper(self, guild, role, role_data):
        for user_id in list(role_data["subscribed_users"].keys()):
            end_time = role_data["subscribed_users"][user_id]
            now_time = time.time()
            if end_time <= now_time:
                member = guild.get_member(int(user_id))
                if not member:  # clean absent members
                    del role_data["subscribed_users"][user_id]
                    continue
                # make sure they still have the role
                if role not in member.roles:
                    del role_data["subscribed_users"][user_id]
                    continue
                # charge user
                raw_cost = await self.config.role(role).cost()
                cost = await self.get_cost(member, role)
                currency_name = await bank.get_currency_name(guild)
                curr_sub = await self.config.role(role).subscription()
                if raw_cost == 0 or curr_sub == 0:
                    # role is free now or sub is removed, remove stale sub
                    del role_data["subscribed_users"][user_id]
                    continue
                if cost == 0:
                    continue

                msg = f"Hello! You are being charged {cost} {currency_name} for your subscription to the {role.name} role in {guild.name}."
                try:
                    await bank.withdraw_credits(member, cost)
                    msg += f"\n\nNo further action is required! You'll be charged again in {parse_seconds(curr_sub)}."
                    role_data["subscribed_users"][user_id] = now_time + curr_sub
                except ValueError:  # user is poor
                    msg += f"\n\nHowever, you do not have enough {currency_name} to cover the subscription. The role will be removed."
                    await self.update_roles_atomically(who=member, remove=[role])
                    del role_data["subscribed_users"][user_id]

                try:
                    await member.send(msg)
                except:
                    # trys to send in system channel, if that fails then
                    # send message in first channel bot can speak in
                    channel = guild.system_channel
                    msg += f"\n\n{member.mention} make sure to allow receiving DM's from server members so I can DM you this message!"
                    if channel.permissions_for(channel.guild.me).send_messages:
                        await channel.send(msg)
                    else:
                        for channel in guild.text_channels:
                            if channel.permissions_for(channel.guild.me).send_messages:
                                await channel.send(msg)
                                break

        return role_data

    async def sub_checker(self):
        await self.wait_for_ready()
        while True:
            await asyncio.sleep(SLEEP_TIME)
            for guild in self.bot.guilds:
                async with self.config.guild(guild).s_roles() as s_roles:
                    for role_id in reversed(s_roles):
                        role = guild.get_role(role_id)
                        if not role:  # clean stale subs if role is deleted
                            s_roles.remove(role_id)
                            continue

                        role_data = await self.config.role(role).all()

                        role_data = await self.sub_helper(guild, role, role_data)

                        await self.config.role(role).subscribed_users.set(
                            role_data["subscribed_users"]
                        )
                        if len(role_data["subscribed_users"]) == 0:
                            s_roles.remove(role_id)

    async def get_cost(self, member: discord.Member, role: discord.Role):
        """Gets cost of a role for a user"""
        cost = await self.config.role(role).cost()
        free_roles = await self.config.guild(member.guild).free_roles()

        for m_role in member.roles:
            if m_role.id in free_roles:
                return 0

        return cost

    @commands.guild_only()
    @commands.bot_has_permissions(manage_roles=True)
    @checks.admin_or_permissions(manage_roles=True)
    @commands.command()
    async def changeage(self, ctx: GuildContext, member: discord.Member, birthday: str):
        """
        Manually update the birthday of a user.
        """
        try:
            dob = parser.parse(birthday)
        except:
            await ctx.send(error("Invalid date format!"), delete_after=30)
            return

        if dob.year == datetime.now().year:
            await ctx.send(
                error(
                    f"Invalid date format, please make sure to include your birth year."
                ),
                delete_after=30,
            )
            return

        await self.config.member(member).birthday.set(dob.strftime("%m/%d/%Y"))
        await ctx.tick()

    @commands.guild_only()
    @commands.bot_has_permissions(manage_roles=True)
    @checks.admin_or_permissions(manage_roles=True)
    @commands.command(name="hackrole")
    async def hackrole(self, ctx: GuildContext, user_id: int, *, role: discord.Role):
        """
        Puts a stickyrole on someone not in the server.
        """

        if not await self.all_are_valid_roles(ctx, role):
            return await ctx.maybe_send_embed(
                "Can't do that. Discord role heirarchy applies here."
            )

        if not await self.config.role(role).sticky():
            return await ctx.send("This only works on sticky roles.")

        member = ctx.guild.get_member(user_id)
        if member:

            try:
                await self.update_roles_atomically(who=member, give=[role])
            except PermissionOrHierarchyException:
                await ctx.send("Can't, somehow")
            else:
                await ctx.maybe_send_embed("They are in the guild...assigned anyway.")
        else:

            async with self.config.member_from_ids(
                ctx.guild.id, user_id
            ).roles() as sticky:
                if role.id not in sticky:
                    sticky.append(role.id)

            await ctx.tick()

    @checks.is_owner()
    @commands.command(name="rrcleanup", hidden=True)
    async def rolemanagementcleanup(self, ctx: GuildContext):
        """:eyes:"""
        data = await self.config.custom("REACTROLE").all()

        key_data = {}

        for maybe_message_id, maybe_data in data.items():
            try:
                message_id = int(maybe_message_id)
            except ValueError:
                continue

            ex_keys = list(maybe_data.keys())
            if not ex_keys:
                continue

            message = None
            channel_id = maybe_data[ex_keys[0]]["channelid"]
            channel = ctx.bot.get_channel(channel_id)
            if channel:
                with contextlib.suppress(discord.HTTPException):
                    assert isinstance(channel, discord.TextChannel)  # nosec
                    message = await channel.fetch_message(message_id)

            if not message:
                key_data.update({maybe_message_id: ex_keys})

        for mid, keys in key_data.items():
            for k in keys:
                await self.config.custom("REACTROLE", mid, k).clear()

        await ctx.tick()

    @commands.guild_only()
    @commands.bot_has_permissions(manage_roles=True)
    @checks.admin_or_permissions(manage_guild=True)
    @commands.command(name="rolebind")
    async def bind_role_to_reactions(
        self,
        ctx: GuildContext,
        role: discord.Role,
        channel: discord.TextChannel,
        msgid: int,
        emoji: str,
    ):
        """
        Binds a role to a reaction on a message...

        The role is only given if the criteria for it are met.
        Make sure you configure the other settings for a role in [p]roleset
        """

        if not await self.all_are_valid_roles(ctx, role):
            return await ctx.maybe_send_embed(
                "Can't do that. Discord role heirarchy applies here."
            )

        try:
            message = await channel.fetch_message(msgid)
        except discord.HTTPException:
            return await ctx.maybe_send_embed("No such message")

        _emoji: Optional[Union[discord.Emoji, str]]

        _emoji = discord.utils.find(lambda e: str(e) == emoji, self.bot.emojis)
        if _emoji is None:
            try:
                await ctx.message.add_reaction(emoji)
            except discord.HTTPException:
                return await ctx.maybe_send_embed("No such emoji")
            else:
                _emoji = emoji
                eid = self.strip_variations(emoji)
        else:
            eid = str(_emoji.id)

        if not any(str(r) == emoji for r in message.reactions):
            try:
                await message.add_reaction(_emoji)
            except discord.HTTPException:
                return await ctx.maybe_send_embed(
                    "Hmm, that message couldn't be reacted to"
                )

        cfg = self.config.custom("REACTROLE", str(message.id), eid)
        await cfg.set(
            {
                "roleid": role.id,
                "channelid": message.channel.id,
                "guildid": role.guild.id,
            }
        )
        await ctx.send(
            f"Remember, the reactions only function according to "
            f"the rules set for the roles using `{ctx.prefix}roleset`",
            delete_after=30,
        )

    @commands.guild_only()
    @commands.bot_has_permissions(manage_roles=True)
    @checks.admin_or_permissions(manage_guild=True)
    @commands.command(name="roleunbind")
    async def unbind_role_from_reactions(
        self, ctx: commands.Context, role: discord.Role, msgid: int, emoji: str
    ):
        """
        unbinds a role from a reaction on a message
        """

        if not await self.all_are_valid_roles(ctx, role):
            return await ctx.maybe_send_embed(
                "Can't do that. Discord role heirarchy applies here."
            )

        await self.config.custom(
            "REACTROLE", f"{msgid}", self.strip_variations(emoji)
        ).clear()
        await ctx.tick()

    @commands.guild_only()
    @commands.bot_has_permissions(manage_roles=True)
    @checks.admin_or_permissions(manage_guild=True)
    @commands.group(name="roleset", autohelp=True)
    async def rgroup(self, ctx: GuildContext):
        """
        Settings for role requirements
        """
        pass

    @rgroup.command(name="age")
    async def rg_age_verification(self, ctx, role: discord.Role, age: int):
        """
        Add an age verification requirement for a role

        Users will have to give the bot their birthdate in order to obtain a role.

        An age of 0 will disable age verification.
        """
        if age == 0:
            await self.config.role(role).age_verification.set(None)
        elif age < 0:
            await ctx.send(error("Age must be greater than or equal to 0."))
            return
        else:
            await self.config.role(role).age_verification.set(age)

        await ctx.tick()

    @rgroup.command(name="agelog")
    async def rg_age_verification_log(self, ctx, log: bool):
        """
        Log age verification to modlog
        """
        await self.config.guild(ctx.guild).age_log.set(log)
        await ctx.tick()

    @rgroup.command(name="addwith")
    async def rg_addwith(
        self, ctx: GuildContext, add_role: discord.Role, *roles: discord.Role
    ):
        """
        Sets a list of roles to add to a user when they receive
        the role specifed by `add_role`

        Leave roles empty to clear add_with roles.

        Roles with spaces in the name should be put in quotes
        """
        current = await self.config.role(add_role).add_with()
        current = [discord.utils.get(ctx.guild.roles, id=r) for r in current]

        await self.config.role(add_role).add_with.set([r.id for r in roles])

        if not roles and current:
            await ctx.send(f"Add with roles cleared from: `{humanize_list(current)}`")
        elif not roles and not current:
            await ctx.send("No roles originally defined.")
        else:
            await ctx.send(
                f"Add with roles set to `{humanize_list([r.name for r in roles])}` from `{humanize_list(current) if current else None}`"
            )

    @rgroup.command(name="viewreactions")
    async def rg_view_reactions(self, ctx: GuildContext):
        """
        View the reactions enabled for the server
        """
        # This design is intentional for later extention to view this per role

        use_embeds = await ctx.embed_requested()
        react_roles = "\n".join(
            [
                msg
                async for msg in self.build_messages_for_react_roles(
                    *ctx.guild.roles, use_embeds=use_embeds
                )
            ]
        )

        if not react_roles:
            return await ctx.send("No react roles bound here.")

        # ctx.send is already going to escape said mentions if any somehow get generated
        # should also not be possible to do so without willfully being done by an admin.

        color = await ctx.embed_colour() if use_embeds else None

        for page in pagify(
            react_roles, escape_mass_mentions=False, page_length=1800, shorten_by=0
        ):
            # unrolling iterative calling of ctx.maybe_send_embed
            if use_embeds:
                await ctx.send(embed=discord.Embed(description=page, color=color))
            else:
                await ctx.send(page)

    @rgroup.command(name="dm-message")
    async def rg_dm_message(
        self, ctx: GuildContext, role: discord.Role, *, msg: str = None
    ):
        """
        Set message to DM to user when they obtain the role.
        Will send it in the channel they ran the command if DM fails to send.

        Run with no message to get the current message of the role.
        Set message to message_clear to clear the message for the role.
        """
        if not msg:
            curr = await self.config.role(role).dm_msg()
            if not curr:
                await ctx.send("No message set for that role.")
            else:
                await ctx.send(curr)
            return
        elif msg.lower() == "message_clear":
            await self.config.role(role).dm_msg.set(None)
            await ctx.tick()
            return

        await self.config.role(role).dm_msg.set(msg)
        await ctx.tick()

    @rgroup.group(name="join")
    async def join_roles(self, ctx: GuildContext):
        """
        Set roles to add to users on join.
        """
        pass

    @join_roles.command(name="add")
    async def join_roles_add(self, ctx: GuildContext, *, role: discord.Role):
        """
        Add a role to the join list.
        """
        async with self.config.guild(ctx.guild).join_roles() as join_roles:
            if role.id not in join_roles:
                join_roles.append(role.id)

        await ctx.tick()

    @join_roles.command(name="rem")
    async def join_roles_rem(self, ctx: GuildContext, *, role: discord.Role):
        """
        Remove a role from the join list.
        """
        async with self.config.guild(ctx.guild).join_roles() as join_roles:
            try:
                join_roles.remove(role.id)
            except:
                await ctx.send("Role not in join list!")
                return

        await ctx.tick()

    @join_roles.command(name="list")
    async def join_roles_list(self, ctx: GuildContext):
        """
        List join roles.
        """
        roles = await self.config.guild(ctx.guild).join_roles()
        if not roles:
            await ctx.send("No roles defined.")
            return
        roles = [ctx.guild.get_role(role) for role in roles]
        missing = len([role for role in roles if role is None])
        roles = [
            f"{i+1}.{role.name}" for i, role in enumerate(roles) if role is not None
        ]

        msg = "\n".join(sorted(roles))
        msg = pagify(msg)
        for m in msg:
            await ctx.send(box(m))

    @rgroup.command(name="viewrole")
    async def rg_view_role(self, ctx: GuildContext, *, role: discord.Role):
        """
        Views the current settings for a role
        """

        rsets = await self.config.role(role).all()

        output = (
            f"This role:\n{'is' if rsets['self_role'] else 'is not'} self assignable"
            f"\n{'is' if rsets['self_removable'] else 'is not'} self removable"
            f"\n{'is' if rsets['sticky'] else 'is not'} sticky."
        )
        if rsets["requires_any"]:
            rstring = ", ".join(
                r.name for r in ctx.guild.roles if r.id in rsets["requires_any"]
            )
            output += f"\nThis role requires any of the following roles: {rstring}"
        if rsets["requires_all"]:
            rstring = ", ".join(
                r.name for r in ctx.guild.roles if r.id in rsets["requires_all"]
            )
            output += f"\nThis role requires all of the following roles: {rstring}"
        if rsets["add_with"]:
            rstring = ", ".join(
                r.name for r in ctx.guild.roles if r.id in rsets["add_with"]
            )
            output += f"\nThis role when added will also be added with the following roles: {rstring}"
        if rsets["exclusive_to"]:
            rstring = ""
            for group, roles in rsets["exclusive_to"].items():
                rstring = f"`{group}`: "
                rstring += ", ".join(r.name for r in ctx.guild.roles if r.id in roles)
                rstring += "\n"
            output += f"\nThis role is mutually exclusive to the following role groups:\n{rstring}"
        if rsets["cost"]:
            curr = await bank.get_currency_name(ctx.guild)
            cost = rsets["cost"]
            output += f"\nThis role costs {cost} {curr}"
        else:
            output += "\nThis role does not have an associated cost."

        if rsets["subscription"]:
            s = rsets["subscription"]
            output += f"\nThis role has a subscription time of: {parse_seconds(s)}"

        if rsets["dm_msg"]:
            dm_msg = rsets["dm_msg"]
            output += f"\nDM Message: {box(dm_msg)}"

        if rsets["age_verification"]:
            age = rsets["age_verification"]
            output += f"\nMinimum Age: `{age}`"

        for page in pagify(output):
            await ctx.send(page)

    @rgroup.command(name="cost")
    async def make_purchasable(
        self, ctx: GuildContext, cost: int, *, role: discord.Role
    ):
        """
        Makes a role purchasable for a specified cost.
        Cost must be a number greater than 0.
        A cost of exactly 0 can be used to remove purchasability.

        Purchase eligibility still follows other rules including self assignable.

        Warning: If these roles are bound to a reaction,
        it will be possible to gain these without paying.
        """

        if not await self.all_are_valid_roles(ctx, role):
            return await ctx.maybe_send_embed(
                "Can't do that. Discord role heirarchy applies here."
            )

        if cost < 0:
            return await ctx.send_help()

        await self.config.role(role).cost.set(cost)
        if cost == 0:
            await ctx.send(f"{role.name} is no longer purchasable.")
        else:
            await ctx.send(f"{role.name} is purchasable for {cost}")

    @rgroup.command(name="subscription")
    async def subscription(self, ctx, role: discord.Role, *, interval: str):
        """
        Sets a role to be a subscription, must set cost first.
        Will charge role's cost every interval, and remove the role if they run out of money
        Set to 0 to disable
        **__Minimum subscription duration is 1 hour__**
        Intervals look like:
           5 minutes
           1 minute 30 seconds
           1 hour
           2 days
           30 days
           5h30m
           (etc)
        """
        if not await self.all_are_valid_roles(ctx, role):
            return await ctx.maybe_send_embed(
                "Can't do that. Discord role heirarchy applies here."
            )
        role_cost = await self.config.role(role).cost()

        if role_cost == 0:
            await ctx.send(warning("Please set a cost for the role first."))
            return

        time = parse_timedelta(interval)
        if int(time.total_seconds()) == 0:
            await ctx.send("Subscription removed.")
            async with self.config.guild(ctx.guild).s_roles() as s:
                s.remove(role.id)
            return
        elif int(time.total_seconds()) < MIN_SUB_TIME:
            await ctx.send("Subscriptions must be 1 hour or longer.")
            return

        await self.config.role(role).subscription.set(int(time.total_seconds()))
        async with self.config.guild(ctx.guild).s_roles() as s:
            s.append(role.id)
        await ctx.send(f"Subscription set to {parse_seconds(time.total_seconds())}.")

    @rgroup.command(name="forbid")
    async def forbid_role(
        self, ctx: GuildContext, role: discord.Role, *, user: discord.Member
    ):
        """
        Forbids a user from gaining a specific role.
        """
        async with self.config.member(user).forbidden() as fb:
            if role.id not in fb:
                fb.append(role.id)
            else:
                await ctx.send("Role was already forbidden")
        await ctx.tick()

    @rgroup.command(name="unforbid")
    async def unforbid_role(
        self, ctx: GuildContext, role: discord.Role, *, user: discord.Member
    ):
        """
        Unforbids a user from gaining a specific role.
        """
        async with self.config.member(user).forbidden() as fb:
            if role.id in fb:
                fb.remove(role.id)
            else:
                await ctx.send("Role was not forbidden")
        await ctx.tick()

    @rgroup.command(name="exclusive")
    async def set_exclusivity(
        self, ctx: GuildContext, group: str, *roles: discord.Role
    ):
        """
        Set exclusive roles for group
        Takes 2 or more roles and sets them as exclusive to eachother

        The group can be any name, use spaces for names with spaces.
        Groups will show up in role list etc.
        """

        _roles = set(roles)

        if len(_roles) < 2:
            return await ctx.send("You need to provide at least 2 roles")

        for role in _roles:
            async with self.config.role(role).exclusive_to() as ex_list:
                if group not in ex_list.keys():
                    ex_list[group] = []
                ex_list[group].extend(
                    [r.id for r in _roles if r != role and r.id not in ex_list[group]]
                )

        await ctx.tick()

    @rgroup.command(name="unexclusive")
    async def unset_exclusivity(
        self, ctx: GuildContext, group: str, *roles: discord.Role
    ):
        """
        Remove exclusive roles for group
        Takes any number of roles, and removes their exclusivity settings

        The group can be any name, use spaces for names with spaces.
        If all roles are removed from a group then
        """

        _roles = set(roles)

        if not _roles:
            return await ctx.send("You need to provide at least a role to do this to")

        for role in _roles:
            ex_list = await self.config.role(role).exclusive_to()
            if group not in ex_list.keys():
                continue
            ex_list[group] = [
                idx for idx in ex_list if idx not in [r.id for r in _roles]
            ]
            if not ex_list[group]:
                del ex_list[group]
            await self.config.role(role).exclusive_to.set(ex_list)
        await ctx.tick()

    @rgroup.command(name="sticky")
    async def setsticky(
        self, ctx: GuildContext, role: discord.Role, sticky: bool = None
    ):
        """
        sets a role as sticky if used without a settings, gets the current ones
        """

        if sticky is None:
            is_sticky = await self.config.role(role).sticky()
            return await ctx.send(
                "{role} {verb} sticky".format(
                    role=role.name, verb=("is" if is_sticky else "is not")
                )
            )

        await self.config.role(role).sticky.set(sticky)
        if sticky:
            for m in role.members:
                async with self.config.member(m).roles() as rids:
                    if role.id not in rids:
                        rids.append(role.id)

        await ctx.tick()

    @rgroup.command(name="requireall")
    async def reqall(self, ctx: GuildContext, role: discord.Role, *roles: discord.Role):
        """
        Sets the required roles to gain a role

        Takes a role plus zero or more other roles (as requirements for the first)
        """

        rids = [r.id for r in roles]
        await self.config.role(role).requires_all.set(rids)
        await ctx.tick()

    @rgroup.command(name="requireany")
    async def reqany(self, ctx: GuildContext, role: discord.Role, *roles: discord.Role):
        """
        Sets a role to require already having one of another

        Takes a role plus zero or more other roles (as requirements for the first)
        """

        rids = [r.id for r in (roles or [])]
        await self.config.role(role).requires_any.set(rids)
        await ctx.tick()

    @rgroup.command(name="selfrem")
    async def selfrem(
        self, ctx: GuildContext, role: discord.Role, removable: bool = None
    ):
        """
        Sets if a role is self-removable (default False)

        use without a setting to view current
        """

        if removable is None:
            is_removable = await self.config.role(role).self_removable()
            return await ctx.send(
                "{role} {verb} self-removable".format(
                    role=role.name, verb=("is" if is_removable else "is not")
                )
            )

        await self.config.role(role).self_removable.set(removable)
        await ctx.tick()

    @rgroup.command(name="selfadd")
    async def selfadd(
        self, ctx: GuildContext, role: discord.Role, assignable: bool = None
    ):
        """
        Sets if a role is self-assignable via command

        (default False)

        use without a setting to view current
        """

        if assignable is None:
            is_assignable = await self.config.role(role).self_role()
            return await ctx.send(
                "{role} {verb} self-assignable".format(
                    role=role.name, verb=("is" if is_assignable else "is not")
                )
            )

        await self.config.role(role).self_role.set(assignable)
        await ctx.tick()

    @rgroup.group(name="freerole")
    async def free_roles(self, ctx: GuildContext):
        """
        Sets roles that bypass costs for purchasing roles in your guild.
        """
        pass

    @free_roles.command(name="add")
    async def free_roles_add(self, ctx: GuildContext, *, role: discord.Role):
        """
        Add a role to the free list.
        """
        async with self.config.guild(ctx.guild).free_roles() as free_roles:
            if role.id not in free_roles:
                free_roles.append(role.id)

        await ctx.tick()

    @free_roles.command(name="rem")
    async def free_roles_rem(self, ctx: GuildContext, *, role: discord.Role):
        """
        Remove a role from the free list.
        """
        async with self.config.guild(ctx.guild).free_roles() as free_roles:
            try:
                free_roles.remove(role.id)
            except:
                await ctx.send("Role not in free list!")
                return

        await ctx.tick()

    @free_roles.command(name="list")
    async def free_roles_list(self, ctx: GuildContext):
        """
        List free roles.
        """
        roles = await self.config.guild(ctx.guild).free_roles()
        if not roles:
            await ctx.send("No roles defined.")
            return
        roles = [ctx.guild.get_role(role) for role in roles]
        missing = len([role for role in roles if role is None])
        roles = [
            f"{i+1}.{role.name}" for i, role in enumerate(roles) if role is not None
        ]

        msg = "\n".join(sorted(roles))
        msg = pagify(msg)
        for m in msg:
            await ctx.send(box(m))

    @checks.bot_has_permissions(manage_roles=True)
    @commands.guild_only()
    @commands.group(name="selfrole", autohelp=True)
    async def selfrole(self, ctx: GuildContext):
        """
        Self assignable role commands
        """
        pass

    @selfrole.command(name="list")
    async def selfrole_list(self, ctx: GuildContext):
        """
        Lists the selfroles and any associated costs.
        """

        MYPY = False
        if MYPY:
            # remove this when mypy supports type narrowing from :=
            # It's less efficient, so not removing the actual
            # implementation below
            data: Dict[discord.Role, tuple] = {}
            for role_id, vals in (await self.config.all_roles()).items():
                role = ctx.guild.get_role(role_id)
                if role and vals["self_role"]:
                    data[role] = vals["cost"]
        else:
            data = {
                role: (vals["cost"], vals["subscription"], vals["exclusive_to"])
                for role_id, vals in (await self.config.all_roles()).items()
                if (role := ctx.guild.get_role(role_id)) and vals["self_role"]
            }

        if not data:
            return await ctx.send("There aren't any self roles here.")

        embed = discord.Embed(title="Roles", colour=ctx.guild.me.colour)
        embed.set_footer(text="You can only have one role in the same unique group!")
        i = 0
        for role, (cost, sub, ex_groups) in sorted(
            data.items(), key=lambda kv: kv[1][0]
        ):
            if ex_groups:
                groups = humanize_list(list(ex_groups.keys()))
            else:
                groups = None
            embed.add_field(
                name=f"__**{i+1}. {role.name}**__",
                value="%s%s%s"
                % (
                    (f"Cost: {cost}" if cost else "Free"),
                    (f", every {parse_seconds(sub)}" if sub else ""),
                    (f"\nunique groups: `{groups}`" if groups else ""),
                ),
            )
            i += 1
            if i % MAX_EMBED == 0:
                await ctx.send(embed=embed)
                embed.clear_fields()

        if i % MAX_EMBED != 0:
            await ctx.send(embed=embed)

    @selfrole.command(name="buy")
    async def selfrole_buy(self, ctx: GuildContext, *, role: discord.Role):
        """
        Purchase a role
        """
        if role in ctx.author.roles:
            await ctx.send("You already have that role.")
            return
        try:
            remove = await self.is_self_assign_eligible(ctx.author, role)
            eligible = await self.config.role(role).self_role()
            cost = await self.config.role(role).cost()
            subscription = await self.config.role(role).subscription()
        except PermissionOrHierarchyException:
            await ctx.send(
                "I cannot assign roles which I can not manage. (Discord Hierarchy)"
            )
        except MissingRequirementsException as e:
            msg = ""
            if e.miss_all:
                roles = [r.name for r in ctx.guild.roles if r.id in e.miss_all]
                msg += f"You need all of these roles in order to get this role: {humanize_list(roles)}\n"
            if e.miss_any:
                roles = [r.name for r in ctx.guild.roles if r.id in e.miss_any]
                msg += f"You need one of these roles in order to get this role: {humanize_list(roles)}\n"
            await ctx.send(msg)
        except ConflictingRoleException as e:
            roles = [r.name for r in ctx.guild.roles if r.id in e.conflicts]
            plural = "are" if len(roles) > 1 else "is"
            await ctx.send(
                f"You have {humanize_list(roles)}, which you are not allowed to remove and {plural} exclusive to: {role.name}"
            )
        else:
            if not eligible:
                return await ctx.send(
                    f"You aren't allowed to add `{role}` to yourself {ctx.author.mention}!"
                )

            if not cost:
                return await ctx.send(
                    "This role doesn't have a cost. Please try again using `[p]selfrole add`."
                )

            if not await self.verify_age(role, ctx=ctx):
                return await ctx.send(
                    "You do not meet the minimum age requiremnt for this role, if you think this is an error please contact a staff member."
                )

            free_roles = await self.config.guild(ctx.guild).free_roles()
            currency_name = await bank.get_currency_name(ctx.guild)
            for m_role in ctx.author.roles:
                if m_role.id in free_roles:
                    await ctx.send(
                        f"You're special, no {currency_name} will be deducted from your account."
                    )
                    cost = 0
                    # await self.update_roles_atomically(who=ctx.author, give=[role], remove=remove)
                    # await ctx.tick()
                    # return

            try:
                if cost > 0:
                    await bank.withdraw_credits(ctx.author, cost)
            except ValueError:
                return await ctx.send(
                    f"You don't have enough {currency_name} (Cost: {cost} {currency_name})"
                )
            else:
                if subscription > 0:
                    if cost > 0:
                        await ctx.send(
                            f"{role.name} will be renewed every {parse_seconds(subscription)}"
                        )
                    async with self.config.role(role).subscribed_users() as s:
                        s[str(ctx.author.id)] = time.time() + subscription
                    async with self.config.guild(ctx.guild).s_roles() as s:
                        if role.id not in s:
                            s.append(role.id)

                if remove:
                    plural = "s" if len(remove) > 1 else ""
                    await ctx.send(
                        f"Removed `{humanize_list([r.name for r in remove])}` role{plural} since they are exclusive to the role you added."
                    )
                await self.update_roles_atomically(
                    who=ctx.author, give=[role], remove=remove
                )
                await self.dm_user(ctx, role)
                await ctx.tick()

    @selfrole.command(name="add")
    async def sadd(self, ctx: GuildContext, *, role: discord.Role):
        """
        Join a role
        """
        if role in ctx.author.roles:
            await ctx.send("You already have that role.")
            return
        try:
            remove = await self.is_self_assign_eligible(ctx.author, role)
            eligible = await self.config.role(role).self_role()
            cost = await self.config.role(role).cost()
        except PermissionOrHierarchyException:
            await ctx.send(
                "I cannot assign roles which I can not manage. (Discord Hierarchy)"
            )
        except MissingRequirementsException as e:
            msg = ""
            if e.miss_all:
                roles = [r.name for r in ctx.guild.roles if r.id in e.miss_all]
                msg += f"You need all of these roles in order to get this role: {humanize_list(roles)}\n"
            if e.miss_any:
                roles = [r.name for r in ctx.guild.roles if r.id in e.miss_any]
                msg += f"You need one of these roles in order to get this role: {humanize_list(roles)}\n"
            await ctx.send(msg)
        except ConflictingRoleException as e:
            roles = [r.name for r in ctx.guild.roles if r in e.conflicts]
            plural = "are" if len(roles) > 1 else "is"
            await ctx.send(
                f"You have {humanize_list(roles)}, which you are not allowed to remove and {plural} exclusive to: {role.name}"
            )
        else:
            if not eligible:
                await ctx.send(
                    f"You aren't allowed to add `{role}` to yourself {ctx.author.mention}!"
                )

            elif not await self.verify_age(role, ctx=ctx):
                return await ctx.send(
                    "You do not meet the minimum age requiremnt for this role, if you think this is an error please contact a staff member."
                )

            elif cost:
                await ctx.send(
                    "This role is not free. "
                    "Please use `[p]selfrole buy` if you would like to purchase it."
                )
            else:
                if remove:
                    plural = "s" if len(remove) > 1 else ""
                    await ctx.send(
                        f"Removed `{humanize_list([r.name for r in remove])}` role{plural} since they are exclusive to the role you added."
                    )
                await self.update_roles_atomically(
                    who=ctx.author, give=[role], remove=remove
                )
                await self.dm_user(ctx, role)
                await ctx.tick()

    @selfrole.command(name="remove")
    async def srem(self, ctx: GuildContext, *, role: discord.Role):
        """
        leave a role
        """
        if role not in ctx.author.roles:
            await ctx.send("You do not have that role.")
            return
        if await self.config.role(role).self_removable():
            await self.update_roles_atomically(who=ctx.author, remove=[role])
            try:  # remove subscription, if any
                async with self.config.role(role).subscribed_users() as s:
                    del s[str(ctx.author.id)]
            except:
                pass
            await ctx.tick()
        else:
            await ctx.send(
                f"You aren't allowed to remove `{role}` from yourself {ctx.author.mention}!`"
            )

    # Stuff for clean interaction with react role entries

    async def build_messages_for_react_roles(
        self, *roles: discord.Role, use_embeds=True
    ) -> AsyncIterator[str]:
        """
        Builds info.

        Info is suitable for passing to embeds if use_embeds is True
        """

        linkfmt = (
            "[message #{message_id}](https://discordapp.com/channels/{guild_id}/{channel_id}/{message_id})"
            if use_embeds
            else "<https://discordapp.com/channels/{guild_id}/{channel_id}/{message_id}>"
        )

        for role in roles:
            # pylint: disable=E1133
            async for message_id, emoji_info, data in self.get_react_role_entries(role):

                channel_id = data.get("channelid", None)
                if channel_id:
                    link = linkfmt.format(
                        guild_id=role.guild.id,
                        channel_id=channel_id,
                        message_id=message_id,
                    )
                else:
                    link = (
                        f"unknown message with id {message_id}"
                        f" (use `roleset fixup` to find missing data for this)"
                    )

                emoji: Union[discord.Emoji, str]
                if emoji_info.isdigit():
                    emoji = (
                        discord.utils.get(self.bot.emojis, id=int(emoji_info))
                        or f"A custom enoji with id {emoji_info}"
                    )
                else:
                    emoji = emoji_info

                react_m = f"{role.name} is bound to {emoji} on {link}"
                yield react_m

    async def verify_age(
        self,
        role: discord.Role,
        ctx: GuildContext = None,
        member: discord.Member = None,
    ) -> bool:
        """
        Verify age of a user.

        Returns True if age is successfully verified, false otherwise.
        """
        if ctx is not None:
            member = ctx.author

        guild = member.guild
        dob = await self.config.member(member).birthday()
        min_age = await self.config.role(role).age_verification()
        age_log = await self.config.guild(guild).age_log()
        today = datetime.utcnow().date()

        if min_age is None:
            return True

        if dob is not None:
            dob = parser.parse(dob).date()
            age = today.year - dob.year

            # check to see if their birthday has passed
            dob = dob.replace(year=today.year)
            if dob > today:
                # birthday hasn't passed, subtract one from age
                age -= 1

            return age > min_age

        if ctx is not None:
            await ctx.send(
                info(
                    "Please check your DMs with me in order to continue getting this role!"
                ),
                delete_after=30,
            )

        # get dob of user
        try:
            age_msg = f"Hello! In order to get the `{role}` role in `{guild}`, you must provide your **full date of birth** in order to verify your age. Please send it here."
            await member.send(age_msg)
            pred = MessagePredicate.same_context(user=member)
            msg = await self.bot.wait_for("message", check=pred, timeout=60)
        except discord.Forbidden:  # TODO: send message in guild telling user to allow dms?
            return False
        except discord.HTTPException:
            return False
        except asyncio.TimeoutError:
            await member.send(
                error(
                    f"Took too long, the {role} role has not been added to you in {guild}!\nPlease try again."
                ),
                delete_after=30,
            )
            return False

        try:
            dob = parser.parse(msg.content.strip())
        except:
            await member.send(
                error(
                    f"Invalid date format, the {role} role has not been added to you in {guild}!\nPlease try again."
                ),
                delete_after=30,
            )
            return False

        dob = dob.date()
        if dob.year == today.year:
            await member.send(
                error(
                    f"Invalid date format, please make sure to include your birth year, the {role} role has not been added to you in {guild}!\nPlease try again."
                ),
                delete_after=30,
            )
            return False

        dob_str = dob.strftime("%m/%d/%Y")
        await member.send(
            f"Thank you! Please check back in `{guild}` to confirm you obtained the role. If not, you may not meet the age requirement for the role."
        )
        await self.config.member(member).birthday.set(dob_str)
        if age_log:
            try:
                await modlog.create_case(
                    self.bot,
                    guild,
                    datetime.now(),
                    "Date of Birth Added",
                    member,
                    moderator=member,
                    reason=f"Date of birth added for `{member}`: `{dob_str}`\n\nRole: `{role}`",
                )
            except:  # TODO: warn staff
                pass

        age = today.year - dob.year

        # check to see if their birthday has passed
        dob = dob.replace(year=today.year)
        if dob > today:
            # birthday hasn't passed, subtract one from age
            age -= 1

        return age > min_age

    async def dm_user(self, ctx: GuildContext, role: discord.Role):
        """
        DM user if dm_msg set for role.
        """
        dm_msg = await self.config.role(role).dm_msg()
        if not dm_msg:
            return

        try:
            await ctx.author.send(dm_msg)
        except:
            await ctx.send(
                f"Hey {ctx.author.mention}, please allow server members to DM you so I can send you messages! Here is the message for this role:"
            )
            await ctx.send(dm_msg)

    async def get_react_role_entries(
        self, role: discord.Role
    ) -> AsyncIterator[Tuple[str, str, dict]]:
        """
        yields:
            str, str, dict

            first str: message id
            second str: emoji id or unicode codepoint
            dict: data from the corresponding:
                config.custom("REACTROLE", messageid, emojiid)
        """

        # self.config.register_custom(
        #    "REACTROLE", roleid=None, channelid=None, guildid=None
        # )  # ID : Message.id, str(React)

        data = await self.config.custom("REACTROLE").all()

        for mid, _outer in data.items():
            if not _outer or not isinstance(_outer, dict):
                continue
            for em, rdata in _outer.items():
                if rdata and rdata["roleid"] == role.id:
                    yield (mid, em, rdata)

    async def red_delete_data_for_user(
        self,
        *,
        requester: Literal["discord_deleted_user", "owner", "user", "user_strict"],
        user_id: int,
    ):
        pass
