"""lg-rez / features / IA des réponses

Tout ce qui concerne la manière dont le bot réagit aux messages :
détermination de la meilleure réaction, gestion des réactions,
activation/désactivation des modes de chat

"""

import re
import random
import requests

from discord.ext import commands

from lgrez import config
from lgrez.blocs import tools
from lgrez.bdd import Trigger, Reaction, Role


# Marqueurs de séparation du mini-langage des séquences-réactions
MARK_OR = ' <||> '
MARK_THEN = ' <&&> '
MARK_REACT = '<::>'
MARK_CMD = '<!!>'
MARKS = [MARK_OR, MARK_THEN, MARK_REACT, MARK_CMD]


async def _build_sequence(ctx):
    """Construction d'une séquence-réaction par l'utilisateur"""
    reponse = ""
    fini = False
    while not fini:
        message = await ctx.send(
            "Réaction du bot : prochain message/commande/média, "
            "ou réaction à ce message"
        )
        ret = await tools.wait_for_react_clic(
            message, process_text=True, trigger_all_reacts=True,
            trigger_on_commands=True
        )
        if isinstance(ret, str):
            if ret.startswith(config.bot.command_prefix):   # Commande
                reponse += MARK_CMD + ret.lstrip(config.bot.command_prefix)
            else:                                           # Texte / média
                reponse += ret
        else:                                               # React
            reponse += MARK_REACT + ret.name

        message = await ctx.send("▶ Puis / 🔀 Ou / ⏹ Fin ?")
        ret = await tools.wait_for_react_clic(
            message, emojis={"▶": MARK_THEN, "🔀": MARK_OR, "⏹": False}
        )
        if ret:
            # On ajoute la marque OR ou THEN à la séquence
            reponse += ret
        else:
            fini = True

    return reponse


def fetch_tenor(trigger):
    """Renvoie le GIF Tenor le plus "pertinent" pour un texte donné.

    Args:
        trigger (str): texte auquel réagir.

    Returns:
        :class:`str` (URL du GIF) ou ``None``
    """
    # API key module ternorpy (parce que la flemme de créer un compte Tenor)
    apikey = "J5UVWPVIM4A5"

    rep = requests.get(
        url="https://api.tenor.com/v1/search",
        params={
            "q": trigger, "key": apikey, "limit": 1,
            "locale": "fr_FR", "contentfilter": "off",
            "media_filter": "minimal", "ar_range": "all"
        }
    )

    if rep:
        gifs = rep.json()["results"]
        # Payload Tenor : {..., "results":[ ... ]}
        # (https://tenor.com/gifapi/documentation#responseobjects-gif)
        if gifs:
            return gifs[0]["itemurl"]

    return None     # Pas de GIF trouvé


class GestionIA(commands.Cog):
    """Commandes relatives à l'IA (réponses automatiques du bot)"""

    @commands.command()
    @tools.private
    async def stfu(self, ctx, force=None):
        """Active/désactive la réponse automatique du bot sur ton channel privé

        Args:
            force: ``"start"``/``"on"`` / ``"stop"``/``"off"`` permet de
                forcer l'activation / la désactivation.

        Sans argument, la commande active les réactions si désactivées
        et vice-versa ; avec un autre argument, elle le fait silencieusment.

        N'agit que sur les messages classiques envoyés dans le channel :
        les commandes restent reconnues.

        Si vous ne comprenez pas le nom de la commande, demandez à Google.
        """
        id = ctx.channel.id

        if force in [None, "start", "on"] and id not in config.bot.in_stfu:
            config.bot.in_stfu.append(id)
            await ctx.send(
                "Okay, je me tais ! Tape !stfu quand tu voudras de "
                "nouveau de moi :cry:"
            )

        elif force in [None, "stop", "off"] and id in config.bot.in_stfu:
            config.bot.in_stfu.remove(id)
            await ctx.send("Ahhh, ça fait plaisir de pouvoir reparler !")

        elif force not in ["start", "on", "stop", "off"]:
            # Quelque chose d'autre que start/stop précisé après !stfu :
            # bot discret
            if id in config.bot.in_stfu:
                config.bot.in_stfu.remove(id)
            else:
                config.bot.in_stfu.append(id)


    @commands.command(aliases=["cancer", "214"])
    async def fals(self, ctx, force=None):
        """Active/désactive le mode « foire à la saucisse »

        Args:
            force: ``"start"``/``"on"`` / ``"stop"``/``"off"`` permet de
                forcer l'activation / la désactivation.

        Sans argument, la commande active le mode si désactivé
        et vice-versa.

        En mode « foire à la saucisse », le bot réagira à (presque) tous
        les messages, pas seulement sur les motifs qu'on lui a appris.

        À utiliser à vos risques et périls !
        """
        id = ctx.channel.id

        if force in [None, "start", "on"] and id not in config.bot.in_fals:
            config.bot.in_fals.append(id)
            await ctx.send(
                "https://tenor.com/view/saucisse-sausage-gif-5426973"
            )

        elif force in [None, "stop", "off"] and id in config.bot.in_fals:
            config.bot.in_fals.remove(id)
            await ctx.send("T'as raison, faut pas abuser des bonnes choses")

        elif force not in ["start", "on", "stop", "off"]:
            # Quelque chose d'autre que start/stop précisé après !fals :
            # bot discret
            if id in config.bot.in_fals:
                config.bot.in_fals.remove(id)
            else:
                config.bot.in_fals.append(id)


    @commands.command(aliases=["r"])
    async def react(self, ctx, *, trigger):
        """Force le bot à réagir à un message

        Args:
            trigger: texte auquel le bot doit réagir

        Permet de faire appel à l'IA du bot même sur les chans publics,
        ou en mode STFU, etc.

        Si utilisée par un MJ, active aussi le mode débug des
        évaluations Python (messages d'erreur).
        """
        oc = ctx.message.content
        ctx.message.content = trigger
        debug = (ctx.message.webhook_id
                 or ctx.author.top_role == config.Role.mj)
        await process_IA(ctx.message, debug=debug)
        ctx.message.content = oc
        # On rétablit le message original pour ne pas qu'il trigger
        # l'IA 2 fois, le cas échéant


    @commands.command(aliases=["rf"])
    async def reactfals(self, ctx, *, trigger):
        """Force le bot à réagir à un message comme en mode Foire à la saucisse

        Args:
            trigger: texte auquel le bot doit réagir

        Permet de faire appel directement au mode Foire à la saucisse,
        même si il n'est pas activé / sur un chan public.
        """
        async with ctx.typing():
            gif = fetch_tenor(trigger)

        if gif:
            await ctx.send(gif)
        else:
            await ctx.send("Palaref")


    @commands.command()
    @tools.mjs_et_redacteurs
    async def addIA(self, ctx, *, triggers=None):
        """Ajoute une règle d'IA (COMMANDE MJ/RÉDACTEURS)

        Args:
            triggers: mot(s), phrase(s), ou expression(s) séparées par
                des points-virgules ou sauts de lignes (ne peut pas
                contenir de ">" ou ";" hors séparateurs)

        Nouveau système d'ajout rapide :
            !addIA trigger > reponse
            !addIA trig1 ; trig2 > reponse

        ou, si réponse à un message de contenu "trigger" :
            !addIA > reponse        (triggers = [trigger])
            !addIA trig2 > reponse  (triggers = [trigger, trig2])

        Une sécurité empêche d'ajouter un trigger déjà existant.

        Dans le cas où plusieurs expressions sont spécifiées, toutes
        déclencheront l'action demandée.
        """
        fast = False
        if not triggers:
            await ctx.send(
                "Mots/expressions déclencheurs (non sensibles à la "
                "casse / accents), séparés par des points-virgules "
                "ou des sauts de ligne :"
            )
            mess = await tools.wait_for_message_here(ctx)
            triggers = mess.content

        elif ">" in triggers:
            fast = True
            # Mode rapide
            triggers, reponse = triggers.split(">", maxsplit=1)

        triggers = triggers.replace('\n', ';').split(';')
        if fast and ctx.message.reference:
            repmess = ctx.message.reference.resolved
            if repmess:
                triggers.append(repmess.content)

        triggers = [tools.remove_accents(s).lower().strip() for s in triggers]
        triggers = list({trig for trig in triggers if trig})
        # filtre doublons (accents et non accents...) et triggers vides

        for trigger in triggers.copy():
            if Trigger.query.filter_by(trigger=trigger).all():
                await ctx.send(f"Trigger `{trigger}` déjà associé à une "
                               "réaction, enlevé")
                triggers.remove(trigger)

        if not triggers:
            await ctx.send("Aucun trigger valide, abort")
            return

        await ctx.send(f"Triggers : `{'` – `'.join(triggers)}`")

        if fast:
            reponse = reponse.strip()
        else:
            reponse = await _build_sequence(ctx)

        await ctx.send(f"Résumé de la séquence : {tools.code(reponse)}")
        async with ctx.typing():
            reac = Reaction(reponse=reponse)
            config.session.add(reac)

            trigs = [Trigger(trigger=trigger, reaction=reac)
                     for trigger in triggers]
            config.session.add_all(trigs)

            config.session.commit()

        await ctx.send("Règle ajoutée en base.")


    @commands.command()
    @tools.mjs_et_redacteurs
    async def listIA(self, ctx, trigger=None, sensi=0.5):
        """Liste les règles d'IA reconnues par le bot (COMMANDE MJ/RÉDACTEURS)

        Args
            trigger (optionnel): mot/expression permettant de filter et
                trier les résultats. SI ``trigger`` FAIT PLUS D'UN MOT,
                IL DOIT ÊTRE ENTOURÉ PAR DES GUILLEMETS !
            sensi: sensibilité de détection (ratio des caractères
                correspondants, entre 0 et 1) si trigger est précisé.
        """
        async with ctx.typing():
            if trigger:
                trigs = Trigger.find_nearest(
                    trigger, col=Trigger.trigger,
                    sensi=sensi, solo_si_parfait=False
                )
                if not trigs:
                    await ctx.send(
                        f"Rien trouvé, pas de chance (sensi = {sensi})"
                    )
                    return
            else:
                raw_trigs = Trigger.query.order_by(Trigger.id).all()
                # Trié par date de création
                trigs = list(zip(raw_trigs, [None] * len(raw_trigs)))
                # Mise au format (trig, score)

            reacts = []     # Réactions associées à notre liste de triggers
            for trig in trigs:
                if (reac := trig[0].reaction) not in reacts:
                    # Pas de doublons, et reste ordonné
                    reacts.append(reac)

            def nettoy(s):
                # Abrège la réponse si trop longue et neutralise les
                # sauts de ligne / rupture code_bloc, pour affichage
                s = s.replace('\r\n', '\\n').replace('\n', '\\n')
                s = s.replace('\r', '\\r').replace("```", "'''")
                if len(s) < 75:
                    return s
                else:
                    return s[:50] + " [...] " + s[-15:]

            rep = ""
            for reac in reacts:                 # pour chaque réponse
                r = ""
                for (trig, score) in trigs:         # pour chaque trigger
                    if trig.reaction == reac:
                        sc = f"({float(score):.2}) " if score else ""
                        r += f" - {sc}{trig.trigger}"
                        # (score) trigger - (score) trigger ...

                rep += r.ljust(50) + f" ⇒ {nettoy(reac.reponse)}\n"
                # ⇒ réponse

            rep += "\nPour modifier une réaction, utiliser !modifIA <trigger>."

        await tools.send_code_blocs(ctx, rep)
        # On envoie, en séparant en blocs de 2000 caractères max


    @commands.command()
    @tools.mjs_et_redacteurs
    async def modifIA(self, ctx, *, trigger=None):
        """Modifie/supprime une règle d'IA (COMMANDE MJ/RÉDACTEURS)

        Args:
            trigger: mot/expression déclenchant la réaction à
                modifier/supprimer

        Permet d'ajouter et supprimer des triggers, de modifier la
        réaction du bot (construction d'une séquence de réponses
        successives ou aléatoires) ou de supprimer la réaction.
        """
        if not trigger:
            await ctx.send(
                "Mot/expression déclencheur de la réaction à modifier :"
            )
            mess = await tools.wait_for_message_here(ctx)
            trigger = mess.content

        trigs = Trigger.find_nearest(trigger, col=Trigger.trigger)
        if not trigs:
            await ctx.send("Rien trouvé.")
            return

        trig = trigs[0][0]
        reac = trig.reaction

        displ_seq = (reac.reponse if reac.reponse.startswith('`')
                     else tools.code(reac.reponse))     # Pour affichage
        trigs = list(reac.triggers)

        await ctx.send(
            f"Triggers : `{'` – `'.join([trig.trigger for trig in trigs])}`\n"
            f"Séquence réponse : {displ_seq}"
        )

        message = await ctx.send(
            "Modifier : ⏩ triggers / ⏺ Réponse / 🚮 Supprimer ?"
        )
        choix = await tools.wait_for_react_clic(
            message, emojis={"⏩": 1, "⏺": 2, "🚮": 0})

        if choix == 1:              # Modification des triggers
            fini = False
            while not fini:
                s = "Supprimer un trigger : \n"
                for i, t in enumerate(trigs[:10]):
                    s += f"{tools.emoji_chiffre(i+1)}. {t.trigger} \n"

                mess = await ctx.send(
                    s + "Ou entrer un mot / une expression pour l'ajouter "
                    "en trigger.\n⏹ pour finir"
                )
                r = await tools.wait_for_react_clic(
                    mess,
                    emojis={(tools.emoji_chiffre(i) if i else "⏹"): str(i)
                            for i in range(len(trigs) + 1)},
                    process_text=True
                )

                if r == "0":
                    fini = True
                elif r.isdigit() and (n := int(r)) <= len(trigs):
                    config.session.delete(trigs[n - 1])
                    config.session.commit()
                    del trigs[n - 1]
                else:
                    new_trig = Trigger(trigger=r, reaction=reac)
                    trigs.append(new_trig)
                    config.session.add(trig)
                    config.session.commit()

            if not trigs:        # on a tout supprimé !
                await ctx.send(
                    "Tous les triggers supprimés, suppression de la réaction"
                )
                config.session.delete(reac)
                config.session.commit()
                return

        elif choix == 2:            # Modification de la réponse
            if any([mark in reac.reponse for mark in MARKS]):
                # Séquence compliquée
                await ctx.send(
                    "\nLa séquence-réponse peut être refaite manuellement "
                    "ou modifiée rapidement en envoyant directment la "
                    "séquence ci-dessus modifiée (avec les marqueurs : "
                    f"OU = {tools.code(MARK_OR)}, "
                    f"ET = {tools.code(MARK_THEN)}, "
                    f"REACT = {tools.code(MARK_REACT)}, "
                    f"CMD = {tools.code(MARK_CMD)})"
                )

            reponse = await _build_sequence(ctx)
            reac.reponse = reponse

        else:                       # Suppression
            config.session.delete(reac)
            for trig in trigs:
                config.session.delete(trig)

        config.session.commit()

        await ctx.send("Fini.")


async def trigger_at_mj(message):
    """Règle d'IA : réaction si le message mentionne les MJs.

    Args:
        message (~discord.Message): message auquel réagir.

    Returns:
        - ``True`` -- si le message mentionne les MJ et qu'une réponse
          a été envoyée
        - ``False`` -- sinon
    """
    if config.Role.mj in message.role_mentions:
        await message.channel.send(
            "Les MJs ont entenu ton appel, ils sont en route ! :superhero:"
        )
        return True

    return False


async def trigger_roles(message, sensi=0.8):
    """Règle d'IA : réaction si un nom de rôle est donné.

    Args:
        message (~discord.Message): message auquel réagir.
        sensi (float): sensibilité de la recherche
            (voir :meth:`.bdd.base.TableMeta.find_nearest`).

    Trouve l'entrée la plus proche de ``message.content`` dans la table
    :class:`.bdd.Role`.

    Returns:
        - ``True`` -- si un rôle a été trouvé (sensibilité ``> sensi``)
          et qu'une réponse a été envoyée
        - ``False`` -- sinon
    """
    roles = Role.find_nearest(message.content, col=Role.nom,
                              filtre=(Role.actif.is_(True)), sensi=sensi)

    if roles:       # Au moins un trigger trouvé à cette sensi
        await message.channel.send(embed=roles[0][0].embed)
        return True

    return False


async def trigger_reactions(message, chain=None, sensi=0.7, debug=False):
    """Règle d'IA : réaction à partir de la table :class:`.bdd.Reaction`.

    Args:
        message (~discord.Message`): message auquel réagir.
        chain (str): contenu auquel réagir (défaut : contenu de
            ``message``).
        sensi (float): sensibilité de la recherche (cf
            :meth:`.bdd.base.TableMeta.find_nearest`).
        debug (bool): si ``True``, affiche les erreurs lors de
            l'évaluation des messages (voir :func:`.tools.eval_accols`).

    Trouve l'entrée la plus proche de ``chain`` dans la table
    :class:`.bdd.Reaction` ; si il contient des accolades, évalue le
    message selon le contexte de ``message``.

    Returns:
        - ``True`` -- si une réaction a été trouvé (sensibilité
          ``> sensi``) et qu'une réponse a été envoyée
        - ``False`` -- sinon
    """
    if not chain:                   # Si pas précisé,
        chain = message.content         # contenu de message
    trigs = Trigger.find_nearest(chain, col=Trigger.trigger, sensi=sensi)

    if trigs:       # Au moins un trigger trouvé à cette sensi
        trig = trigs[0][0]                  # Meilleur trigger (score max)
        seq = trig.reaction.reponse         # Séquence-réponse associée

        for rep in seq.split(MARK_THEN):        # Pour chaque étape :
            if MARK_OR in rep:
                # Si plusieurs possiblités, on en choisit une random
                rep = random.choice(rep.split(MARK_OR))

            if rep.startswith(MARK_REACT):          # Réaction
                react = rep.lstrip(MARK_REACT)
                emoji = tools.emoji(react, must_be_found=False) or react
                await message.add_reaction(emoji)

            elif rep.startswith(MARK_CMD):          # Commande
                message.content = rep.replace(MARK_CMD,
                                              config.bot.command_prefix)
                # Exécution de la commande
                await config.bot.process_commands(message)

            else:                                   # Sinon, texte / média
                # On remplace tous les "{expr}" par leur évaluation
                rep = tools.eval_accols(rep, locals_=locals(), debug=debug)
                await message.channel.send(rep)

        return True

    return False


async def trigger_sub_reactions(message, sensi=0.9, debug=False):
    """Règle d'IA : réaction à partir de la table, mais sur les mots

    Appelle :func:`trigger_reactions(bot, message, mot, sensi, debug)
    <.trigger_reactions>` pour tous les mots ``mot`` composant
    ``message.content`` (mots de plus de 4 lettres, testés des plus
    longs aux plus courts).

    Args:
        message (~discord.Message`): message auquel réagir.
        sensi (float): sensibilité de la recherche (cf
            :meth:`.bdd.base.TableMeta.find_nearest`).
        debug (bool): si ``True``, affiche les erreurs lors de
            l'évaluation des messages (voir :func:`.tools.eval_accols`).

    Returns:
        - ``True`` -- si une réaction a été trouvé (sensibilité
          ``> sensi``) et qu'une réponse a été envoyée
        - ``False`` -- sinon
    """
    mots = message.content.split(" ")
    if len(mots) > 1:       # Si le message fait plus d'un mot
        for mot in sorted(mots, key=lambda m: -len(m)):
            # On parcourt les mots du plus long au plus court
            if len(mot) > 4:        # on élimine les mots de liaison
                if await trigger_reactions(message, chain=mot,
                                           sensi=sensi, debug=debug):
                    # Si on trouve une sous-rect (à 0.9)
                    return True

    return False


async def trigger_di(message):
    """Règle d'IA : réaction aux messages en di... / cri...

    Args:
        message (~discord.Message): message auquel réagir.

    Returns:
        - ``True`` -- si le message correspond et qu'une réponse a été
          envoyée
        - ``False`` -- sinon
    """
    c = message.content
    diprefs = ["di", "dy", "dis ", "dit ", "dis-", "dit-"]
    criprefs = ["cri", "cry", "kri", "kry"]
    pos_prefs = {c.lower().find(pref): pref for pref in diprefs + criprefs
                 if pref in c[:-1].lower()}
    # On extrait les cas où le préfixe est à la fin du message

    if pos_prefs:           # Si on a trouvé au moins un préfixe
        i = min(pos_prefs)
        pref = pos_prefs[i]
        if pref in criprefs:
            mess = tools.bold(c[i + len(pref):].upper())
        else:
            mess = c[i + len(pref):]
        await message.channel.send(mess, tts=True)
        # On envoie le di.../cri... en mode TTS (oh si, c'est rigolo)
        return True

    return False


async def trigger_gif(message):
    """Règle d'IA : réaction par GIF en mode Foire à la saucisse.

    Args:
        message (~discord.Message): message auquel réagir

    Returns:
        - ``True`` -- si le message correspond et qu'une réponse a été
          envoyée
        - ``False`` -- sinon
    """
    if message.channel.id in config.bot.in_fals:
        # Chan en mode Foire à la saucisse
        async with message.channel.typing():
            gif = fetch_tenor(message.content)
            if gif:
                await message.channel.send(gif)
                return True

    return False


async def trigger_mot_unique(message):
    """Règle d'IA : réaction à un mot unique (le répète).

    Args:
        message (~discord.Message): message auquel réagir.

    Returns:
        - ``True`` -- si le message correspond et qu'une réponse a été
          envoyée
        - ``False`` -- sinon
    """
    if len(message.content.split()) == 1 and ":" not in message.content:
        # : pour ne pas trigger aux liens
        rep = f"{message.content.capitalize()} ?"
        await message.channel.send(rep)
        return True

    return False


async def trigger_a_ou_b(message):
    """Règle d'IA : réaction à un motif type « a ou b » (répond « b »).

    Args:
        message (~discord.Message): message auquel réagir.

    Returns:
        - ``True`` -- si le message correspond et qu'une réponse a été
          envoyée
        - ``False`` -- sinon
    """
    if (motif := re.fullmatch(r"(.+)\s+ou\s+(.+?)", message.content)):
        rep = f"{motif.group(2).rstrip(' !?.,;')}.".capitalize()
        await message.channel.send(rep)
        return True

    return False


async def default(message):
    """Règle d'IA : réponse par défaut

    Args:
        message (~discord.Message): message auquel réagir.

    Returns:
        - ``True`` -- si le message correspond et qu'une réponse a été
          envoyée
        - ``False`` -- sinon
    """
    mess = "Désolé, je n'ai pas compris :person_shrugging:"
    if random.random() < 0.05:
        mess += "\n(et toi, tu as perdu)"
    await message.channel.send(mess)
    return True


async def process_IA(message, debug=False):
    """Exécute les règles d'IA.

    Args:
        message (~discord.Message): message auquel réagir.
        debug (bool): si ``True``, affiche les erreurs lors de
            l'évaluation des messages (voir :func:`.tools.eval_accols`).
    """
    (
        await trigger_at_mj(message)            # @MJ (aled)
        or await trigger_gif(message)           # Un petit GIF ? (si FALS)
        or await trigger_roles(message)         # Rôles
        or await trigger_reactions(             # Table Reaction ("IA")
            message, debug=debug)
        or await trigger_sub_reactions(         # IA sur les mots
            message, debug=debug)
        or await trigger_a_ou_b(message)        # "a ou b" ==> "b"
        or await trigger_di(message)            # di... / cri...
        or await trigger_mot_unique(message)    # Un seul mot ==> on répète
        or await default(message)               # Réponse par défaut
    )
