import { TypedEmitter } from "tiny-typed-emitter";
import type { types } from "@constl/ipa";

import { v4 as uuidv4 } from "uuid";

import {
  MessageActionDIpa,
  MessageActionPourIpa,
  MessageConfirmationRéceptionRetourDIpa,
  MessageDIpa,
  MessageErreurDIpa,
  MessagePourIpa,
  MessageRetourPourIpa,
  MessageSuivrePourIpa,
  MessageSuivrePrêtDIpa,
} from "./messages.js";
import {
  ERREUR_EXÉCUTION_IPA,
  ERREUR_FONCTION_MANQUANTE,
  ERREUR_FORMAT_ARGUMENTS,
  ERREUR_INIT_IPA,
  ERREUR_INIT_IPA_DÉJÀ_LANCÉ,
  ERREUR_MESSAGE_INCONNU,
  ERREUR_MULTIPLES_FONCTIONS,
  ERREUR_PAS_UNE_FONCTION,
} from "./codes.js";
import { lorsque } from "./utils.js";

interface Tâche {
  idRequête: string;
  fSuivre: types.schémaFonctionSuivi<unknown>;
  fRetour: (fonction: string, args?: unknown[]) => Promise<void>;
}

class Callable extends Function {
  // Code obtenu de https://hackernoon.com/creating-callable-objects-in-javascript-d21l3te1
  //@ts-expect-error On ne peut pas appeller super() d'une fonction dans un contexte navigateur sécuritaire
  constructor() {
    const closure = function () {
      // Rien faire. Je ne comprends pas tout ça mais ça fonctionne.
    };
    return Object.setPrototypeOf(closure, new.target.prototype);
  }
}

export type ErreurMandataire = {
  code: string;
  erreur: string;
  idRequête?: string;
};

type ÉvénementsMandataire = {
  erreur: (e: ErreurMandataire) => void;
};

export abstract class Mandatairifiable extends Callable {
  dernièreErreur?: ErreurMandataire;
  événements: TypedEmitter<ÉvénementsMandataire>;
  événementsInternes: TypedEmitter<{
    [id: string]: (
      x:
        | MessageActionDIpa
        | MessageSuivrePrêtDIpa
        | MessageErreurDIpa
        | MessageConfirmationRéceptionRetourDIpa,
    ) => void;
  }>;
  tâches: { [key: string]: Tâche };

  constructor() {
    super();

    this.événements = new TypedEmitter<ÉvénementsMandataire>();
    this.événementsInternes = new TypedEmitter<{
      [id: string]: (
        x:
          | MessageActionDIpa
          | MessageSuivrePrêtDIpa
          | MessageErreurDIpa
          | MessageConfirmationRéceptionRetourDIpa,
      ) => void;
    }>();

    this.tâches = {};
  }

  __call__(
    fonction: string[],
    args: { [key: string]: unknown } = {},
  ): Promise<unknown> {
    if (typeof args !== "object")
      this.erreur({
        code: ERREUR_FORMAT_ARGUMENTS,
        erreur: `La fonction ${fonction.join(
          ".",
        )} fut appelée avec arguments ${args}. 
      Toute fonction mandataire Constellation doit être appelée avec un seul argument en format d'objet (dictionnaire).`,
      });
    const idRequête = uuidv4();
    const nomArgFonction = Object.entries(args).find(
      (x) => typeof x[1] === "function",
    )?.[0];

    if (nomArgFonction) {
      return this.appelerFonctionSuivre(
        idRequête,
        fonction,
        args,
        nomArgFonction,
      );
    } else {
      return this.appelerFonctionAction(idRequête, fonction, args);
    }
  }

  async appelerFonctionSuivre(
    idRequête: string,
    fonction: string[],
    args: { [key: string]: unknown },
    nomArgFonction: string,
  ): Promise<
    | types.schémaFonctionOublier
    | { [key: string]: (...args: unknown[]) => void }
  > {
    const f = args[nomArgFonction] as types.schémaFonctionSuivi<unknown>;
    const argsSansF = Object.fromEntries(
      Object.entries(args).filter((x) => typeof x[1] !== "function"),
    );

    // Vérifier format paramètres
    if (f === undefined) {
      this.erreur({
        code: ERREUR_FONCTION_MANQUANTE,
        erreur:
          "Aucun argument de nom " +
          nomArgFonction +
          " n'a été donnée pour " +
          fonction.join("."),
        idRequête,
      });
    }
    if (Object.keys(args).length > Object.keys(argsSansF).length + 1) {
      this.erreur({
        code: ERREUR_MULTIPLES_FONCTIONS,
        erreur:
          "Plus d'un argument pour " +
          fonction.join(".") +
          " est une fonction : " +
          JSON.stringify(args),
        idRequête,
      });
    } else if (typeof f !== "function") {
      this.erreur({
        code: ERREUR_PAS_UNE_FONCTION,
        erreur: "Argument " + nomArgFonction + "n'est pas une fonction : ",
        idRequête,
      });
    }

    const message: MessageSuivrePourIpa = {
      type: "suivre",
      idRequête,
      fonction,
      args: argsSansF,
      nomArgFonction,
    };

    const fRetour = async (fonction: string, args?: unknown[]) => {
      const idRetour = uuidv4();
      const messageRetour: MessageRetourPourIpa = {
        type: "retour",
        idRequête,
        fonction,
        idRetour,
        args,
      };

      const lorsqueRetour = lorsque(this.événementsInternes, idRetour);
      this.envoyerMessageÀIpa(messageRetour);

      const retour = await lorsqueRetour;
      if (retour.type === "erreur") {
        this.erreur({
          erreur: retour.erreur,
          idRequête,
          code: retour.codeErreur || ERREUR_EXÉCUTION_IPA,
        });
      }
      if (retour.type !== "confirmation") {
        this.erreur({
          erreur: `Type de retour ${JSON.stringify(retour)} non reconnu.`,
          idRequête,
          code: ERREUR_EXÉCUTION_IPA,
        });
      }
    };

    const tâche: Tâche = {
      idRequête,
      fSuivre: f,
      fRetour,
    };
    this.tâches[idRequête] = tâche;

    const fOublierTâche = async () => {
      await this.oublierTâche(idRequête);
    };

    const lorsqueRetour = lorsque(this.événementsInternes, idRequête);

    this.envoyerMessageÀIpa(message);

    const retour = await lorsqueRetour;

    if (retour.type === "erreur") {
      this.erreur({
        erreur: retour.erreur,
        idRequête,
        code: retour.codeErreur || ERREUR_EXÉCUTION_IPA,
      });
    }

    if (retour.type === "suivrePrêt") {
      const { fonctions } = retour;
      if (fonctions && fonctions[0]) {
        const retour: { [key: string]: (...args: unknown[]) => Promise<void> } =
          {
            fOublier: fOublierTâche,
          };
        for (const f of fonctions) {
          retour[f] = async (...args: unknown[]) => {
            await this.tâches[idRequête]?.fRetour(f, args);
          };
        }
        return retour;
      }
    }
    return fOublierTâche;
  }

  async appelerFonctionAction<T>(
    idRequête: string,
    fonction: string[],
    args: { [key: string]: unknown },
  ): Promise<T> {
    const message: MessageActionPourIpa = {
      type: "action",
      idRequête,
      fonction,
      args: args,
    };

    const lorsqueRetour = lorsque(this.événementsInternes, idRequête);

    this.envoyerMessageÀIpa(message);

    const retour = await lorsqueRetour;
    if (retour.type === "action") {
      return retour.résultat as T;
    } else if (retour.type === "erreur") {
      this.erreur({
        erreur: retour.erreur,
        idRequête,
        code: retour.codeErreur || ERREUR_EXÉCUTION_IPA,
      });
    } else {
      this.erreur({
        erreur: `Type de retour ${retour} non reconnu.`,
        idRequête,
        code: ERREUR_EXÉCUTION_IPA,
      });
    }
    throw new Error("On ne devrait jamais arriver ici.");
  }

  erreur({
    erreur,
    code,
    idRequête,
  }: {
    erreur: string;
    code: string;
    idRequête?: string;
  }): void {
    // Si l'IPA n'a pas bien été initialisée, toutes les autres erreurs sont pas très importantes
    if (
      this.dernièreErreur?.code !== ERREUR_INIT_IPA &&
      this.dernièreErreur?.code !== ERREUR_INIT_IPA_DÉJÀ_LANCÉ
    ) {
      this.dernièreErreur = { erreur, idRequête, code };
    }
    this.événements.emit("erreur", { idRequête, ...this.dernièreErreur });
    throw new Error(JSON.stringify({ idRequête, ...this.dernièreErreur }));
  }

  async oublierTâche(idRequête: string): Promise<void> {
    const tâche = this.tâches[idRequête];
    if (tâche) await tâche.fRetour("fOublier");
    delete this.tâches[idRequête];
  }

  abstract envoyerMessageÀIpa(message: MessagePourIpa): void;

  async recevoirMessageDIpa(message: MessageDIpa): Promise<void> {
    const { type } = message;
    switch (type) {
      case "suivre": {
        const { idRequête, données } = message;
        if (!this.tâches[idRequête]) return;
        const { fSuivre } = this.tâches[idRequête];
        await fSuivre(données);
        break;
      }
      case "action":
      case "suivrePrêt":
      case "erreur": {
        if (message.type === "erreur" && !message.idRequête) {
          this.erreur({
            erreur: message.erreur,
            code: message.erreur || ERREUR_EXÉCUTION_IPA,
          });
          break;
        }
        this.événementsInternes.emit(message.idRequête!, message);
        break;
      }
      case "confirmation":
        // Important - on utiliser l'id du message de retour
        this.événementsInternes.emit(message.idRetour, message);
        break;
      default: {
        this.erreur({
          code: ERREUR_MESSAGE_INCONNU,
          erreur: `Type inconnu ${type} du message ${message}.`,
          idRequête: (message as MessageDIpa).idRequête,
        });
      }
    }
  }

  // Fonctions publiques
  suivreErreurs({
    f,
  }: {
    f: (x: ErreurMandataire | undefined) => void;
  }): types.schémaFonctionOublier {
    this.événements.on("erreur", f);
    f(this.dernièreErreur);
    return async () => {
      this.événements.off("erreur", f);
    };
  }
}

class Handler {
  listeAtributs: string[];

  constructor(listeAtributs?: string[]) {
    this.listeAtributs = listeAtributs || [];
  }

  get(obj: Mandatairifiable, prop: string): unknown {
    // Inscrire ici les fonctions publiques du mandataire qui ne
    // doivent pas être envoyées à Constellation
    const directes = ["suivreErreurs"];
    if (directes.includes(prop)) {
      return obj[prop as keyof Mandatairifiable].bind(obj);
    } else {
      const listeAtributs = [...this.listeAtributs, prop];
      const h = new Handler(listeAtributs);
      return new Proxy(obj, h);
    }
  }

  apply(
    target: Mandatairifiable,
    _thisArg: Handler,
    args: [{ [key: string]: unknown }],
  ) {
    return target.__call__(this.listeAtributs, args[0]);
  }
}

export type MandataireConstellation<T> = Required<T> & Mandatairifiable;

export const générerMandataire = <T>(
  mandataireClient: Mandatairifiable,
): MandataireConstellation<T> => {
  const handler = new Handler();
  return new Proxy<Mandatairifiable>(
    mandataireClient,
    handler,
  ) as MandataireConstellation<T>;
};
