/**
 * @packageDocumentation
 * @module Voice
 */
import { EventEmitter } from 'events';
import Device from './device';
import { InvalidArgumentError, NotSupportedError } from './errors';
import Log from './log';
import OutputDeviceCollection from './outputdevicecollection';
import MediaDeviceInfoShim from './shims/mediadeviceinfo';
import getMediaDevicesInstance from './shims/mediadevices';
import { average, difference, isFirefox } from './util';

/**
 * Aliases for audio kinds, used for labelling.
 * @private
 */
const kindAliases: Record<string, string> = {
  audioinput: 'Audio Input',
  audiooutput: 'Audio Output',
};

/**
 * Provides input and output audio-based functionality in one convenient class.
 * @publicapi
 */
class AudioHelper extends EventEmitter {
  /**
   * The currently set audio constraints set by setAudioConstraints(). Starts as null.
   */
  get audioConstraints(): MediaTrackConstraints | null { return this._audioConstraints; }

  /**
   * A Map of all audio input devices currently available to the browser by their device ID.
   */
  availableInputDevices: Map<string, MediaDeviceInfo> = new Map();

  /**
   * A Map of all audio output devices currently available to the browser by their device ID.
   */
  availableOutputDevices: Map<string, MediaDeviceInfo> = new Map();

  /**
   * The active input device. Having no inputDevice specified by `setInputDevice()`
   * will disable input selection related functionality.
   */
  get inputDevice(): MediaDeviceInfo | null { return this._inputDevice; }

  /**
   * The current input stream.
   */
  get inputStream(): MediaStream | null { return this._inputStream; }

  /**
   * False if the browser does not support `HTMLAudioElement.setSinkId()` or
   * `MediaDevices.enumerateDevices()` and Twilio cannot facilitate output selection functionality.
   */
  isOutputSelectionSupported: boolean;

  /**
   * False if the browser does not support AudioContext and Twilio can not analyse the volume
   * in real-time.
   */
  isVolumeSupported: boolean;

  /**
   * The current set of output devices that incoming ringtone audio is routed through.
   * These are the sounds that may play while the user is away from the machine or not wearing
   * their headset. It is important that this audio is heard. If all specified
   * devices lost, this Set will revert to contain only the "default" device.
   */
  ringtoneDevices: OutputDeviceCollection;

  /**
   * The current set of output devices that call audio (`[voice, outgoing, disconnect, dtmf]`)
   * is routed through. These are the sounds that are initiated by the user, or played while
   * the user is otherwise present at the endpoint. If all specified devices are lost,
   * this Set will revert to contain only the "default" device.
   */
  speakerDevices: OutputDeviceCollection;

  /**
   * The currently set audio constraints set by setAudioConstraints().
   */
  private _audioConstraints: MediaTrackConstraints | null = null;

  /**
   * An AudioContext to use.
   */
  private _audioContext?: AudioContext;

  /**
   * Whether each sound is enabled.
   */
  private _enabledSounds: Record<Device.ToggleableSound, boolean> = {
    [Device.SoundName.Disconnect]: true,
    [Device.SoundName.Incoming]: true,
    [Device.SoundName.Outgoing]: true,
  };

  /**
   * The enumerateDevices method to use
   */
  private _enumerateDevices: any;

  /**
   * The `getUserMedia()` function to use.
   */
  private _getUserMedia: (constraints: MediaStreamConstraints) => Promise<MediaStream>;

  /**
   * The current input device.
   */
  private _inputDevice: MediaDeviceInfo | null = null;

  /**
   * The current input stream.
   */
  private _inputStream: MediaStream | null = null;

  /**
   * An AnalyserNode to use for input volume.
   */
  private _inputVolumeAnalyser?: AnalyserNode;

  /**
   * An MediaStreamSource to use for input volume.
   */
  private _inputVolumeSource?: MediaStreamAudioSourceNode;

  /**
   * Whether the {@link AudioHelper} is currently polling the input stream's volume.
   */
  private _isPollingInputVolume: boolean = false;

  /**
   * An instance of Logger to use.
   */
  private _log: Log = Log.getInstance();

  /**
   * The MediaDevices instance to use.
   */
  private _mediaDevices: AudioHelper.MediaDevicesLike | null;

  /**
   * Called with the new input stream when the active input is changed.
   */
  private _onActiveInputChanged: (stream: MediaStream | null) => Promise<void>;

  /**
   * A record of unknown devices (Devices without labels)
   */
  private _unknownDeviceIndexes: Record<string, Record<string, number>> = {
    audioinput: { },
    audiooutput: { },
  };

  /**
   * @constructor
   * @private
   * @param onActiveOutputsChanged - A callback to be called when the user changes the active output devices.
   * @param onActiveInputChanged - A callback to be called when the user changes the active input device.
   * @param getUserMedia - The getUserMedia method to use.
   * @param [options]
   */
  constructor(onActiveOutputsChanged: (type: 'ringtone' | 'speaker', outputIds: string[]) => Promise<void>,
              onActiveInputChanged: (stream: MediaStream | null) => Promise<void>,
              getUserMedia: (constraints: MediaStreamConstraints) => Promise<MediaStream>,
              options?: AudioHelper.Options) {
    super();

    options = Object.assign({
      AudioContext: typeof AudioContext !== 'undefined' && AudioContext,
      setSinkId: typeof HTMLAudioElement !== 'undefined' && (HTMLAudioElement.prototype as any).setSinkId,
    }, options);

    this._getUserMedia = getUserMedia;
    this._mediaDevices = options.mediaDevices || getMediaDevicesInstance() as AudioHelper.MediaDevicesLike;
    this._onActiveInputChanged = onActiveInputChanged;
    this._enumerateDevices = typeof options.enumerateDevices === 'function'
      ? options.enumerateDevices
      : this._mediaDevices && this._mediaDevices.enumerateDevices;

    const isAudioContextSupported: boolean = !!(options.AudioContext || options.audioContext);
    const isEnumerationSupported: boolean = !!this._enumerateDevices;

    if (options.enabledSounds) {
      this._enabledSounds = options.enabledSounds;
    }

    const isSetSinkSupported: boolean = typeof options.setSinkId === 'function';
    this.isOutputSelectionSupported = isEnumerationSupported && isSetSinkSupported;
    this.isVolumeSupported = isAudioContextSupported;

    if (this.isVolumeSupported) {
      this._audioContext = options.audioContext || options.AudioContext && new options.AudioContext();
      if (this._audioContext) {
        this._inputVolumeAnalyser = this._audioContext.createAnalyser();
        this._inputVolumeAnalyser.fftSize = 32;
        this._inputVolumeAnalyser.smoothingTimeConstant = 0.3;
      }
    }

    this.ringtoneDevices = new OutputDeviceCollection('ringtone',
      this.availableOutputDevices, onActiveOutputsChanged, this.isOutputSelectionSupported);
    this.speakerDevices = new OutputDeviceCollection('speaker',
      this.availableOutputDevices, onActiveOutputsChanged, this.isOutputSelectionSupported);

    this.addListener('newListener', (eventName: string) => {
      if (eventName === 'inputVolume') {
        this._maybeStartPollingVolume();
      }
    });

    this.addListener('removeListener', (eventName: string) => {
      if (eventName === 'inputVolume') {
        this._maybeStopPollingVolume();
      }
    });

    this.once('newListener', () => {
      // NOTE (rrowland): Ideally we would only check isEnumerationSupported here, but
      //   in at least one browser version (Tested in FF48) enumerateDevices actually
      //   returns bad data for the listed devices. Instead, we check for
      //   isOutputSelectionSupported to avoid these quirks that may negatively affect customers.
      if (!this.isOutputSelectionSupported) {
        this._log.warn('Warning: This browser does not support audio output selection.');
      }

      if (!this.isVolumeSupported) {
        this._log.warn(`Warning: This browser does not support Twilio's volume indicator feature.`);
      }
    });

    if (isEnumerationSupported) {
      this._initializeEnumeration();
    }
  }

  /**
   * Current state of the enabled sounds
   * @private
   */
  _getEnabledSounds(): Record<Device.ToggleableSound, boolean> {
    return this._enabledSounds;
  }

  /**
   * Start polling volume if it's supported and there's an input stream to poll.
   * @private
   */
  _maybeStartPollingVolume(): void {
    if (!this.isVolumeSupported || !this._inputStream) { return; }

    this._updateVolumeSource();

    if (this._isPollingInputVolume || !this._inputVolumeAnalyser) { return; }

    const bufferLength: number = this._inputVolumeAnalyser.frequencyBinCount;
    const buffer: Uint8Array = new Uint8Array(bufferLength);

    this._isPollingInputVolume = true;

    const emitVolume = (): void => {
      if (!this._isPollingInputVolume) { return; }

      if (this._inputVolumeAnalyser) {
        this._inputVolumeAnalyser.getByteFrequencyData(buffer);
        const inputVolume: number = average(buffer);

        this.emit('inputVolume', inputVolume / 255);
      }

      requestAnimationFrame(emitVolume);
    };

    requestAnimationFrame(emitVolume);
  }

  /**
   * Stop polling volume if it's currently polling and there are no listeners.
   * @private
   */
  _maybeStopPollingVolume(): void {
    if (!this.isVolumeSupported) { return; }

    if (!this._isPollingInputVolume || (this._inputStream && this.listenerCount('inputVolume'))) {
      return;
    }

    if (this._inputVolumeSource) {
      this._inputVolumeSource.disconnect();
      delete this._inputVolumeSource;
    }

    this._isPollingInputVolume = false;
  }

  /**
   * Unbind the listeners from mediaDevices.
   * @private
   */
  _unbind(): void {
    if (!this._mediaDevices || !this._enumerateDevices) {
      throw new NotSupportedError('Enumeration is not supported');
    }

    if (this._mediaDevices.removeEventListener) {
      this._mediaDevices.removeEventListener('devicechange', this._updateAvailableDevices);
      this._mediaDevices.removeEventListener('deviceinfochange', this._updateAvailableDevices);
    }
  }

  /**
   * Enable or disable the disconnect sound.
   * @param doEnable Passing `true` will enable the sound and `false` will disable the sound.
   * Not passing this parameter will not alter the enable-status of the sound.
   * @returns The enable-status of the sound.
   */
  disconnect(doEnable?: boolean): boolean {
    return this._maybeEnableSound(Device.SoundName.Disconnect, doEnable);
  }

  /**
   * Enable or disable the incoming sound.
   * @param doEnable Passing `true` will enable the sound and `false` will disable the sound.
   * Not passing this parameter will not alter the enable-status of the sound.
   * @returns The enable-status of the sound.
   */
  incoming(doEnable?: boolean): boolean {
    return this._maybeEnableSound(Device.SoundName.Incoming, doEnable);
  }

  /**
   * Enable or disable the outgoing sound.
   * @param doEnable Passing `true` will enable the sound and `false` will disable the sound.
   * Not passing this parameter will not alter the enable-status of the sound.
   * @returns The enable-status of the sound.
   */
  outgoing(doEnable?: boolean): boolean {
    return this._maybeEnableSound(Device.SoundName.Outgoing, doEnable);
  }

  /**
   * Set the MediaTrackConstraints to be applied on every getUserMedia call for new input
   * device audio. Any deviceId specified here will be ignored. Instead, device IDs should
   * be specified using {@link AudioHelper#setInputDevice}. The returned Promise resolves
   * when the media is successfully reacquired, or immediately if no input device is set.
   * @param audioConstraints - The MediaTrackConstraints to apply.
   */
  setAudioConstraints(audioConstraints: MediaTrackConstraints): Promise<void> {
    this._audioConstraints = Object.assign({ }, audioConstraints);
    delete this._audioConstraints.deviceId;

    return this.inputDevice
      ? this._setInputDevice(this.inputDevice.deviceId, true)
      : Promise.resolve();
  }

  /**
   * Replace the current input device with a new device by ID.
   * @param deviceId - An ID of a device to replace the existing
   *   input device with.
   */
  setInputDevice(deviceId: string): Promise<void> {
    return !isFirefox()
      ? this._setInputDevice(deviceId, false)
      : Promise.reject(new NotSupportedError('Firefox does not currently support opening multiple ' +
        'audio input tracks simultaneously, even across different tabs. As a result, ' +
        'Device.audio.setInputDevice is disabled on Firefox until support is added.\n' +
        'Related BugZilla thread: https://bugzilla.mozilla.org/show_bug.cgi?id=1299324'));
  }

  /**
   * Unset the MediaTrackConstraints to be applied on every getUserMedia call for new input
   * device audio. The returned Promise resolves when the media is successfully reacquired,
   * or immediately if no input device is set.
   */
  unsetAudioConstraints(): Promise<void> {
    this._audioConstraints = null;
    return this.inputDevice
      ? this._setInputDevice(this.inputDevice.deviceId, true)
      : Promise.resolve();
  }

  /**
   * Unset the input device, stopping the tracks. This should only be called when not in a connection, and
   *   will not allow removal of the input device during a live call.
   */
  unsetInputDevice(): Promise<void> {
    if (!this.inputDevice) { return Promise.resolve(); }

    return this._onActiveInputChanged(null).then(() => {
      this._replaceStream(null);
      this._inputDevice = null;
      this._maybeStopPollingVolume();
    });
  }

  /**
   * Get the index of an un-labeled Device.
   * @param mediaDeviceInfo
   * @returns The index of the passed MediaDeviceInfo
   */
  private _getUnknownDeviceIndex(mediaDeviceInfo: MediaDeviceInfo): number {
    const id: string = mediaDeviceInfo.deviceId;
    const kind: string = mediaDeviceInfo.kind;

    let index: number = this._unknownDeviceIndexes[kind][id];
    if (!index) {
      index = Object.keys(this._unknownDeviceIndexes[kind]).length + 1;
      this._unknownDeviceIndexes[kind][id] = index;
    }

    return index;
  }

  /**
   * Initialize output device enumeration.
   */
  private _initializeEnumeration(): void {
    if (!this._mediaDevices || !this._enumerateDevices) {
      throw new NotSupportedError('Enumeration is not supported');
    }

    if (this._mediaDevices.addEventListener) {
      this._mediaDevices.addEventListener('devicechange', this._updateAvailableDevices);
      this._mediaDevices.addEventListener('deviceinfochange', this._updateAvailableDevices);
    }

    this._updateAvailableDevices().then(() => {
      if (!this.isOutputSelectionSupported) { return; }

      Promise.all([
        this.speakerDevices.set('default'),
        this.ringtoneDevices.set('default'),
      ]).catch(reason => {
        this._log.warn(`Warning: Unable to set audio output devices. ${reason}`);
      });
    });
  }

  /**
   * Set whether the sound is enabled or not
   * @param soundName
   * @param doEnable
   * @returns Whether the sound is enabled or not
   */
  private _maybeEnableSound(soundName: Device.ToggleableSound, doEnable?: boolean): boolean {
    if (typeof doEnable !== 'undefined') {
      this._enabledSounds[soundName] = doEnable;
    }
    return this._enabledSounds[soundName];
  }

  /**
   * Remove an input device from inputs
   * @param lostDevice
   * @returns Whether the device was active
   */
  private _removeLostInput = (lostDevice: MediaDeviceInfo): boolean => {
    if (!this.inputDevice || this.inputDevice.deviceId !== lostDevice.deviceId) {
      return false;
    }

    this._replaceStream(null);
    this._inputDevice = null;
    this._maybeStopPollingVolume();

    const defaultDevice: MediaDeviceInfo = this.availableInputDevices.get('default')
      || Array.from(this.availableInputDevices.values())[0];

    if (defaultDevice) {
      this.setInputDevice(defaultDevice.deviceId);
    }

    return true;
  }

  /**
   * Remove an input device from outputs
   * @param lostDevice
   * @returns Whether the device was active
   */
  private _removeLostOutput = (lostDevice: MediaDeviceInfo): boolean => {
    const wasSpeakerLost: boolean = this.speakerDevices.delete(lostDevice);
    const wasRingtoneLost: boolean = this.ringtoneDevices.delete(lostDevice);
    return wasSpeakerLost || wasRingtoneLost;
  }

  /**
   * Stop the tracks on the current input stream before replacing it with the passed stream.
   * @param stream - The new stream
   */
  private _replaceStream(stream: MediaStream | null): void {
    if (this._inputStream) {
      this._inputStream.getTracks().forEach(track => {
        track.stop();
      });
    }

    this._inputStream = stream;
  }

  /**
   * Replace the current input device with a new device by ID.
   * @param deviceId - An ID of a device to replace the existing
   *   input device with.
   * @param forceGetUserMedia - If true, getUserMedia will be called even if
   *   the specified device is already active.
   */
  private _setInputDevice(deviceId: string, forceGetUserMedia: boolean): Promise<void> {
    if (typeof deviceId !== 'string') {
      return Promise.reject(new InvalidArgumentError('Must specify the device to set'));
    }

    const device: MediaDeviceInfo | undefined = this.availableInputDevices.get(deviceId);
    if (!device) {
      return Promise.reject(new InvalidArgumentError(`Device not found: ${deviceId}`));
    }

    if (this._inputDevice && this._inputDevice.deviceId === deviceId && this._inputStream) {
      if (!forceGetUserMedia) {
        return Promise.resolve();
      }

      // If the currently active track is still in readyState `live`, gUM may return the same track
      // rather than returning a fresh track.
      this._inputStream.getTracks().forEach(track => {
        track.stop();
      });
    }

    const constraints = { audio: Object.assign({ deviceId: { exact: deviceId } }, this.audioConstraints) };
    return this._getUserMedia(constraints).then((stream: MediaStream) => {
      return this._onActiveInputChanged(stream).then(() => {
        this._replaceStream(stream);
        this._inputDevice = device;
        this._maybeStartPollingVolume();
      });
    });
  }

  /**
   * Update the available input and output devices
   */
  private _updateAvailableDevices = (): Promise<void> => {
    if (!this._mediaDevices || !this._enumerateDevices) {
      return Promise.reject('Enumeration not supported');
    }

    return this._enumerateDevices().then((devices: MediaDeviceInfo[]) => {
      this._updateDevices(devices.filter((d: MediaDeviceInfo) => d.kind === 'audiooutput'),
        this.availableOutputDevices,
        this._removeLostOutput);

      this._updateDevices(devices.filter((d: MediaDeviceInfo) => d.kind === 'audioinput'),
        this.availableInputDevices,
        this._removeLostInput);

      const defaultDevice = this.availableOutputDevices.get('default')
        || Array.from(this.availableOutputDevices.values())[0];

      [this.speakerDevices, this.ringtoneDevices].forEach(outputDevices => {
        if (!outputDevices.get().size && this.availableOutputDevices.size && this.isOutputSelectionSupported) {
          outputDevices.set(defaultDevice.deviceId)
            .catch((reason) => {
              this._log.warn(`Unable to set audio output devices. ${reason}`);
            });
        }
      });
    });
  }

  /**
   * Update a set of devices.
   * @param updatedDevices - An updated list of available Devices
   * @param availableDevices - The previous list of available Devices
   * @param removeLostDevice - The method to call if a previously available Device is
   *   no longer available.
   */
  private _updateDevices(updatedDevices: MediaDeviceInfo[],
                         availableDevices: Map<string, MediaDeviceInfo>,
                         removeLostDevice: (lostDevice: MediaDeviceInfo) => boolean): void {
    const updatedDeviceIds: string[] = updatedDevices.map(d => d.deviceId);
    const knownDeviceIds: string[] = Array.from(availableDevices.values()).map(d => d.deviceId);
    const lostActiveDevices: MediaDeviceInfo[] = [];

    // Remove lost devices
    const lostDeviceIds: string[] = difference(knownDeviceIds, updatedDeviceIds);
    lostDeviceIds.forEach((lostDeviceId: string) => {
      const lostDevice: MediaDeviceInfo | undefined = availableDevices.get(lostDeviceId);
      if (lostDevice) {
        availableDevices.delete(lostDeviceId);
        if (removeLostDevice(lostDevice)) { lostActiveDevices.push(lostDevice); }
      }
    });

    // Add any new devices, or devices with updated labels
    let deviceChanged: boolean = false;
    updatedDevices.forEach(newDevice => {
      const existingDevice: MediaDeviceInfo | undefined = availableDevices.get(newDevice.deviceId);
      const newMediaDeviceInfo: MediaDeviceInfo = this._wrapMediaDeviceInfo(newDevice);

      if (!existingDevice || existingDevice.label !== newMediaDeviceInfo.label) {
        availableDevices.set(newDevice.deviceId, newMediaDeviceInfo);
        deviceChanged = true;
      }
    });

    if (deviceChanged || lostDeviceIds.length) {
      // Force a new gUM in case the underlying tracks of the active stream have changed. One
      //   reason this might happen is when `default` is selected and set to a USB device,
      //   then that device is unplugged or plugged back in. We can't check for the 'ended'
      //   event or readyState because it is asynchronous and may take upwards of 5 seconds,
      //   in my testing. (rrowland)
      if (this.inputDevice !== null && this.inputDevice.deviceId === 'default') {
        this._log.warn(`Calling getUserMedia after device change to ensure that the \
          tracks of the active device (default) have not gone stale.`);
        this._setInputDevice(this.inputDevice.deviceId, true);
      }

      this.emit('deviceChange', lostActiveDevices);
    }
  }

  /**
   * Disconnect the old input volume source, and create and connect a new one with the current
   * input stream.
   */
  private _updateVolumeSource(): void {
    if (!this._inputStream || !this._audioContext || !this._inputVolumeAnalyser) {
      return;
    }

    if (this._inputVolumeSource) {
      this._inputVolumeSource.disconnect();
    }

    try {
      this._inputVolumeSource = this._audioContext.createMediaStreamSource(this._inputStream);
      this._inputVolumeSource.connect(this._inputVolumeAnalyser);
    } catch (ex) {
      this._log.warn('Unable to update volume source', ex);
      delete this._inputVolumeSource;
    }
  }

  /**
   * Convert a MediaDeviceInfo to a IMediaDeviceInfoShim.
   * @param mediaDeviceInfo - The info to convert
   * @returns The converted shim
   */
  private _wrapMediaDeviceInfo(mediaDeviceInfo: MediaDeviceInfo): MediaDeviceInfo {
    const options: Record<string, string> = {
      deviceId: mediaDeviceInfo.deviceId,
      groupId: mediaDeviceInfo.groupId,
      kind: mediaDeviceInfo.kind,
      label: mediaDeviceInfo.label,
    };

    if (!options.label) {
      if (options.deviceId === 'default') {
        options.label = 'Default';
      } else {
        const index: number = this._getUnknownDeviceIndex(mediaDeviceInfo);
        options.label = `Unknown ${kindAliases[options.kind]} Device ${index}`;
      }
    }

    return new MediaDeviceInfoShim(options) as MediaDeviceInfo;
  }
}

namespace AudioHelper {
  /**
   * Emitted when the available set of Devices changes.
   * @param lostActiveDevices - An array containing any Devices that were previously active
   * that were lost as a result of this deviceChange event.
   * @example `device.audio.on('deviceChange', lostActiveDevices => { })`
   * @event
   * @private
   */
  declare function deviceChangeEvent(lostActiveDevices: MediaDeviceInfo[]): void;

  /**
   * Emitted on `requestAnimationFrame` (up to 60fps, depending on browser) with
   *   the current input and output volumes, as a percentage of maximum
   *   volume, between -100dB and -30dB. Represented by a floating point
   *   number.
   * @param inputVolume - A floating point number between 0.0 and 1.0 inclusive.
   * @example `device.audio.on('inputVolume', volume => { })`
   * @event
   */
  declare function inputVolumeEvent(inputVolume: number): void;

  /**
   * An object like MediaDevices.
   * @private
   */
  export interface MediaDevicesLike {
    addEventListener?: (eventName: string, handler: (...args: any[]) => void) => void;
    enumerateDevices: (...args: any[]) => any;
    getUserMedia: (...args: any[]) => any;
    removeEventListener?: (eventName: string, handler: (...args: any[]) => void) => void;
  }

  /**
   * Options that can be passed to the AudioHelper constructor
   * @private
   */
  export interface Options {
    /**
     * A custom replacement for the AudioContext constructor.
     */
    AudioContext?: typeof AudioContext;

    /**
     * An existing AudioContext instance to use.
     */
    audioContext?: AudioContext;

    /**
     * Whether each sound is enabled.
     */
    enabledSounds?: Record<Device.ToggleableSound, boolean>;

    /**
     * Overrides the native MediaDevices.enumerateDevices API.
     */
    enumerateDevices?: any;

    /**
     * A custom MediaDevices instance to use.
     */
    mediaDevices?: AudioHelper.MediaDevicesLike;

    /**
     * A custom setSinkId function to use.
     */
    setSinkId?: (sinkId: string) => Promise<void>;
  }
}

export default AudioHelper;
