# redbot/discord
from locale import currency
from textwrap import shorten
from redbot.core.utils.chat_formatting import *
from redbot.core import Config, checks, commands, bank
from redbot.core.utils.predicates import ReactionPredicate
from redbot.core.utils.menus import start_adding_reactions
import discord

import re
import asyncio
from datetime import datetime
from dateutil.relativedelta import relativedelta
from typing import Optional

TIME_RE_STRING = r"\s?".join(
    [
        r"((?P<years>\d+?)\s?(years?|y))?",
        r"((?P<months>\d+?)\s?(months?|mt))?",
        r"((?P<weeks>\d+?)\s?(weeks?|w))?",
        r"((?P<days>\d+?)\s?(days?|d))?",
        r"((?P<hours>\d+?)\s?(hours?|hrs|hr?))?",
        r"((?P<minutes>\d+?)\s?(minutes?|mins?|m(?!o)))?",  # prevent matching "months"
        r"((?P<seconds>\d+?)\s?(seconds?|secs?|s))?",
    ]
)

TIME_RE = re.compile(TIME_RE_STRING, re.I)


def parse_timedelta(argument: str) -> Optional[relativedelta]:
    matches = TIME_RE.match(argument)
    if matches:
        params = {k: int(v) for k, v in matches.groupdict().items() if v}
        if params:
            return relativedelta(**params)
    return None


NO_NICKNAME = "#" * 40


class NameChange(commands.Cog):
    """
    Allow users to pay currency to change someone's name for a time period.
    """

    def __init__(self, bot):
        self.bot = bot
        self.config = Config.get_conf(self, identifier=574243463248323838, force_registration=True)

        default_guild = {
            "allowed_roles": [],
            "allowed_users": [],
            "disallowed_users": [],
            "cost_per_minute": 0,
            "current_changes": {},
        }
        self.config.register_guild(**default_guild)

        self.task = asyncio.create_task(self.init())

    async def init(self):
        await self.bot.wait_until_ready()
        check_names = True
        while True:
            await self.update_namechanges(check_names=check_names)
            await asyncio.sleep(60)
            check_names = False

    def cog_unload(self):
        if self.task is not None:
            self.task.cancel()

    async def update_namechanges(self, check_names: bool = False):
        for guild in self.bot.guilds:
            to_remove = []
            to_change = {}
            for member_id, data in (await self.config.guild(guild).current_changes()).items():
                now = datetime.now()
                end_time = datetime.fromtimestamp(data["end_time"])
                member = guild.get_member(int(member_id))

                if member is None:
                    to_remove.append(member_id)
                    continue

                if now > end_time:
                    # name change is past due
                    to_change[member] = data
                    to_remove.append(member_id)
                elif check_names and member.display_name != data["new_nick"]:
                    # need to also check if anyone changed their name during bot's downtime and change them back
                    await self.change_nickname(member, data["new_nick"])

            async with self.config.guild(guild).current_changes() as current_changes:
                for id in to_remove:
                    del current_changes[id]

            # have to change usernames back after current_changes gets updated
            for member, data in to_change.items():
                if data["old_nick"] == NO_NICKNAME:
                    await self.change_nickname(member, None)
                else:
                    await self.change_nickname(member, data["old_nick"])

    async def check_can_change(self, member: discord.Member):
        roles = await self.config.guild(member.guild).allowed_roles()
        members = await self.config.guild(member.guild).allowed_users()
        disallow_members = await self.config.guild(member.guild).disallowed_users()

        if member.id in disallow_members:
            return False

        for role in member.roles:
            if role.id in roles:
                return True

        if member.id in members:
            return True

        return False

    @staticmethod
    async def change_nickname(member: discord.Member, nick: str):
        try:
            await member.edit(nick=nick)
            return True
        except discord.Forbidden:
            return False
        except discord.HTTPException:
            return False

    # @commands.command()
    # async def test(self, ctx):
    #    await self.update_namechanges()

    @commands.group(name="name", invoke_without_command=True)
    @commands.guild_only()
    @checks.bot_has_permissions(manage_nicknames=True)
    async def namechange(self, ctx, member: discord.Member, time: str, *, new_name: str):
        """
        Set someone's Nickname using bot currency

        Time should be a combination of s, m, h, d, w in this format:
            `5m30s` = 5 minutes 30 seconds
            `1w3d` = 1 week 3 days
            `10m` = 10 minutes
            etc...
        """
        if ctx.invoked_subcommand:
            return
        elif member is not None:
            time = parse_timedelta(time)
            if time is None:
                await ctx.send(error("Invalid time interval!"), delete_after=30)
                return

            if len(new_name) > 32 or len(new_name) < 2:
                await ctx.send(error("Nickname must be 2 to 32 characters in length!"), delete_after=30)
                return

            if not (await self.check_can_change(member)):
                await ctx.send(error("That user cannot have their name changed!"), delete_after=30)
                return

            now = datetime.now()
            time = (now + time) - now  # convert from relative delta to time delta

            cost = await self.config.guild(ctx.guild).cost_per_minute()
            currency_name = await bank.get_currency_name(ctx.guild)
            total_cost = int(time.total_seconds() / 60 * cost)
            current = await self.config.guild(ctx.guild).current_changes()

            if str(member.id) in current:
                end_time = current[str(member.id)]["end_time"]
                await ctx.send(
                    error(
                        f"{member} already has their name changed! Please wait until <t:{end_time}> to change their name again."
                    )
                )
                return

            msg = await ctx.send(
                f"The total cost to change {member}'s name will be {total_cost} {currency_name}, continue?",
                delete_after=31,
            )
            start_adding_reactions(msg, ReactionPredicate.YES_OR_NO_EMOJIS)
            pred = ReactionPredicate.yes_or_no(msg, ctx.author)

            try:
                await self.bot.wait_for("reaction_add", check=pred, timeout=30)
            except asyncio.TimeoutError:
                await ctx.send(error("Took too long, name change is cancelled!"), delete_after=30)
                return

            if not pred.result:
                await ctx.send("Name change cancelled, you were not charged.")
                return

            try:
                await bank.withdraw_credits(ctx.author, total_cost)
            except ValueError:
                await ctx.send(
                    f"You do not have enough {currency_name}! Cost: {total_cost}, balance: {await bank.get_balance(ctx.author)}"
                )
                return

            now = datetime.now()
            data = {
                "old_nick": member.nick if member.nick is not None else NO_NICKNAME,
                "new_nick": new_name,
                "end_time": int((now + time).astimezone(now.astimezone().tzinfo).timestamp()),
                "author": ctx.author.id,
            }

            async with self.config.guild(ctx.guild).current_changes() as current_changes:
                current_changes[str(member.id)] = data

            result = await self.change_nickname(member, new_name)
            if not result:
                await ctx.send(
                    error(
                        f"It seem's I couldn't change {member}'s nickname, please contact a staff member to check my role hierarchy.\n\nYou have not been charged."
                    ),
                    delete_after=30,
                )
                # refund them
                await bank.deposit_credits(ctx.author, total_cost)
                return

            await ctx.send(f"{member}'s nickname changed to {new_name} until <t:{data['end_time']}>.")

            try:
                await member.send(
                    info(
                        f"**Name changed in {ctx.guild}**\n\nYour name was changed to `{new_name}` by {ctx.author.mention} until <t:{data['end_time']}>."
                    )
                )
            except:
                pass

    @namechange.command(name="setcost")
    @checks.admin()
    async def namechange_setcost(self, ctx, cost: int = None):
        """
        Set the cost **per minute** to change someone's name
        """
        if cost is None:
            currency_name = await bank.get_currency_name(ctx.guild)
            current = await self.config.guild(ctx.guild).cost_per_minute()
            await ctx.send(f"Current cost per minute to change someone's name: {current} {currency_name}")
            return

        if cost < 0:
            await ctx.send(error("Cost must be greater than or equal to 0."), delete_after=30)
            return

        await self.config.guild(ctx.guild).cost_per_minute.set(cost)
        await ctx.tick()

    @namechange.group(name="roles")
    @checks.admin()
    async def namechange_roles(self, ctx):
        """
        Set the roles that allow name changing
        """
        pass

    @namechange_roles.command(name="add")
    async def namechange_roles_add(self, ctx, *, role: discord.Role):
        """
        Add a role for name changing
        """
        async with self.config.guild(ctx.guild).allowed_roles() as allowed_roles:
            if role.id not in allowed_roles:
                allowed_roles.append(role.id)
                await ctx.tick()
            else:
                await ctx.send(error(f"`{role}` is already added!"), delete_after=30)

    @namechange_roles.command(name="del")
    async def namechange_roles_del(self, ctx, *, role: discord.Role):
        """
        Remove a role from name changing
        """
        async with self.config.guild(ctx.guild).allowed_roles() as allowed_roles:
            if role.id in allowed_roles:
                allowed_roles.remove(role.id)
                await ctx.tick()
            else:
                await ctx.send(error(f"`{role}` is not in the allowed list!"), delete_after=30)

    @namechange_roles.command(name="list")
    async def namechange_roles_list(self, ctx):
        """
        View all roles that allow name changing.
        """
        roles = await self.config.guild(ctx.guild).allowed_roles()
        roles = [ctx.guild.get_role(r) for r in roles]

        msg = [f"{r.mention}\n" for r in roles if r is not None]
        msg = "Current roles:\n" + "".join(msg)

        for page in pagify(msg, page_length=1800, shorten_by=22):
            await ctx.send(page)

    @namechange.group(name="user")
    @checks.admin()
    async def namechange_user(self, ctx):
        """
        Set specific users that allow name changing
        """
        pass

    @namechange_user.command(name="add")
    async def namechange_user_add(self, ctx, *, member: discord.Member):
        """
        Add a member for name changing
        """
        async with self.config.guild(ctx.guild).allowed_users() as allowed_users:
            if member.id not in allowed_users:
                allowed_users.append(member.id)
                await ctx.tick()
            else:
                await ctx.send(error(f"`{member}` is already added!"), delete_after=30)

    @namechange_user.command(name="del")
    async def namechange_user_del(self, ctx, *, member: discord.Member):
        """
        Remove a member from name changing
        """
        async with self.config.guild(ctx.guild).allowed_users() as allowed_users:
            if member.id in allowed_users:
                allowed_users.remove(member.id)
                await ctx.tick()
            else:
                await ctx.send(error(f"`{member}` is not in the allowed list!"), delete_after=30)

    @namechange_user.command(name="list")
    async def namechange_user_list(self, ctx):
        """
        View all members that allow name changing.
        """
        members = await self.config.guild(ctx.guild).allowed_users()
        members = [ctx.guild.get_member(m) for m in members]

        msg = [f"{m.mention}\n" for m in members if m is not None]
        msg = "Current members:\n" + "".join(msg)

        for page in pagify(msg, page_length=1800, shorten_by=22):
            await ctx.send(page)

    @namechange_user.group(name="optout")
    @checks.admin()
    async def namechange_user_optout(self, ctx):
        """
        Opt specific users out from changing their name
        """
        pass

    @namechange_user_optout.command(name="add")
    async def namechange_user_optout_add(self, ctx, *, member: discord.Member):
        """
        Add a member to disallow name changing
        """
        async with self.config.guild(ctx.guild).disallowed_users() as disallowed_users:
            if member.id not in disallowed_users:
                disallowed_users.append(member.id)
                await ctx.tick()
            else:
                await ctx.send(error(f"`{member}` is already added!"), delete_after=30)

    @namechange_user_optout.command(name="del")
    async def namechange_user_optout_del(self, ctx, *, member: discord.Member):
        """
        Remove a member from opting out
        """
        async with self.config.guild(ctx.guild).disallowed_users() as disallowed_users:
            if member.id in disallowed_users:
                disallowed_users.remove(member.id)
                await ctx.tick()
            else:
                await ctx.send(error(f"`{member}` is not in the disallow list!"), delete_after=30)

    @namechange_user_optout.command(name="list")
    async def namechange_user_optout_list(self, ctx):
        """
        View all members that allow name changing.
        """
        members = await self.config.guild(ctx.guild).disallowed_users()
        members = [ctx.guild.get_member(m) for m in members]

        msg = [f"{m.mention}\n" for m in members if m is not None]
        msg = "Current members:\n" + "".join(msg)

        for page in pagify(msg, page_length=1800, shorten_by=22):
            await ctx.send(page)

    @namechange.command(name="cost")
    async def namechange_cost(self, ctx):
        """
        Get the cost of changing someone's name
        """
        current_cost = await self.config.guild(ctx.guild).cost_per_minute()
        currency_name = await bank.get_currency_name(ctx.guild)

        await ctx.send(
            info(f"It costs {current_cost} {currency_name} **per minute** to change someone's name."),
            delete_after=30,
        )

    @namechange.command(name="optin")
    async def namechange_optin(self, ctx):
        """
        Opt in or opt out of allowing others to change your name

        If you are already opted in, running the command again will opt you out.
        """
        member = ctx.author
        async with self.config.guild(ctx.guild).allowed_users() as allowed_users:
            if member.id not in allowed_users:
                allowed_users.append(member.id)
                await ctx.tick()
            else:
                allowed_users.remove(member.id)
                await ctx.tick()
                await ctx.send(info("You have opted out of allowing others to change your name."), delete_after=30)

    @namechange.command(name="remove")
    @checks.admin()
    async def namechange_remove(self, ctx, *, member: discord.Member):
        """
        Manually remove someone's name change.
        """
        data = None
        async with self.config.guild(ctx.guild).current_changes() as current_changes:
            if str(member.id) in current_changes:
                data = current_changes[str(member.id)]
                del current_changes[str(member.id)]

        if data is not None:
            if data["old_nick"] == NO_NICKNAME:
                await self.change_nickname(member, None)
            else:
                await self.change_nickname(member, data["old_nick"])

        await ctx.tick()

    @namechange.command(name="list")
    @checks.admin()
    async def namechange_list(self, ctx):
        """
        List all users who have their name changed.
        """
        current = await self.config.guild(ctx.guild).current_changes()

        msg = ""
        for member_id, data in current.items():
            member = ctx.guild.get_member(int(member_id))
            if not member:
                continue

            author = ctx.guild.get_member(data["author"])
            author = "Unknown User" if author is None else author.mention
            msg += f"{member.mention}:\n\t-Changed by {author}\n\t-Ends on <t:{data['end_time']}>\n\n"

        if len(msg) == 0:
            msg = "No users currently have their name changed!"

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

    @commands.Cog.listener()
    async def on_member_update(self, before: discord.Member, after: discord.Member):
        # check if nickname changed
        if before.nick != after.nick:
            current = await self.config.guild(before.guild).current_changes()
            if str(before.id) in current and current[str(before.id)]["new_nick"] != after.nick:
                await self.change_nickname(after, current[str(before.id)]["new_nick"])
