text: /* @flow */
  
  import Bourne from '@hapi/bourne';
  
  import { i18next } from '../../base/i18n';
  import logger from '../logger';
  import {
      FlacAdapter,
      OggAdapter,
      WavAdapter,
      downloadBlob
  } from '../recording';
  import { sessionManager } from '../session';
  
  /**
   * XMPP command for signaling the start of local recording to all clients.
   * Should be sent by the moderator only.
   */
  const COMMAND_START = 'localRecStart';
  
  /**
   * XMPP command for signaling the stop of local recording to all clients.
   * Should be sent by the moderator only.
   */
  const COMMAND_STOP = 'localRecStop';
  
  /**
   * One-time command used to trigger the moderator to resend the commands.
   * This is a workaround for newly-joined clients to receive remote presence.
   */
  const COMMAND_PING = 'localRecPing';
  
  /**
   * One-time command sent upon receiving a {@code COMMAND_PING}.
   * Only the moderator sends this command.
   * This command does not carry any information itself, but rather forces the
   * XMPP server to resend the remote presence.
   */
  const COMMAND_PONG = 'localRecPong';
  
  /**
   * Participant property key for local recording stats.
   */
  const PROPERTY_STATS = 'localRecStats';
  
  /**
   * Supported recording formats.
   */
  const RECORDING_FORMATS = new Set([ 'flac', 'wav', 'ogg' ]);
  
  /**
   * Default recording format.
   */
  const DEFAULT_RECORDING_FORMAT = 'flac';
  
  /**
   * States of the {@code RecordingController}.
   */
  const ControllerState = Object.freeze({
      /**
       * Idle (not recording).
       */
      IDLE: Symbol('IDLE'),
  
      /**
       * Starting.
       */
      STARTING: Symbol('STARTING'),
  
      /**
       * Engaged (recording).
       */
      RECORDING: Symbol('RECORDING'),
  
      /**
       * Stopping.
       */
      STOPPING: Symbol('STOPPING'),
  
      /**
       * Failed, due to error during starting / stopping process.
       */
      FAILED: Symbol('FAILED')
  });
  
  /**
   * Type of the stats reported by each participant (client).
   */
  type RecordingStats = {
  
      /**
       * Current local recording session token used by the participant.
       */
      currentSessionToken: number,
  
      /**
       * Whether local recording is engaged on the participant's device.
       */
      isRecording: boolean,
  
      /**
       * Total recorded bytes. (Reserved for future use.)
       */
      recordedBytes: number,
  
      /**
       * Total recording duration. (Reserved for future use.)
       */
      recordedLength: number
  }
  
  /**
   * The component responsible for the coordination of local recording, across
   * multiple participants.
   * Current implementation requires that there is only one moderator in a room.
   */
  class RecordingController {
  
      /**
       * For each recording session, there is a separate @{code RecordingAdapter}
       * instance so that encoded bits from the previous sessions can still be
       * retrieved after they ended.
       *
       * @private
       */
      _adapters = {};
  
      /**
       * The {@code JitsiConference} instance.
       *
       * @private
       */
      _conference: * = null;
  
      /**
       * Current recording session token.
       * Session token is a number generated by the moderator, to ensure every
       * client is in the same recording state.
       *
       * @private
       */
      _currentSessionToken: number = -1;
  
      /**
       * Current state of {@code RecordingController}.
       *
       * @private
       */
      _state = ControllerState.IDLE;
  
      /**
       * Whether or not the audio is muted in the UI. This is stored as internal
       * state of {@code RecordingController} because we might have recording
       * sessions that start muted.
       */
      _isMuted = false;
  
      /**
       * The ID of the active microphone.
       *
       * @private
       */
      _micDeviceId = 'default';
  
      /**
       * Current recording format. This will be in effect from the next
       * recording session, i.e., if this value is changed during an on-going
       * recording session, that on-going session will not use the new format.
       *
       * @private
       */
      _format = DEFAULT_RECORDING_FORMAT;
  
      /**
       * Whether or not the {@code RecordingController} has registered for
       * XMPP events. Prevents initialization from happening multiple times.
       *
       * @private
       */
      _registered = false;
  
      /**
       * FIXME: callback function for the {@code RecordingController} to notify
       * UI it wants to display a notice. Keeps {@code RecordingController}
       * decoupled from UI.
       */
      _onNotify: ?(messageKey: string, messageParams?: Object) => void;
  
      /**
       * FIXME: callback function for the {@code RecordingController} to notify
       * UI it wants to display a warning. Keeps {@code RecordingController}
       * decoupled from UI.
       */
      _onWarning: ?(messageKey: string, messageParams?: Object) => void;
  
      /**
       * FIXME: callback function for the {@code RecordingController} to notify
       * UI that the local recording state has changed.
       */
      _onStateChanged: ?(boolean) => void;
  
      /**
       * Constructor.
       *
       * @returns {void}
       */
      constructor() {
          this.registerEvents = this.registerEvents.bind(this);
          this.getParticipantsStats = this.getParticipantsStats.bind(this);
          this._onStartCommand = this._onStartCommand.bind(this);
          this._onStopCommand = this._onStopCommand.bind(this);
          this._onPingCommand = this._onPingCommand.bind(this);
          this._doStartRecording = this._doStartRecording.bind(this);
          this._doStopRecording = this._doStopRecording.bind(this);
          this._updateStats = this._updateStats.bind(this);
          this._switchToNewSession = this._switchToNewSession.bind(this);
      }
  
      registerEvents: () => void;
  
      /**
       * Registers listeners for XMPP events.
       *
       * @param {JitsiConference} conference - A {@code JitsiConference} instance.
       * @returns {void}
       */
      registerEvents(conference: Object) {
          if (!this._registered) {
              this._conference = conference;
              if (this._conference) {
                  this._conference
                      .addCommandListener(COMMAND_STOP, this._onStopCommand);
                  this._conference
                      .addCommandListener(COMMAND_START, this._onStartCommand);
                  this._conference
                      .addCommandListener(COMMAND_PING, this._onPingCommand);
                  this._registered = true;
              }
              if (!this._conference.isModerator()) {
                  this._conference.sendCommandOnce(COMMAND_PING, {});
              }
          }
      }
  
      /**
       * Sets the event handler for {@code onStateChanged}.
       *
       * @param {Function} delegate - The event handler.
       * @returns {void}
       */
      set onStateChanged(delegate: Function) {
          this._onStateChanged = delegate;
      }
  
      /**
       * Sets the event handler for {@code onNotify}.
       *
       * @param {Function} delegate - The event handler.
       * @returns {void}
       */
      set onNotify(delegate: Function) {
          this._onNotify = delegate;
      }
  
      /**
       * Sets the event handler for {@code onWarning}.
       *
       * @param {Function} delegate - The event handler.
       * @returns {void}
       */
      set onWarning(delegate: Function) {
          this._onWarning = delegate;
      }
  
      /**
       * Signals the participants to start local recording.
       *
       * @returns {void}
       */
      startRecording() {
          this.registerEvents();
          if (this._conference && this._conference.isModerator()) {
              this._conference.removeCommand(COMMAND_STOP);
              this._conference.sendCommand(COMMAND_START, {
                  attributes: {
                      sessionToken: this._getRandomToken(),
                      format: this._format
                  }
              });
          } else if (this._onWarning) {
              this._onWarning('localRecording.messages.notModerator');
          }
      }
  
      /**
       * Signals the participants to stop local recording.
       *
       * @returns {void}
       */
      stopRecording() {
          if (this._conference) {
              if (this._conference.isModerator()) {
                  this._conference.removeCommand(COMMAND_START);
                  this._conference.sendCommand(COMMAND_STOP, {
                      attributes: {
                          sessionToken: this._currentSessionToken
                      }
                  });
              } else if (this._onWarning) {
                  this._onWarning('localRecording.messages.notModerator');
              }
          }
      }
  
      /**
       * Triggers the download of recorded data.
       * Browser only.
       *
       * @param {number} sessionToken - The token of the session to download.
       * @returns {void}
       */
      downloadRecordedData(sessionToken: number) {
          if (this._adapters[sessionToken]) {
              this._adapters[sessionToken].exportRecordedData()
                  .then(args => {
                      const { data, format } = args;
  
                      const filename = `session_${sessionToken}`
                          + `_${this._conference.myUserId()}.${format}`;
  
                      downloadBlob(data, filename);
                  })
                  .catch(error => {
                      logger.error('Failed to download audio for'
                          + ` session ${sessionToken}. Error: ${error}`);
                  });
          } else {
              logger.error(`Invalid session token for download ${sessionToken}`);
          }
      }
  
      /**
       * Changes the current microphone.
       *
       * @param {string} micDeviceId - The new microphone device ID.
       * @returns {void}
       */
      setMicDevice(micDeviceId: string) {
          if (micDeviceId !== this._micDeviceId) {
              this._micDeviceId = String(micDeviceId);
  
              if (this._state === ControllerState.RECORDING) {
                  // sessionManager.endSegment(this._currentSessionToken);
                  logger.log('Before switching microphone...');
                  this._adapters[this._currentSessionToken]
                      .setMicDevice(this._micDeviceId)
                      .then(() => {
                          logger.log('Finished switching microphone.');
  
                          // sessionManager.beginSegment(this._currentSesoken);
                      })
                      .catch(() => {
                          logger.error('Failed to switch microphone');
                      });
              }
              logger.log(`Switch microphone to ${this._micDeviceId}`);
          }
      }
  
      /**
       * Mute or unmute audio. When muted, the ongoing local recording should
       * produce silence.
       *
       * @param {boolean} muted - If the audio should be muted.
       * @returns {void}
       */
      setMuted(muted: boolean) {
          this._isMuted = Boolean(muted);
  
          if (this._state === ControllerState.RECORDING) {
              this._adapters[this._currentSessionToken].setMuted(this._isMuted);
          }
      }
  
      /**
       * Switches the recording format.
       *
       * @param {string} newFormat - The new format.
       * @returns {void}
       */
      switchFormat(newFormat: string) {
          if (!RECORDING_FORMATS.has(newFormat)) {
              logger.log(`Unknown format ${newFormat}. Ignoring...`);
  
              return;
          }
          this._format = newFormat;
          logger.log(`Recording format switched to ${newFormat}`);
  
          // the new format will be used in the next recording session
      }
  
      /**
       * Returns the local recording stats.
       *
       * @returns {RecordingStats}
       */
      getLocalStats(): RecordingStats {
          return {
              currentSessionToken: this._currentSessionToken,
              isRecording: this._state === ControllerState.RECORDING,
              recordedBytes: 0,
              recordedLength: 0
          };
      }
  
      getParticipantsStats: () => *;
  
      /**
       * Returns the remote participants' local recording stats.
       *
       * @returns {*}
       */
      getParticipantsStats() {
          const members
              = this._conference.getParticipants()
              .map(member => {
                  return {
                      id: member.getId(),
                      displayName: member.getDisplayName(),
                      recordingStats:
                          Bourne.parse(member.getProperty(PROPERTY_STATS) || '{}'),
                      isSelf: false
                  };
              });
  
          // transform into a dictionary for consistent ordering
          const result = {};
  
          for (let i = 0; i < members.length; ++i) {
              result[members[i].id] = members[i];
          }
          const localId = this._conference.myUserId();
  
          result[localId] = {
              id: localId,
              displayName: i18next.t('localRecording.me'),
              recordingStats: this.getLocalStats(),
              isSelf: true
          };
  
          return result;
      }
  
      _changeState: (Symbol) => void;
  
      /**
       * Changes the current state of {@code RecordingController}.
       *
       * @private
       * @param {Symbol} newState - The new state.
       * @returns {void}
       */
      _changeState(newState: Symbol) {
          if (this._state !== newState) {
              logger.log(`state change: ${this._state.toString()} -> `
                  + `${newState.toString()}`);
              this._state = newState;
          }
      }
  
      _updateStats: () => void;
  
      /**
       * Sends out updates about the local recording stats via XMPP.
       *
       * @private
       * @returns {void}
       */
      _updateStats() {
          if (this._conference) {
              this._conference.setLocalParticipantProperty(PROPERTY_STATS,
                  JSON.stringify(this.getLocalStats()));
          }
      }
  
      _onStartCommand: (*) => void;
  
      /**
       * Callback function for XMPP event.
       *
       * @private
       * @param {*} value - The event args.
       * @returns {void}
       */
      _onStartCommand(value) {
          const { sessionToken, format } = value.attributes;
  
          if (this._state === ControllerState.IDLE) {
              this._changeState(ControllerState.STARTING);
              this._switchToNewSession(sessionToken, format);
              this._doStartRecording();
          } else if (this._state === ControllerState.RECORDING
              && this._currentSessionToken !== sessionToken) {
              // There is local recording going on, but not for the same session.
              // This means the current state might be out-of-sync with the
              // moderator's, so we need to restart the recording.
              this._changeState(ControllerState.STOPPING);
              this._doStopRecording().then(() => {
                  this._changeState(ControllerState.STARTING);
                  this._switchToNewSession(sessionToken, format);
                  this._doStartRecording();
              });
          }
      }
  
      _onStopCommand: (*) => void;
  
      /**
       * Callback function for XMPP event.
       *
       * @private
       * @param {*} value - The event args.
       * @returns {void}
       */
      _onStopCommand(value) {
          if (this._state === ControllerState.RECORDING
              && this._currentSessionToken === value.attributes.sessionToken) {
              this._changeState(ControllerState.STOPPING);
              this._doStopRecording();
          }
      }
  
      _onPingCommand: () => void;
  
      /**
       * Callback function for XMPP event.
       *
       * @private
       * @returns {void}
       */
      _onPingCommand() {
          if (this._conference.isModerator()) {
              logger.log('Received ping, sending pong.');
              this._conference.sendCommandOnce(COMMAND_PONG, {});
          }
      }
  
      /**
       * Generates a token that can be used to distinguish each local recording
       * session.
       *
       * @returns {number}
       */
      _getRandomToken() {
          return Math.floor(Math.random() * 100000000) + 1;
      }
  
      _doStartRecording: () => void;
  
      /**
       * Starts the recording locally.
       *
       * @private
       * @returns {void}
       */
      _doStartRecording() {
          if (this._state === ControllerState.STARTING) {
              const delegate = this._adapters[this._currentSessionToken];
  
              delegate.start(this._micDeviceId)
              .then(() => {
                  this._changeState(ControllerState.RECORDING);
                  sessionManager.beginSegment(this._currentSessionToken);
                  logger.log('Local recording engaged.');
  
                  if (this._onNotify) {
                      this._onNotify('localRecording.messages.engaged');
                  }
                  if (this._onStateChanged) {
                      this._onStateChanged(true);
                  }
  
                  delegate.setMuted(this._isMuted);
                  this._updateStats();
              })
              .catch(err => {
                  logger.error('Failed to start local recording.', err);
              });
          }
  
      }
  
      _doStopRecording: () => Promise<void>;
  
      /**
       * Stops the recording locally.
       *
       * @private
       * @returns {Promise<void>}
       */
      _doStopRecording() {
          if (this._state === ControllerState.STOPPING) {
              const token = this._currentSessionToken;
  
              return this._adapters[this._currentSessionToken]
                  .stop()
                  .then(() => {
                      this._changeState(ControllerState.IDLE);
                      sessionManager.endSegment(this._currentSessionToken);
                      logger.log('Local recording unengaged.');
                      this.downloadRecordedData(token);
  
                      const messageKey
                          = this._conference.isModerator()
                              ? 'localRecording.messages.finishedModerator'
                              : 'localRecording.messages.finished';
                      const messageParams = {
                          token
                      };
  
                      if (this._onNotify) {
                          this._onNotify(messageKey, messageParams);
                      }
                      if (this._onStateChanged) {
                          this._onStateChanged(false);
                      }
                      this._updateStats();
                  })
                  .catch(err => {
                      logger.error('Failed to stop local recording.', err);
                  });
          }
  
          /* eslint-disable */
          return (Promise.resolve(): Promise<void>);
          // FIXME: better ways to satisfy flow and ESLint at the same time?
          /* eslint-enable */
  
      }
  
      _switchToNewSession: (string, string) => void;
  
      /**
       * Switches to a new local recording session.
       *
       * @param {string} sessionToken - The session Token.
       * @param {string} format - The recording format for the session.
       * @returns {void}
       */
      _switchToNewSession(sessionToken, format) {
          this._format = format;
          this._currentSessionToken = sessionToken;
          logger.log(`New session: ${this._currentSessionToken}, `
              + `format: ${this._format}`);
          this._adapters[sessionToken]
               = this._createRecordingAdapter();
          sessionManager.createSession(sessionToken, this._format);
      }
  
      /**
       * Creates a recording adapter according to the current recording format.
       *
       * @private
       * @returns {RecordingAdapter}
       */
      _createRecordingAdapter() {
          logger.debug('[RecordingController] creating recording'
              + ` adapter for ${this._format} format.`);
  
          switch (this._format) {
          case 'ogg':
              return new OggAdapter();
          case 'flac':
              return new FlacAdapter();
          case 'wav':
              return new WavAdapter();
          default:
              throw new Error(`Unknown format: ${this._format}`);
          }
      }
  }
  
  /**
   * Global singleton of {@code RecordingController}.
   */
  export const recordingController = new RecordingController();
  