/* @flow */

import { i18next } from '../../base/i18n';

import {
    FlacAdapter,
    OggAdapter,
    WavAdapter,
    downloadBlob
} from '../recording';
import { sessionManager } from '../session';

const logger = require('jitsi-meet-logger').getLogger(__filename);

/**
 * 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:
                        JSON.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();
