import { isAsync } from './mel.js';
import { BnumEvent as JsEvent } from './mel_events.js';

export { BnumPromise };

/**
 * Contient les classes utiles aux promesses du Bnum
 * @module BnumPromise
 * @tutorial bnum-promise
 * @local PromiseManager
 * @local PromiseManagerAsync
 * @local PromiseCallback
 * @local PromiseCallbackAsync
 * @local BnumPromise
 * @local EPromiseState
 * @local CheckStateCallback
 * @local ResolvingState
 * @local BnumAjax
 * @local EAjaxMethod
 * @local MethodCallback
 * @local SuccessCallback
 * @local ErrorCallback
 * @local BnumResolvedPromise
 * @local ResolvingCallback
 */

/**
 * @callback ResolvingCallback
 * @param {T} why
 * @return {unknown}
 * @template T
 */

/**
 * @callback SuccessCallback
 * @param {D} data
 * @returns {R}
 * @template D
 * @template R
 */

/**
 * @callback ErrorCallback
 * @param {...any} args
 * @return {any[]}
 */

/**
 * @callback MethodCallback
 * @return {void}
 */

/**
 * @callback CheckStateCallback
 * @returns {EPromiseState}
 */

/**
 * Permet de savoir l'état de la promise et de pouvoir résoudre ou non la promesse
 * @template T
 * @typedef PromiseManager
 * @property {ResolvingState<T> | null | undefined} resolver Null on async function
 * @property {CheckStateCallback} state
 */

/**
 * @typedef PromiseManagerAsync
 * @property {CheckStateCallback} state
 */

/**
 * @template T
 * @callback PromiseCallback
 * @param {PromiseManager<T>} manager
 * @param {...any} args
 * @return {T}
 */

/**
 * @async
 * @template T
 * @callback PromiseCallbackAsync
 * @param {PromiseManager<T>} manager
 * @param {...any} args
 * @return {BnumPromise<T> | Promise<T>}
 */

/**
 * @class
 * @classdesc
 * @template {Object} T
 * @tutorial bnum-promise
 */
class BnumPromise {
  #_childs = [];
  #_callback;
  /**
   * @type {EPromiseState}
   */
  #_state;
  #_promise;
  #_cancel_completed;
  #_args;

  /**
   *
   * @param {PromiseCallback<T> | PromiseCallbackAsync<T>} callback
   * @param  {...any} args Arguments qui seront envoyé au callback
   * @frommoduleparam BnumPromise callback {@linkto PromiseCallbackAsync}
   */
  constructor(callback, ...args) {
    this.#_callback = callback;
    this.#_state = EPromiseState.pending;
    this.#_cancel_completed = false;
    this.#_args = args;

    /**
     * @type {JsEvent<() => void>}
     * @event
     */
    this.onAbort = new JsEvent();
    /**
     * @type {JsEvent<(signal:any, ...args) => void>}
     * @event
     */
    this.onSignalChanged = new JsEvent();
  }

  /**
   * Envoi un signal qui lancera {@link BnumPromise.onSignalChanged}
   * @param {any} signal
   * @param  {...any} args
   * @fires BnumPromise.onSignalChanged
   */
  setSignal(signal, ...args) {
    this.onSignalChanged.call(signal, ...args);
  }

  /**
   * Etat courant de la promesse
   * @type {EPromiseState}
   * @readonly
   */
  get state() {
    return this.#_state;
  }

  /**
   * Si la promesse a commencé ou non
   * @type {boolean}
   * @readonly
   */
  get isStarted() {
    return !!this.#_promise;
  }

  /**
   *
   * @param {BnumPromise} promise_creator
   * @param {*} param1
   * @returns {BnumPromise}
   * @private
   */
  #_create_promises(promise_creator, { onAbort = () => {} } = {}) {
    let deleted = false;
    let tmp = promise_creator();
    tmp.onAbort.push(onAbort);

    if (tmp.start) tmp.start();

    const index = this.#_childs.push(tmp) - 1;
    const key = this.onAbort.push(async () => {
      console.log('ABORT !', tmp);
      await tmp.abort();
      if (this.onAbort.events[key]) this.onAbort.remove(key);

      if (!deleted) {
        deleted = true;
        this.#_childs = this.#_childs.filter((x, i) => i !== index);
      }
    });

    tmp.always(() => {
      if (!deleted) {
        deleted = true;
        this.#_childs = this.#_childs.filter((x, i) => i !== index);
      }
    });

    return tmp;
  }

  /**
   * Créer une sous promesse fille
   * @async
   * @param {Object} options
   * @param {PromiseCallback<Y> | PromiseCallbackAsync<Y>} options.callback
   * @param {MethodCallback} [options.onAbort=()=>{}]
   * @param  {...any} args Arguments à donner à la nouvelle promesse
   * @returns {BnumPromise<Y>}
   * @template Y
   * @frommoduleparam BnumPromise options.callback {@linkto PromiseCallbackAsync}
   */
  createPromise({ callback, onAbort = () => {} }, ...args) {
    const promiseCreator = () => {
      return new BnumPromise(callback, ...args);
    };

    return this.#_create_promises(
      promiseCreator,
      { callback, onAbort },
      ...args,
    );
  }

  /**
   * Créer une requête ajax fille
   * @async
   * @param {string} url Url de la requête
   * @param {Object} [options={}]
   * @param {EAjaxMethod} [options.type=EAjaxMethod.get] Type de la requête
   * @param {SuccessCallback<D, Y>} [options.success=(d)=>d] Action à faire si tout se déroule bien.
   * @param {ErrorCallback} [options.failed=(...args)=>args] Action à faire si la la requête à échoué.
   * @param {MethodCallback} [options.onAbort=()=>{}] Action à faire lorsque la variable mère s'arrête
   * @param {Object<string, any> | string | null} [options.data=null] Body de la requête. Ne fonctionne pas avec get.
   * @returns {BnumPromise<Y>}
   * @template D
   * @template Y
   */
  createAjaxRequest(
    url,
    {
      type = BnumAjax.EAjaxMethod.get,
      success = (d) => d,
      failed = (...args) => args,
      onAbort = () => {},
      data = null,
    } = {},
  ) {
    const promiseCreator = () => {
      return BnumAjax.Call(url, { type, success, failed, data });
    };
    return this.#_create_promises(promiseCreator, {
      onAbort,
    });
  }

  /**
   * Créer une requête ajax "post" fille
   * @async
   * @param {string} url Url de la requête
   * @param {Object} [options={}]
   * @param {SuccessCallback<D, Y>} [options.success=(d)=>d] Action à faire si tout se déroule bien.
   * @param {ErrorCallback} [options.failed=(...args)=>args] Action à faire si la la requête à échoué.
   * @param {MethodCallback} [options.onAbort=()=>{}] Action à faire lorsque la variable mère s'arrête
   * @param {Object<string, any> | string | null} [options.data=null] Body de la requête.
   * @returns {BnumPromise<Y>}
   * @template D
   * @template Y
   */
  createAjaxPostRequest(
    url,
    { success = () => {}, failed = () => {}, onAbort = () => {}, data = null },
  ) {
    return this.createAjaxRequest({
      type: EAjaxMethod.post,
      url,
      success,
      failed,
      onAbort,
      data,
    });
  }

  /**
   * Créer une requête ajax "get" fille
   * @async
   * @param {string} url Url de la requête
   * @param {Object} [options={}]
   * @param {SuccessCallback<D, Y>} [options.success=(d)=>d] Action à faire si tout se déroule bien.
   * @param {ErrorCallback} [options.failed=(...args)=>args] Action à faire si la la requête à échoué.
   * @param {MethodCallback} [options.onAbort=()=>{}] Action à faire lorsque la variable mère s'arrête
   * @returns {BnumPromise<Y>}
   * @template D
   * @template Y
   */
  createAjaxGetRequest(
    url,
    { success = () => {}, failed = () => {}, onAbort = () => {} },
  ) {
    return this.createAjaxRequest({
      type: EAjaxMethod.get,
      url,
      success,
      failed,
      onAbort,
    });
  }

  /**
   * Récupère une promesse qui attend que toute les promesses filles ont finie d'être résolue.
   * @returns {BnumPromise<Array<PromiseSettledResult<any>>>}
   */
  awaitAllChilds() {
    return BnumPromise.AllSettled(...this.#_childs);
  }

  *allChildGenerator() {
    yield* this.#_childs;
  }

  /**
   * Si la promesse est en cours
   * @returns {boolean}
   */
  isPending() {
    return this.state === EPromiseState.pending;
  }

  /**
   * Si la promesse est résolue sans erreur
   * @returns {boolean}
   */
  isResolved() {
    return this.state === EPromiseState.resolved;
  }

  /**
   * Si la promesse à planter ou a été rejetée.
   * @returns {boolean}
   */
  isRejected() {
    return this.state === EPromiseState.rejected;
  }

  /**
   * Si la promesse a été annulée
   * @returns {boolean}
   */
  isCancelled() {
    return this.state === EPromiseState.cancelled;
  }

  /**
   * Arrète la promesse.
   * @returns {BnumPromise<boolean>}
   * @async
   * @fires BnumPromise.onAbort
   */
  abort() {
    this.#_state = EPromiseState.cancelled;

    return new BnumPromise(async () => {
      await new Promise((r, j) => {
        let it = 0;
        const interval = setInterval(() => {
          if (this.#_cancel_completed) {
            clearInterval(interval);
            r(it);
          } else if (it++ > 100) {
            clearInterval(interval);
            j(new Error('Wainting infinite'));
          }
        }, 100);
      });

      return this.#_cancel_completed;
    });
  }

  async #_startPromise() {
    return await new Promise((ok, nok) => {
      const callback = this.#_callback;
      const isAbortablePromise = !!callback.then && !!callback.abort;

      let waiting_promise;

      //Stop la fonction si elle à besoin d'être stoppée
      const check_stop = setInterval(() => {
        if (this.isCancelled() === true) {
          console.info(
            'i[RotomecaPromise]cancelled !',
            waiting_promise,
            callback,
          );
          clearInterval(check_stop);

          if (isAbortablePromise) callback.abort();
          if (waiting_promise?.abort) waiting_promise.abort();

          new Promise((r, j) => {
            try {
              this.onAbort.call();
              this.#_cancel_completed = true;
              r();
            } catch (error) {
              this.#_cancel_completed = true;
              j(error);
            }
          });
          nok(BnumPromise.PromiseStates.cancelled);
        }
      }, 100);

      try {
        if (isAbortablePromise) {
          //Si c'est une promesse
          waiting_promise = callback;
        } else {
          //Si la fonction est asynchrone
          if (callback.constructor.name === 'AsyncFunction')
            waiting_promise = callback(
              { state: () => this.state },
              ...this.#_args,
            );
          else {
            let resolver = new ResolvingState(ok, nok, check_stop);
            this.onSignalChanged.add('resolver', (signal, args) => {
              switch (signal) {
                case EPromiseState.pending:
                  resolver.start();
                  break;

                case EPromiseState.resolved:
                  resolver.resolve(args);
                  break;

                case EPromiseState.rejected:
                  resolver.reject(args);
                  break;

                default:
                  break;
              }
            });

            //Si c'est une fonction + classique
            const val = callback(
              { resolver, state: () => this.state },
              ...this.#_args,
            );

            if (val?.then) waiting_promise = val;
            else {
              if (!resolver.resolving) {
                clearInterval(check_stop);
                ok(val);
              }
            }
          }
        }

        if (waiting_promise) {
          waiting_promise.then(
            (datas) => {
              clearInterval(check_stop);
              ok(datas);
            },
            (error) => {
              clearInterval(check_stop);
              nok(error);
            },
          );
        }
      } catch (error) {
        console.error('###[RotomecaPromise]', error);
        nok(error);
      }
    }).then(
      (d) => {
        this.onSignalChanged.remove('resolver');
        this.#_state = EPromiseState.resolved;
        return d;
      },
      (r) => {
        this.onSignalChanged.remove('resolver');
        this.#_state =
          r === EPromiseState.cancelled ? r : EPromiseState.rejected;
        console.error('Promise Rejected : ', r);
        return r;
      },
    );
  }

  /**
   * Démarre la promesse.
   * @returns {BnumPromise<T>} Chaînage
   */
  start() {
    if (!this.isStarted) this.#_promise = this.#_startPromise();
    else console.warn('/!\\[RotomecaPromise] Already started !');

    return this;
  }

  async executor() {
    if (!this.isStarted) this.start();

    return await this.#_promise;
  }

  /**
   * Action à faire ensuite.
   * @param {SuccessCallback<T, Y>} onfullfiled
   * @param {ErrorCallback} onerror
   * @returns {BnumPromise<Y>}
   * @template Y
   * @async
   */
  then(onfullfiled, onerror = (data) => data) {
    const promise = this.executor();
    const value = promise.then.apply(promise, [onfullfiled, onerror]);
    return new BnumPromise(() => value).start();
  }

  /**
   * Action lorsque la promesse plante
   * @param {SuccessCallback<T, Y>} onrejected
   * @returns {BnumPromise<Y>}
   * @template Y
   * @async
   */
  catch(onrejected = (data) => data) {
    const promise = this.executor();
    const catched = promise.catch.apply(promise, [onrejected]);
    return new BnumPromise(() => catched).start();
  }

  /**
   * Action au succès
   * @param {SuccessCallback<T, Y>} onSuccess
   * @returns {BnumPromise<Y>}
   * @template Y
   * @async
   */
  success(onSuccess) {
    return this.then(onSuccess);
  }

  /**
   * Action si rejeté
   * @param {SuccessCallback<T, Y>} onSuccess
   * @returns {BnumPromise<Y>}
   * @template Y
   * @async
   */
  fail(onFailed) {
    return this.then(() => {}, onFailed);
  }

  /**
   * Action à faire quoi qu'il arrive
   * @param {SuccessCallback<T, Y>} onSuccess
   * @returns {BnumPromise<Y>}
   * @template Y
   * @async
   */
  always(onAlways) {
    return this.then(onAlways, onAlways);
  }

  /**
   * Attend qu'un callback renvoit "vrai".
   * @async
   * @param {(...args:any[]) => boolean | (...args:any[]) => (Promise<boolean> | BnumPromise<boolean> | Mel_Promise<boolean>)} whatIWait
   * @param {Object} [options={}]
   * @param {number} [options.timeout=5] Au bout de combien de secondes la boucle s'arrête
   * @returns {BnumPromise<{resolved:boolean, msg:(string | undefined)}>}
   */
  static Wait(whatIWait, { timeout = 5 } = {}) {
    return new BnumPromise(
      function (manager, callback, seconds) {
        manager.resolver.start();
        new BnumPromise(
          async function (_, parentManager, parentCallback, parentSeconds) {
            try {
              let data = null;
              let it = 0;
              while (
                isAsync(parentCallback)
                  ? !(await parentCallback())
                  : !parentCallback() && it < parentSeconds * 100
              ) {
                if (parentManager.state() === EPromiseState.cancelled) {
                  return {
                    resolved: false,
                    msg: 'aborted',
                  };
                }

                await BnumPromise.Sleep(100);
                ++it;
              }

              it = null;

              if (it >= parentSeconds * 100)
                data = {
                  resolved: false,
                  msg: `timeout : ${parentSeconds * 10}ms`,
                };
              else data = { resolved: true };

              return data;
            } catch (error) {
              parentManager.resolver.reject(error);
            }
          },
          manager,
          callback,
          seconds,
        ).success((value) => {
          manager.resolver.resolve(value);
        });
      },
      whatIWait,
      timeout,
    ).start();
  }

  /**
   * Attend x millisecondes avant de continuer
   * @param {number} ms Millisecondes
   * @returns {BnumPromise<void>}
   * @static
   * @async
   */
  static Sleep(ms) {
    return new BnumStartedPromise((manager, ms) => {
      manager.resolver.start();
      setTimeout(() => {
        manager.resolver.resolve();
      }, ms);
    }, ms);
  }

  /**
   * Récupère une promesse résolue
   * @returns {BnumResolvedPromise}
   * @async
   * @static
   */
  static Resolved() {
    return new BnumResolvedPromise();
  }

  /**
   * "Creates a Promise that is resolved with an array of results when all of the provided Promises resolve, or rejected when any Promise is rejected"
   * @async
   * @param  {...(BnumPromise | Promise)} promises
   * @returns {BnumPromise<any[]>}
   */
  static All(...promises) {
    return new BnumStartedPromise(async () => await Promise.all(promises));
  }

  /**
   * "Creates a Promise that is resolved with an array of results when all of the provided Promises resolve or reject"
   * @async
   * @param  {...(BnumPromise | Promise)} promises
   * @returns {BnumPromise<Array<PromiseSettledResult<any>>>}
   */
  static AllSettled(...promises) {
    return new BnumStartedPromise(
      async () => await Promise.allSettled(promises),
    );
  }

  /**
   * Créer un démarre une promesse bnum.
   * @param {PromiseCallback<Y> | PromiseCallbackAsync<Y>} callback
   * @param  {...any} args
   * @returns {BnumPromise<Y>}
   * @template {Object} Y
   */
  static Start(callback, ...args) {
    return new BnumStartedPromise(callback, ...args);
  }

  /**
   * @type {typeof EPromiseState}
   * @readonly
   * @static
   */
  static get PromiseStates() {
    return EPromiseState;
  }

  /**
   * Fonctions Ajax
   * @type {typeof BnumAjax}
   * @readonly
   * @frommodule BnumPromise {@linkto BnumAjax}
   */
  static get Ajax() {
    return BnumAjax;
  }
}

/**
 * @class
 * @classdesc Donne des fonctions utile pour les appels ajax.
 * @hideconstructor
 */
class BnumAjax {
  /**
   * Lance un appel ajax
   * @async
   * @param {string} url Url à atteindre
   * @param {Object} [options={}]
   * @param {EAjaxMethod} [options.type=EAjaxMethod.get] Type de requête
   * @param {(data:T) => Y} [options.success=(d)=>d]  Action si la requête réussie.
   * @param {(...args:any[]) => any[]} [options.failed] Action si la requête échoue.
   * @param {Object<string, string | number | boolean> | string | null | undefined} [options.data=null] Données à envoyer, ne fonctionne pas avec "get"
   * @returns {BnumPromise<Y>}
   * @static
   * @template T
   * @template Y
   */
  static Call(
    url,
    {
      type = this.EAjaxMethod.get,
      success = (d) => d,
      failed = (...a) => a,
      data = null,
    } = {},
  ) {
    if (typeof type === 'symbol') {
      type = Object.keys(EAjaxMethod)
        .find((x) => EAjaxMethod[x] === type)
        .toUpperCase();
    }

    let parameters = { type, url, success, failed };

    if (data) parameters.data = data;

    return new BnumPromise((manager, config) => {
      manager.resolver.start();
      $.ajax(config)
        .done((d) => manager.resolver.resolve(d))
        .fail((...args) => manager.resolver.reject(args));
    }, parameters).start();
  }

  /**
   * Lance un appel ajax "GET"
   * @async
   * @param {string} url Url à atteindre
   * @param {Object} [options={}]
   * @param {(data:T) => Y} [options.success=(d)=>d]  Action si la requête réussie.
   * @param {(...args:any[]) => any[]} [options.failed] Action si la requête échoue.
   * @returns {BnumPromise<Y>}
   * @static
   * @template T
   * @template Y
   */
  static Get(url, { success = () => {}, failed = () => {} } = {}) {
    return this.Call(url, {
      type: this.EAjaxMethod.get,
      success,
      failed,
    });
  }

  /**
   * Lance un appel ajax "POST"
   * @async
   * @param {string} url Url à atteindre
   * @param {Object} [options={}]
   * @param {(data:T) => Y} [options.success=(d)=>d]  Action si la requête réussie.
   * @param {(...args:any[]) => any[]} [options.failed] Action si la requête échoue.
   * @param {Object<string, string | number | boolean> | string | null | undefined} [options.data=null] Données à envoyer, ne fonctionne pas avec "get"
   * @returns {BnumPromise<Y>}
   * @static
   * @template T
   * @template Y
   */
  static Post(
    url,
    { success = () => {}, failed = () => {}, data = null } = {},
  ) {
    return this.Call(url, {
      type: this.EAjaxMethod.post,
      success,
      failed,
      data,
    });
  }

  /**
   * Type de méthodes ajax disponibles
   * @type {typeof EAjaxMethod}
   * @readonly
   * @static
   */
  static get EAjaxMethod() {
    return EAjaxMethod;
  }
}

/**
 * @class
 * @classdesc Démarre immédiatement la promesse
 * @extends BnumPromise<T>
 * @template {Object} T
 */
class BnumStartedPromise extends BnumPromise {
  /**
   *
   * @param {PromiseCallback<T> | PromiseCallbackAsync<T>} callback
   * @param  {...any} args
   */
  constructor(callback, ...args) {
    super(callback, ...args);
    this.start();
  }
}

/**
 * @class
 * @classdesc Représente une promesse résolue
 * @extends BnumStartedPromise<void>
 */
class BnumResolvedPromise extends BnumStartedPromise {
  constructor() {
    super(() => {});
  }
}

/**
 * @class
 * @classdesc Permet de résoudre une fonction synchrone de manière asynchrone
 * @template {any} T
 * @template {any} E
 */
class ResolvingState {
  #_resolving = false;

  #_ok = null;
  #_nok = null;
  #_timeout = null;
  /**
   *
   * @param {ResolvingCallback<T>} ok Fonction qui permet de marquer la promesse comme résolue
   * @param {ResolvingCallback<E>} nok Fonction qui permet de marquer la promesse comme erreur
   * @param {number} timeout Id du timeout à arreter
   */
  constructor(ok, nok, timeout) {
    this.#_ok = ok;
    this.#_nok = nok;
    this.#_timeout = timeout;
  }

  /**
   * Indique que l'on attend une résolution asynchrone
   * @returns {ResolvingState<T, E>} Chaînage
   */
  start() {
    this.#_resolving = true;
    return this;
  }

  /**
   * Est-ce qu'on attend une résolution asynchrone ?
   * @type {boolean}
   * @readonly
   */
  get resolving() {
    return this.#_resolving;
  }

  /**
   * Indique la promesse comme résolue
   * @param {?T} [data=null] Donnée à envoyer
   * @return {?T}
   */
  resolve(data = null) {
    clearInterval(this.#_timeout);
    this.#_ok(data);
    return data;
  }

  /**
   * Indique la promesse comme rejetée
   * @param {?E} [why=null] Données à envoyer
   * @returns {?E}
   */
  reject(why = null) {
    clearInterval(this.#_timeout);
    this.#_nok(why);
    return why;
  }
}

/**
 * Liste des états d'une promesse. Utilisez {@link BnumPromise.PromiseStates} pour y accéder.
 * @enum {Symbol}
 * @property {Symbol} pending
 * @property {Symbol} rejected
 * @property {Symbol} resolved
 * @property {Symbol} cancelled
 * @package
 */
const EPromiseState = Object.freeze({
  pending: Symbol('pending'),
  rejected: Symbol('rejected'),
  resolved: Symbol('resolved'),
  cancelled: Symbol('canlcelled'),
});

/**
 * Liste des types d'appel ajax. Utilisez {@link BnumPromise.Ajax.EAjaxMethod} pour y accéder.
 * @enum {Symbol}
 * @property {Symbol} get
 * @property {Symbol} head
 * @property {Symbol} post
 * @property {Symbol} put
 * @property {Symbol} delete
 * @property {Symbol} connect
 * @property {Symbol} options
 * @property {Symbol} trace
 * @package
 */
const EAjaxMethod = Object.freeze({
  get: Symbol('get'),
  head: Symbol('head'),
  post: Symbol('post'),
  put: Symbol('put'),
  delete: Symbol('delete'),
  connect: Symbol('connect'),
  options: Symbol('options'),
  trace: Symbol('trace'),
});