/**
 * @file mixins/evented.js
 * @module evented
 */
import window from 'global/window' ;
import * as Dom from '../utils/dom' ;
import * as Events from '../utils/events' ;
import * as Fn from '../utils/fn' ;
import * as Obj from '../utils/obj' ;
import EventTarget from '../event-target' ;
import DomData from '../utils/dom-data' ;
import log from '../utils/log' ;

const objName = (obj) => {
  if (typeof obj.name === 'function') {
    return obj.name() ;
  }

  if (typeof obj.name === 'string') {
    return obj.name ;
  }

  if (obj.name_) {
    return obj.name_ ;
  }

  if (obj.constructor && obj.constructor.name) {
    return obj.constructor.name ;
  }

  return typeof obj ;
};

/**
 * Indique si un objet a été soumis ou non à l'application du mixage événementiel.
 *
 * @param {Object} object
 *         Un objet à tester.
 *
 * @return {boolean}
 *         Indique si l'objet semble ou non faire l'objet d'un événement.
 */
const isEvented = (objet) =>
  instanceof EventTarget ||
  !objet.eventBusEl_ &&
  ['on', 'one', 'off', 'trigger'].every(k => typeof object[k] === 'function') ;

/**
 * Ajoute un callback à exécuter après l'application du mixin événementiel.
 *
 * @param {Object} object
 *         Un objet à ajouter
 * @param {Fonction} callback
 *         Le rappel à exécuter.
 */
const addEventedCallback = (target, callback) => {
  if (isEvented(target)) {
    callback() ;
  } else {
    if (!target.eventedCallbacks) {
      target.eventedCallbacks = [] ;
    }
    target.eventedCallbacks.push(callback) ;
  }
};

/**
 * Si une valeur est un type d'événement valide - chaîne ou tableau non vide.
 *
 * @private
 * @param {string|Array} type
 *         La valeur du type à tester.
 *
 * @return {boolean}
 *         Indique si le type est un type d'événement valide ou non.
 */
const isValidEventType = (type) =>
  // La regex vérifie ici que le `type` contient au moins un non
  // caractère d'espacement.
  (typeof type === 'string' && (/\S/).test(type)) ||
  (Array.isArray(type) && !!type.length) ;

/**
 * Valide une valeur pour déterminer s'il s'agit d'une cible d'événement valide. Lance si ce n'est pas le cas.
 *
 * @private
 * @throws {Error}
 *         Si la cible ne semble pas être une cible d'événement valide.
 *
 * @param {Objet} target
 *         L'objet à tester.
 *
 * @param {Objet} obj
 *         L'objet événementiel que nous validons
 *
 * @param {string} fnName
 *         Le nom de la fonction mixin événementielle qui a appelé cette fonction.
 */
const validateTarget = (target, obj, fnName) => {
  if (!target || (!target.nodeName && !isEvented(target))) {
    throw new Error(`Invalid target for ${objName(obj)}#${fnName} ; must be a DOM node or evented object.`) ;
  }
};

/**
 * Valide une valeur pour déterminer s'il s'agit d'une cible d'événement valide. Lance si ce n'est pas le cas.
 *
 * @private
 * @throws {Error}
 *         Si le type ne semble pas être un type d'événement valide.
 *
 * @param {string|Array} type
 *         Le type à tester.
 *
 * @param {Objet} obj
*         L'objet événementiel que nous validons
 *
 * @param {string} fnName
 *         Le nom de la fonction mixin événementielle qui a appelé cette fonction.
 */
const validateEventType = (type, obj, fnName) => {
  if (!isValidEventType(type)) {
    throw new Error(`Type d'événement non valide pour ${objName(obj)}#${fnName} ; doit être une chaîne ou un tableau non vide.`) ;
  }
};

/**
 * Valide une valeur pour déterminer s'il s'agit d'un auditeur valide. Lance si ce n'est pas le cas.
 *
 * @private
 * @throws {Error}
 *         Si l'auditeur n'est pas une fonction.
 *
 * @param {Fonction} listener
 *         L'auditeur à tester.
 *
 * @param {Objet} obj
 *         L'objet événementiel que nous validons
 *
 * @param {string} fnName
 *         Le nom de la fonction mixin événementielle qui a appelé cette fonction.
 */
const validateListener = (listener, obj, fnName) => {
  if (typeof listener !== 'function') {
    throw new Error(`Invalid listener for ${objName(obj)}#${fnName} ; must be a function.`) ;
  }
};

/**
 * Prend un tableau d'arguments donnés à `on()` ou `one()`, les valide, et
 * les normalise en un objet.
 *
 * @private
 * @param {Objet} self
 *         L'objet événementiel sur lequel `on()` ou `one()` a été appelé. Cette
 *         sera lié en tant que valeur `this` pour l'écouteur.
 *
 * @param {Array} args
 *         Un tableau d'arguments passés à `on()` ou `one()`.
 *
 * @param {string} fnName
 *         Le nom de la fonction mixin événementielle qui a appelé cette fonction.
 *
 * @return {Object}
 *         Un objet contenant des valeurs utiles pour les appels `on()` ou `one()`.
 */
const normalizeListenArgs = (self, args, fnName) => {

  // Si le nombre d'arguments est inférieur à 3, la cible est toujours l'élément
  // l'objet événementiel lui-même.
  const isTargetingSelf = args.length < 3 || args[0] === self || args[0] === self.eventBusEl_ ;
  laisser la cible ;
  laisser le type ;
  laisser l'auditeur ;

  if (isTargetingSelf) {
    target = self.eventBusEl_ ;

    // Traiter les cas où nous avons 3 arguments, mais où nous écoutons toujours
    // l'objet événementiel lui-même.
    if (args.length >= 3) {
      args.shift() ;
    }

    [type, listener] = args ;
  } else {
    [target, type, listener] = args ;
  }

  validateTarget(target, self, fnName) ;
  validateEventType(type, self, fnName) ;
  validateListener(listener, self, fnName) ;

  listener = Fn.bind(self, listener) ;

  return {isTargetingSelf, target, type, listener} ;
};

/**
 * Ajoute l'auditeur au(x) type(s) d'événement(s) sur la cible, en normalisant pour
 * le type de cible.
 *
 * @private
 * @param {Element|Objet} target
 *         Un nœud DOM ou un objet événementiel.
 *
 * @param {string} method
 *         La méthode de liaison des événements à utiliser ("on" ou "one").
 *
 * @param {string|Array} type
 *         Un ou plusieurs types d'événements.
 *
 * @param {Fonction} listener
 *         Une fonction d'écoute.
 */
const listen = (target, method, type, listener) => {
  validateTarget(target, target, method) ;

  if (target.nodeName) {
    Events[method](target, type, listener) ;
  } else {
    target[method](type, listener) ;
  }
};

/**
 * Contient des méthodes qui fournissent des capacités événementielles à un objet qui lui est transmis
 * à {@link module:evented|evented}.
 *
 * @mixin EventedMixin
 */
const EventedMixin = {

  /**
   * Ajouter un auditeur à un (ou plusieurs) événement(s) sur cet objet ou sur un autre objet événementiel
   * objet.
   *
   * @param {string|Array|Element|Object} targetOrType
   *         S'il s'agit d'une chaîne ou d'un tableau, il représente le(s) type(s) d'événement(s)
   *         qui déclenchera l'écoute.
   *
   *         Un autre objet événementiel peut être transmis ici à la place, ce qui aura pour effet de
   *         font que l'auditeur écoute les événements sur _cet_ objet.
   *
   *         Dans les deux cas, la valeur `this` de l'auditeur sera liée à
   *         cet objet.
   *
   * @param {string|Array|Function} typeOrListener
   *         Si le premier argument était une chaîne de caractères ou un tableau, il s'agirait de l'élément
   *         fonction d'écoute. Sinon, il s'agit d'une chaîne ou d'un tableau d'événements
   *         type(s).
   *
   * @param {Fonction} [listener]
   *         Si le premier argument était un autre objet événementiel, ce sera
   *         la fonction d'écoute.
   */
  on(...args) {
    const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args, 'on') ;

    listen(target, 'on', type, listener) ;

    // Si cet objet est à l'écoute d'un autre objet événementiel.
    if (!isTargetingSelf) {

      // Si cet objet est éliminé, supprimer l'écouteur.
      const removeListenerOnDispose = () => this.off(target, type, listener) ;

      // Utiliser le même identifiant de fonction que l'écouteur afin de pouvoir le supprimer ultérieurement
      // en utilisant l'ID de l'auditeur original.
      removeListenerOnDispose.guid = listener.guid ;

      // Ajouter un récepteur pour l'événement "dispose" de la cible. Cela permet de garantir
      // que si la cible est éliminée AVANT cet objet, nous supprimons l'élément
      // l'auditeur de suppression qui vient d'être ajouté. Sinon, nous créons une fuite de mémoire.
      const removeRemoverOnTargetDispose = () => this.off('dispose', removeListenerOnDispose) ;

      // Utiliser le même identifiant de fonction que l'écouteur afin de pouvoir le supprimer ultérieurement
      // en utilisant l'ID de l'auditeur original.
      removeRemoverOnTargetDispose.guid = listener.guid ;

      listen(this, 'on', 'dispose', removeListenerOnDispose) ;
      listen(target, 'on', 'dispose', removeRemoverOnTargetDispose) ;
    }
  },

  /**
   * Ajouter un auditeur à un (ou plusieurs) événement(s) sur cet objet ou sur un autre objet événementiel
   * objet. L'écouteur sera appelé une fois par événement, puis supprimé.
   *
   * @param {string|Array|Element|Object} targetOrType
   *         S'il s'agit d'une chaîne ou d'un tableau, il représente le(s) type(s) d'événement(s)
   *         qui déclenchera l'écoute.
   *
   *         Un autre objet événementiel peut être transmis ici à la place, ce qui aura pour effet de
   *         font que l'auditeur écoute les événements sur _cet_ objet.
   *
   *         Dans les deux cas, la valeur `this` de l'auditeur sera liée à
   *         cet objet.
   *
   * @param {string|Array|Function} typeOrListener
   *         Si le premier argument était une chaîne de caractères ou un tableau, il s'agirait de l'élément
   *         fonction d'écoute. Sinon, il s'agit d'une chaîne ou d'un tableau d'événements
   *         type(s).
   *
   * @param {Fonction} [listener]
   *         Si le premier argument était un autre objet événementiel, ce sera
   *         la fonction d'écoute.
   */
  one(...args) {
    const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args, 'one') ;

    // Ciblage de cet objet événementiel.
    if (isTargetingSelf) {
      listen(target, 'one', type, listener) ;

    // Ciblage d'un autre objet événementiel.
    } else {
      // TODO : Cette enveloppe est incorrecte ! Il doit seulement
      // supprimer l'enveloppe du type d'événement qui l'a appelé.
      // Au lieu de cela, toutes les listes sont supprimées au premier déclenchement !
      // voir https://github.com/videojs/video.js/issues/5962
      const wrapper = (...largs) => {
        this.off(target, type, wrapper) ;
        listener.apply(null, largs) ;
      };

      // Utiliser le même identifiant de fonction que l'écouteur afin de pouvoir le supprimer ultérieurement
      // en utilisant l'ID de l'auditeur original.
      wrapper.guid = listener.guid ;
      listen(target, 'one', type, wrapper) ;
    }
  },

  /**
   * Ajouter un auditeur à un (ou plusieurs) événement(s) sur cet objet ou sur un autre objet événementiel
   * objet. L'écouteur ne sera appelé qu'une seule fois pour le premier événement déclenché
   * puis enlevée.
   *
   * @param {string|Array|Element|Object} targetOrType
   *         S'il s'agit d'une chaîne ou d'un tableau, il représente le(s) type(s) d'événement(s)
   *         qui déclenchera l'écoute.
   *
   *         Un autre objet événementiel peut être transmis ici à la place, ce qui aura pour effet de
   *         font que l'auditeur écoute les événements sur _cet_ objet.
   *
   *         Dans les deux cas, la valeur `this` de l'auditeur sera liée à
   *         cet objet.
   *
   * @param {string|Array|Function} typeOrListener
   *         Si le premier argument était une chaîne de caractères ou un tableau, il s'agirait de l'élément
   *         fonction d'écoute. Sinon, il s'agit d'une chaîne ou d'un tableau d'événements
   *         type(s).
   *
   * @param {Fonction} [listener]
   *         Si le premier argument était un autre objet événementiel, ce sera
   *         la fonction d'écoute.
   */
  any(...args) {
    const {isTargetingSelf, target, type, listener} = normalizeListenArgs(this, args, 'any') ;

    // Ciblage de cet objet événementiel.
    if (isTargetingSelf) {
      listen(target, 'any', type, listener) ;

    // Ciblage d'un autre objet événementiel.
    } else {
      const wrapper = (...largs) => {
        this.off(target, type, wrapper) ;
        listener.apply(null, largs) ;
      };

      // Utiliser le même identifiant de fonction que l'écouteur afin de pouvoir le supprimer ultérieurement
      // en utilisant l'ID de l'auditeur original.
      wrapper.guid = listener.guid ;
      listen(target, 'any', type, wrapper) ;
    }
  },

  /**
   * Supprime le(s) auditeur(s) d'un ou plusieurs événement(s) sur un objet événementiel.
   *
   * @param {string|Array|Element|Object} [targetOrType]
   *         S'il s'agit d'une chaîne ou d'un tableau, il représente le(s) type(s) d'événement.
   *
   *         Un autre objet événementiel peut être transmis ici à la place, auquel cas
   *         Les trois arguments sont _nécessaires_.
   *
   * @param {string|Array|Function} [typeOrListener]
   *         Si le premier argument est une chaîne de caractères ou un tableau, il peut s'agir de la fonction
   *         fonction d'écoute. Sinon, il s'agit d'une chaîne ou d'un tableau d'événements
   *         type(s).
   *
   * @param {Fonction} [listener]
   *         Si le premier argument était un autre objet événementiel, ce sera
   *         la fonction d'écoute ; sinon, _tous_ les auditeurs liés à la fonction de
   *         seront supprimés.
   */
  off(targetOrType, typeOrListener, listener) {

    // Ciblage de cet objet événementiel.
    if (!targetOrType || isValidEventType(targetOrType)) {
      Events.off(this.eventBusEl_, targetOrType, typeOrListener) ;

    // Ciblage d'un autre objet événementiel.
    } else {
      const target = targetOrType ;
      const type = typeOrListener ;

      // Échouer rapidement et de manière significative !
      validateTarget(target, this, 'off') ;
      validateEventType(type, this, 'off') ;
      validateListener(listener, this, 'off') ;

      // S'assurer qu'il y a au moins un guid, même si la fonction n'a pas été utilisée
      listener = Fn.bind(this, listener) ;

      // Supprime l'écouteur dispose sur cet objet événementiel, qui a été donné
      // le même guide que l'auditeur d'événements dans on().
      this.off('dispose', listener) ;

      if (target.nodeName) {
        Events.off(target, type, listener) ;
        Events.off(target, 'dispose', listener) ;
      } else if (isEvented(target)) {
        target.off(type, listener) ;
        target.off('dispose', listener) ;
      }
    }
  },

  /**
   * Déclenche un événement sur cet objet événementiel, provoquant l'appel de ses auditeurs.
   *
   * @param {string|Object} event
   *          Un type d'événement ou un objet avec une propriété de type.
   *
   * @param {Objet} [hash]
   *          Un objet supplémentaire à transmettre aux auditeurs.
   *
   * @return {boolean}
   *          Si le comportement par défaut a été empêché ou non.
   */
  trigger(event, hash) {
    validateTarget(this.eventBusEl_, this, 'trigger') ;

    const type = event && typeof event !== 'string' ? event.type : event ;

    if (!isValidEventType(type)) {
      const error = `Type d'événement non valide pour ${objName(this)}#trigger ; ` +
        doit être une chaîne non vide ou un objet avec une clé de type qui a une valeur non vide" ;

      if (event) {
        (this.log || log).error(error) ;
      } else {
        lancer une nouvelle erreur (error) ;
      }
    }
    return Events.trigger(this.eventBusEl_, event, hash) ;
  }
};

/**
 * Applique {@link module:evented~EventedMixin|EventedMixin} à un objet cible.
 *
 * @param {Objet} target
 *         L'objet auquel ajouter des méthodes d'événement.
 *
 * @param {Objet} [options={}]
 *         Options permettant de personnaliser le comportement du mixin.
 *
 * @param {string} [options.eventBusKey]
 *         Par défaut, ajoute un élément DOM `eventBusEl_` à l'objet cible,
 *         qui est utilisé comme bus d'événements. Si l'objet cible possède déjà un
 *         L'élément DOM qui doit être utilisé, transmet sa clé ici.
 *
 * @return {Object}
 *         L'objet cible.
 */
function evented(target, options = {}) {
  const {eventBusKey} = options ;

  // Définir ou créer l'eventBusEl_.
  if (eventBusKey) {
    if (!target[eventBusKey].nodeName) {
      throw new Error(`La clé d'événement "${eventBusKey}" ne fait pas référence à un élément.`) ;
    }
    target.eventBusEl_ = target[eventBusKey] ;
  } else {
    target.eventBusEl_ = Dom.createEl('span', {className : 'vjs-event-bus'}) ;
  }

  Obj.assign(target, EventedMixin) ;

  if (target.eventedCallbacks) {
    target.eventedCallbacks.forEach((callback) => {
      callback() ;
    }) ;
  }

  // Lorsqu'un objet événementiel est éliminé, il supprime tous ses auditeurs.
  target.on('dispose', () => {
    target.off() ;
    [target, target.el_, target.eventBusEl_].forEach(function(val) {
      if (val && DomData.has(val)) {
        DomData.delete(val) ;
      }
    }) ;
    window.setTimeout(() => {
      target.eventBusEl_ = null ;
    }, 0) ;
  }) ;

  retourner la cible ;
}

export default evented ;
export {isEvented} ;
export {addEventedCallback} ;