import { audiourltotest, visualValueCount } from '../../consts.js';

export {
  MelAudioStruct,
  AudioVisualizer,
  MelAudioManager,
  MelAudioTester,
  MelAudioTesterManager,
};

/**
 * Contient les classes utiles à la visualisation des micros
 * @module Visio/Audio
 * @local MelAudioStruct
 * @local AudioVisualizer
 * @local MelAudioManager
 * @local MelAudioTester
 * @local MelAudioTesterManager
 * @local ProcessFrameCallback
 * @local ConnectStreamCallback
 */

/**
 * @callback ProcessFrameCallback
 * @param {Uint8Array<ArrayBuffer>} buffer
 * @param {Array<external:jQuery>} elements Barres de visualisation
 * @return {void}
 */

/**
 * @callback ConnectStreamCallback
 * @param {MediaStream} stream
 * @return {void}
 */

/**
 * @class
 * @classdesc Gère la visualisation de l'audio
 */
class AudioVisualizer {
  /**
   * Constructeur de la classe
   * @param {MediaDeviceInfo} device Micro que l'on souhaite écouter
   * @param {MelAudioStruct} audioStruct Données audios
   * @param {AudioContext} audioContext "An audio-processing graph built from audio modules linked together, each represented by an AudioNode"
   * @param {ProcessFrameCallback} processFrame Action à faire lors d'une frame
   * @param {Function} processError Action à faire lors d'une erreur
   */
  constructor(device, audioStruct, audioContext, processFrame, processError) {
    /**
     * @type {AudioContext}
     */
    this.audioContext = audioContext;
    /**
     * Action à faire lors d'une frame
     * @type {ProcessFrameCallback}
     */
    this.processFrame = processFrame;
    /**
     * Micro que l'on souhaite écouter
     * @type {MediaDeviceInfo}
     */
    this.linkedDevice = device;

    /**
     * Callback pour connecter un stream au visuel
     * @type {ConnectStreamCallback}
     */
    this.connectStream = this.connectStream.bind(this);

    /**
     * Données de l'audio
     * @type {MelAudioStruct}
     */
    this.audioDatas = audioStruct;

    /**
     * Si la classe a libéré ses données ou non.
     * @type {boolean}
     */
    this.disposed = false;
  }

  /**
   * Démarrer la visualisation
   * @param {Array<MediaDeviceInfo> | null} [devices=null]
   * @returns {AudioVisualizer} Chaîne
   */
  async start(devices = null) {
    if (!devices) devices = await navigator.mediaDevices.enumerateDevices();

    let id = null;
    for (const iterator of devices) {
      if (
        iterator.kind === this.linkedDevice.kind &&
        iterator.label === this.linkedDevice.label
      ) {
        id = iterator.deviceId;
        break;
      }
    }

    if (id) {
      navigator.mediaDevices
        .getUserMedia({
          audio: {
            deviceId: id,
          },
          video: false,
        })
        .then(this.connectStream)
        .catch((error) => {
          console.error(error);
        });
    } else {
      console.error('###[start]Device not exist', this.linkedDevice, devices);
    }

    return this;
  }

  /**
   * Connecte un stream au visuel
   * @param {MediaStream} stream
   */
  connectStream(stream) {
    this.analyser = this.audioContext.createAnalyser();
    const source = this.audioContext.createMediaStreamSource(stream);
    source.connect(this.analyser);
    this.analyser.smoothingTimeConstant = 0.5;
    this.analyser.fftSize = 32;

    this.initRenderLoop(this.analyser);
  }

  /**
   * Lance la loop d'animation
   */
  initRenderLoop() {
    const frequencyData = new Uint8Array(this.analyser.frequencyBinCount);
    const processFrame = this.processFrame || (() => {});

    const renderFrame = () => {
      this.analyser.getByteFrequencyData(frequencyData);
      processFrame(frequencyData, this.audioDatas.elements);

      requestAnimationFrame(renderFrame);
    };
    requestAnimationFrame(renderFrame);
  }

  /**
   * Libère les variables
   */
  dispose() {
    if (!this.disposed) {
      this.disposed = true;

      this.audioContext.close();
      this.audioContext = null;
      this.audioDatas.dispose();
      this.audioDatas = null;
      this.processFrame = null;
      this.analyser = null;
    }
  }
}

/**
 * @class
 * @classdesc Données de l'audio
 */
class MelAudioStruct {
  /**
   * Constructeur de la classe
   * @param {MediaDeviceInfo} device Micro que l'on souhaite écouter
   * @param {external:jQuery} $main DIV parente
   */
  constructor(device, $main) {
    this._init()._setup(device, $main);
  }

  /**
   * Initialise les variables
   * @private
   * @returns {MelAudioStruct} Chaîne
   */
  _init() {
    /**
     * Micro que l'on souhaite écouter
     * @type {MediaDeviceInfo}
     */
    this.device = null;
    /**
     * DIV parente
     * @type {external:jQuery}
     */
    this.$main = null;
    /**
     * Les "barres" de visualisations
     * @type {Array<external:jQuery>}
     */
    this.elements = [];
    /**
     * Gère la visualisation de l'audio
     * @type {AudioVisualizer}
     */
    this.visualizer = null;

    /**
     * Si la classe a libéré ses données ou non.
     * @type {boolean}
     */
    this.disposed = false;

    return this;
  }

  /**
   * Assigne les variables de la classe
   * @private
   * @param {MediaDeviceInfo} device Micro que l'on souhaite écouter
   * @param {external:jQuery} $main DIV parente
   * @returns {MelAudioStruct} Chaîne
   */
  _setup(device, $main) {
    this.device = device;
    this.$main = $main;

    return this;
  }

  /**
   * Génère les "barres" de visualisations
   * @returns {MelAudioStruct} Chaîne
   */
  generateElements() {
    for (let i = 0; i < visualValueCount; ++i) {
      const elm = document.createElement('div');
      this.$main.append(elm);
    }

    this.elements = this.$main.children();

    return this;
  }

  /**
   * Libère la classe
   */
  dispose() {
    if (!this.disposed) {
      this.disposed = true;

      this.device = null;
      this.$main.parent().remove();
      this.$main = null;
      this.elements = null;
      this.visualizer.dispose();
      this.visualizer = null;
    }
  }
}

/**
 * @class
 * @classdesc Gère les visualisations audio
 */
class MelAudioManager {
  /**
   * Initialise la classe
   */
  constructor() {
    this._init();
  }

  /**
   * @private
   * @returns {this}
   */
  _init() {
    /**
     * Eléments visuels
     * @type {Object<string, MelAudioStruct>}
     * @frommodule Visio/Audio {@linkto MelAudioStruct}
     */
    this.visualElements = {};
    /**
     * Si l'écoute des médias à commencer ou non
     * @type {boolean}
     */
    this.started = false;
    /**
     * Liste des devices
     * @type {?Array<MediaDeviceInfo>}
     */
    this.devices = null;
    /**
     * Si la classe a libéré ses données ou non.
     * @type {boolean}
     */
    this.disposed = false;

    return this;
  }

  /**
   * Commence la visualisation d'un stream audio
   * @param {external:jQuery} $main Div parente
   * @param {?MediaDeviceInfo[]} [alreadyExistingDevices=null] Devices sauvegardés en mémoire
   * @returns {Promise<MelAudioManager>} Chaînage
   * @async
   * @frommodulereturn Visio/Audio {@linkto MelAudioManager}
   */
  async start($main, alreadyExistingDevices = null) {
    const devices =
      alreadyExistingDevices ||
      (await navigator.mediaDevices.enumerateDevices());
    this.devices = devices;

    for (const dvc of devices) {
      if (dvc.kind === 'audioinput') {
        this.visualElements[dvc.deviceId] = new MelAudioStruct(
          dvc,
          $(
            '<div class="rotovisua"><div class="text"></div><div class="visu"></div></div>',
          )
            .data('deviceid', dvc.deviceId)
            .appendTo($main)
            .find('.visu'),
        ).generateElements();
        this.visualElements[dvc.deviceId].visualizer = new AudioVisualizer(
          dvc,
          this.visualElements[dvc.deviceId],
          new AudioContext(),
          MelAudioManager.processFrame,
          MelAudioManager.processError,
        );
        this.visualElements[dvc.deviceId].$main
          .parent()
          .find('.text')
          .html(dvc.label);
      }
    }

    this.started = true;
    return this;
  }

  /**
   * AJoute un élément de visualisation
   * @param {MediaDeviceInfo} dvc Device
   * @param {external:jQuery} $button Bouton lié à l'élément
   * @returns {Promise<MelAudioStruct>}
   * @async
   * @frommodulereturn Visio/Audio {@linkto MelAudioStruct}
   */
  async addElement(dvc, $button) {
    if (!this.devices) {
      console.log('Génération des devices !');
      this.devices = await navigator.mediaDevices.enumerateDevices();
    }

    if (dvc.kind === 'audioinput') {
      this.visualElements[dvc.deviceId] = new MelAudioStruct(
        dvc,
        $(
          '<div class="rotovisua"><div class="button"></div><div class="visu"></div></div>',
        )
          .data('deviceid', dvc.deviceId)
          .find('.visu'),
      ).generateElements();
      this.visualElements[dvc.deviceId].visualizer = await new AudioVisualizer(
        dvc,
        this.visualElements[dvc.deviceId],
        new AudioContext(),
        MelAudioManager.processFrame,
        MelAudioManager.processError,
      ).start(this.devices);
      this.visualElements[dvc.deviceId].$main
        .parent()
        .find('.button')
        .html($button);
    }

    return this.visualElements[dvc.deviceId];
  }

  /**
   * Ajoute un bouton
   * @param {external:jQuery} $button
   * @param {string} id
   * @returns {MelAudioManager} Chaînage
   */
  addButton($button, id) {
    this.visualElements[id].$main.find('.text').html($button);
    return this;
  }

  /**
   * Affiche les barres de visualisations
   * @param {Uint8Array<ArrayBuffer>} data
   * @param {Array<external:jQuery>} visualElements
   * @static
   */
  static processFrame(data, visualElements) {
    const dataMap = {
      0: 15,
      1: 10,
      2: 8,
      3: 9,
      4: 6,
      5: 5,
      6: 2,
      7: 1,
      8: 0,
      9: 4,
      10: 3,
      11: 7,
      12: 11,
      13: 12,
      14: 13,
      15: 14,
    };
    const values = Object.values(data);
    for (let i = 0; i < visualValueCount; ++i) {
      const value = values[dataMap[i]] / 255;
      const elmStyles = visualElements[i].style;
      elmStyles.transform = `scaleY( ${value} )`;
      elmStyles.opacity = Math.max(0.25, value);
    }
  }

  static processError(visualMainElement) {
    visualMainElement.classList.add('error');
    visualMainElement.innerText =
      'Please allow access to your microphone in order to see this demo.\nNothing bad is going to happen... hopefully :P';
  }

  /**
   * Libère les données en mémoire
   */
  dispose() {
    if (!this.disposed) {
      this.disposed = true;
      this.started = false;

      for (const key in this.visualElements) {
        if (Object.hasOwnProperty.call(this.visualElements, key)) {
          this.visualElements[key].dispose();
        }
      }

      this.visualElements = null;
      this.devices = null;
    }
  }
}

class MelAudioTester {
  constructor(devices = null) {
    this._devices = devices;
    this._audio = null;
  }

  async test(device) {
    if (!this._devices) {
      this._devices = await navigator.mediaDevices.enumerateDevices();
    }

    if (this._audio) {
      this._audio.pause();
      $(this._audio).remove();
      this._audio = null;
    }

    let id = null;
    for (const iterator of this._devices) {
      if (iterator.kind === device.kind && iterator.label === device.label) {
        id = iterator.deviceId;
        break;
      }
    }
    if (id) {
      const audio = document.createElement('audio');
      audio.src = audiourltotest;
      await audio.setSinkId(id);
      await audio.play();

      this._audio = audio;
    } else {
      console.error('###[test]Impossible de trouver le haut-parleur');
    }

    return this;
  }

  dispose() {
    if (!this.disposed) {
      this.disposed = true;
      this._devices = null;
      this._audio.pause();
      $(this._audio).remove();
      this._audio = null;
    }
  }
}

class MelAudioTesterManager {
  constructor() {
    this.audios = {};
  }

  addAudio(key, audio) {
    if (this.audios[key]) return this;

    this.audios[key] = audio;

    return this;
  }

  dispose() {
    if (!this.disposed) {
      this.disposed = true;

      for (const key in this.audios) {
        if (Object.hasOwnProperty.call(this.audios, key)) {
          this.audios[key]?.dispose?.();
        }
      }

      this.audios = null;
    }
  }
}