const VolumeInterface = require('./util/VolumeInterface');
const Prism = require('prism-media');
const OpusEncoders = require('./opus/OpusEngineList');
const Collection = require('../../util/Collection');

const ffmpegArguments = [
  '-analyzeduration', '0',
  '-loglevel', '0',
  '-f', 's16le',
  '-ar', '48000',
  '-ac', '2',
];

/**
 * A voice broadcast can be played across multiple voice connections for improved shared-stream efficiency.
 *
 * Example usage:
 * ```js
 * const broadcast = client.createVoiceBroadcast();
 * broadcast.playFile('./music.mp3');
 * // play "music.mp3" in all voice connections that the client is in
 * for (const connection of client.voiceConnections.values()) {
 *   connection.playBroadcast(broadcast);
 * }
 * ```
 * @implements {VolumeInterface}
 */
class VoiceBroadcast extends VolumeInterface {
  constructor(client) {
    super();
    /**
     * The client that created the broadcast
     * @type {Client}
     */
    this.client = client;
    this._dispatchers = new Collection();
    this._encoders = new Collection();
    /**
     * The audio transcoder that this broadcast uses
     * @type {Prism}
     */
    this.prism = new Prism();
    /**
     * The current audio transcoder that is being used
     * @type {Object}
     */
    this.currentTranscoder = null;
    this.tickInterval = null;
    this._volume = 1;
  }

  /**
   * An array of subscribed dispatchers
   * @type {StreamDispatcher[]}
   * @readonly
   */
  get dispatchers() {
    let d = [];
    for (const container of this._dispatchers.values()) {
      d = d.concat(Array.from(container.values()));
    }
    return d;
  }

  get _playableStream() {
    const currentTranscoder = this.currentTranscoder;
    if (!currentTranscoder) return null;
    const transcoder = currentTranscoder.transcoder;
    const options = currentTranscoder.options;
    return (transcoder && transcoder.output) || options.stream;
  }

  unregisterDispatcher(dispatcher, old) {
    const volume = old || dispatcher.volume;

    /**
     * Emitted whenever a stream dispatcher unsubscribes from the broadcast.
     * @event VoiceBroadcast#unsubscribe
     * @param {StreamDispatcher} dispatcher The unsubscribed dispatcher
     */
    this.emit('unsubscribe', dispatcher);
    for (const container of this._dispatchers.values()) {
      container.delete(dispatcher);

      if (!container.size) {
        this._encoders.get(volume).destroy();
        this._dispatchers.delete(volume);
        this._encoders.delete(volume);
      }
    }
  }

  registerDispatcher(dispatcher) {
    if (!this._dispatchers.has(dispatcher.volume)) {
      this._dispatchers.set(dispatcher.volume, new Set());
      this._encoders.set(dispatcher.volume, OpusEncoders.fetch());
    }
    const container = this._dispatchers.get(dispatcher.volume);
    if (!container.has(dispatcher)) {
      container.add(dispatcher);
      dispatcher.once('end', () => this.unregisterDispatcher(dispatcher));
      dispatcher.on('volumeChange', (o, n) => {
        this.unregisterDispatcher(dispatcher, o);
        if (!this._dispatchers.has(n)) {
          this._dispatchers.set(n, new Set());
          this._encoders.set(n, OpusEncoders.fetch());
        }
        this._dispatchers.get(n).add(dispatcher);
      });
      /**
       * Emitted whenever a stream dispatcher subscribes to the broadcast.
       * @event VoiceBroadcast#subscribe
       * @param {StreamDispatcher} dispatcher The subscribed dispatcher
       */
      this.emit('subscribe', dispatcher);
    }
  }

  killCurrentTranscoder() {
    if (this.currentTranscoder) {
      if (this.currentTranscoder.transcoder) this.currentTranscoder.transcoder.kill();
      this.currentTranscoder = null;
      this.emit('end');
    }
  }

  /**
   * Plays any audio stream across the broadcast.
   * @param {ReadableStream} stream The audio stream to play
   * @param {StreamOptions} [options] Options for playing the stream
   * @returns {VoiceBroadcast}
   * @example
   * // Play streams using ytdl-core
   * const ytdl = require('ytdl-core');
   * const streamOptions = { seek: 0, volume: 1 };
   * const broadcast = client.createVoiceBroadcast();
   *
   * voiceChannel.join()
   *  .then(connection => {
   *    const stream = ytdl('https://www.youtube.com/watch?v=XAWgeLF9EVQ', { filter : 'audioonly' });
   *    broadcast.playStream(stream);
   *    const dispatcher = connection.playBroadcast(broadcast);
   *  })
   *  .catch(console.error);
   */
  playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) {
    const options = { seek, volume, passes, stream };
    return this._playTranscodable(stream, options);
  }

  /**
   * Play the given file in the voice connection.
   * @param {string} file The absolute path to the file
   * @param {StreamOptions} [options] Options for playing the stream
   * @returns {StreamDispatcher}
   * @example
   * // Play files natively
   * const broadcast = client.createVoiceBroadcast();
   *
   * voiceChannel.join()
   *  .then(connection => {
   *    broadcast.playFile('C:/Users/Discord/Desktop/music.mp3');
   *    const dispatcher = connection.playBroadcast(broadcast);
   *  })
   *  .catch(console.error);
   */
  playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) {
    const options = { seek, volume, passes };
    return this._playTranscodable(`file:${file}`, options);
  }

  _playTranscodable(media, options) {
    OpusEncoders.guaranteeOpusEngine();

    this.killCurrentTranscoder();
    const transcoder = this.prism.transcode({
      type: 'ffmpeg',
      media,
      ffmpegArguments: ffmpegArguments.concat(['-ss', String(options.seek)]),
    });
    /**
     * Emitted whenever an error occurs.
     * @event VoiceBroadcast#error
     * @param {Error} error The error that occurred
     */
    transcoder.once('error', e => {
      if (this.listenerCount('error') > 0) this.emit('error', e);
      /**
       * Emitted whenever the VoiceBroadcast has any warnings.
       * @event VoiceBroadcast#warn
       * @param {string|Error} warning The warning that was raised
       */
      else this.emit('warn', e);
    });
    /**
     * Emitted once the broadcast (the audio stream) ends.
     * @event VoiceBroadcast#end
     */
    transcoder.once('end', () => this.killCurrentTranscoder());
    this.currentTranscoder = {
      transcoder,
      options,
    };
    transcoder.output.once('readable', () => this._startPlaying());
    return this;
  }

  /**
   * Plays a stream of 16-bit signed stereo PCM at 48KHz.
   * @param {ReadableStream} stream The audio stream to play
   * @param {StreamOptions} [options] Options for playing the stream
   * @returns {VoiceBroadcast}
   */
  playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) {
    OpusEncoders.guaranteeOpusEngine();

    this.killCurrentTranscoder();
    const options = { seek, volume, passes, stream };
    this.currentTranscoder = { options };
    stream.once('readable', () => this._startPlaying());
    return this;
  }

  /**
   * Plays an Opus encoded stream at 48KHz.
   * <warn>Note that inline volume is not compatible with this method.</warn>
   * @param {ReadableStream} stream The Opus audio stream to play
   * @param {StreamOptions} [options] Options for playing the stream
   * @returns {StreamDispatcher}
   */
  playOpusStream(stream, { seek = 0, passes = 1 } = {}) {
    const options = { seek, passes, stream };
    this.currentTranscoder = { options, opus: true };
    stream.once('readable', () => this._startPlaying());
    return this;
  }

  /**
   * Play an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description)
   * @param {string} input The arbitrary input
   * @param {StreamOptions} [options] Options for playing the stream
   * @returns {VoiceBroadcast}
   */
  playArbitraryInput(input, { seek = 0, volume = 1, passes = 1 } = {}) {
    this.guaranteeOpusEngine();

    const options = { seek, volume, passes, input };
    return this._playTranscodable(input, options);
  }

  /**
   * Pauses the entire broadcast - all dispatchers also pause.
   */
  pause() {
    this.paused = true;
    for (const container of this._dispatchers.values()) {
      for (const dispatcher of container.values()) {
        dispatcher.pause();
      }
    }
  }

  /**
   * Resumes the entire broadcast - all dispatchers also resume.
   */
  resume() {
    this.paused = false;
    for (const container of this._dispatchers.values()) {
      for (const dispatcher of container.values()) {
        dispatcher.resume();
      }
    }
  }

  guaranteeOpusEngine() {
    if (!this.opusEncoder) throw new Error('Couldn\'t find an Opus engine.');
  }

  _startPlaying() {
    if (this.tickInterval) clearInterval(this.tickInterval);
    // Old code?
    // this.tickInterval = this.client.setInterval(this.tick.bind(this), 20);
    this._startTime = Date.now();
    this._count = 0;
    this._pausedTime = 0;
    this._missed = 0;
    this.tick();
  }

  tick() {
    if (!this._playableStream) return;
    if (this.paused) {
      this._pausedTime += 20;
      setTimeout(() => this.tick(), 20);
      return;
    }

    const opus = this.currentTranscoder.opus;
    const buffer = this.readStreamBuffer();

    if (!buffer) {
      this._missed++;
      if (this._missed < 5) {
        this._pausedTime += 200;
        setTimeout(() => this.tick(), 200);
      } else {
        this.killCurrentTranscoder();
      }
      return;
    }

    this._missed = 0;

    let packetMatrix = {};

    const getOpusPacket = volume => {
      if (packetMatrix[volume]) return packetMatrix[volume];

      const opusEncoder = this._encoders.get(volume);
      const opusPacket = opusEncoder.encode(this.applyVolume(buffer, this._volume * volume));
      packetMatrix[volume] = opusPacket;
      return opusPacket;
    };

    for (const dispatcher of this.dispatchers) {
      if (opus) {
        dispatcher.processPacket(buffer);
        continue;
      }

      const volume = dispatcher.volume;
      dispatcher.processPacket(getOpusPacket(volume));
    }

    const next = 20 + (this._startTime + this._pausedTime + (this._count * 20) - Date.now());
    this._count++;
    setTimeout(() => this.tick(), next);
  }

  readStreamBuffer() {
    const opus = this.currentTranscoder.opus;
    const bufferLength = (opus ? 80 : 1920) * 2;
    let buffer = this._playableStream.read(bufferLength);
    if (opus) return buffer;
    if (!buffer) return null;

    if (buffer.length !== bufferLength) {
      const newBuffer = Buffer.alloc(bufferLength).fill(0);
      buffer.copy(newBuffer);
      buffer = newBuffer;
    }

    return buffer;
  }

  /**
   * Stop the current stream from playing without unsubscribing dispatchers.
   */
  end() {
    this.killCurrentTranscoder();
  }

  /**
   * End the current broadcast, all subscribed dispatchers will also end.
   */
  destroy() {
    this.end();
    for (const container of this._dispatchers.values()) {
      for (const dispatcher of container.values()) {
        dispatcher.destroy('end', 'broadcast ended');
      }
    }
  }
}

module.exports = VoiceBroadcast;
