const MathUtil = require('../util/math-util');
const Cast = require('../util/cast');
const Clone = require('../util/clone');

/**
 * Occluded boolean value to make its use more understandable.
 * @const {boolean}
 */
const STORE_WAITING = true;

class Scratch3SoundBlocks {
    constructor (runtime) {
        /**
         * The runtime instantiating this block package.
         * @type {Runtime}
         */
        this.runtime = runtime;

        this.waitingSounds = {};

        // Clear sound effects on green flag and stop button events.
        this.stopAllSounds = this.stopAllSounds.bind(this);
        this._stopWaitingSoundsForTarget = this._stopWaitingSoundsForTarget.bind(this);
        this._clearEffectsForAllTargets = this._clearEffectsForAllTargets.bind(this);
        if (this.runtime) {
            this.runtime.on('PROJECT_STOP_ALL', this.stopAllSounds);
            this.runtime.on('PROJECT_STOP_ALL', this._clearEffectsForAllTargets);
            this.runtime.on('STOP_FOR_TARGET', this._stopWaitingSoundsForTarget);
            this.runtime.on('PROJECT_START', this._clearEffectsForAllTargets);
        }

        this._onTargetCreated = this._onTargetCreated.bind(this);
        if (this.runtime) {
            runtime.on('targetWasCreated', this._onTargetCreated);
        }
    }

    /**
     * The key to load & store a target's sound-related state.
     * @type {string}
     */
    static get STATE_KEY () {
        return 'Scratch.sound';
    }

    /**
     * The default sound-related state, to be used when a target has no existing sound state.
     * @type {SoundState}
     */
    static get DEFAULT_SOUND_STATE () {
        return {
            effects: {
                pitch: 0,
                pan: 0
            }
        };
    }

    /**
     * The minimum and maximum MIDI note numbers, for clamping the input to play note.
     * @type {{min: number, max: number}}
     */
    static get MIDI_NOTE_RANGE () {
        return {min: 36, max: 96}; // C2 to C7
    }

    /**
     * The minimum and maximum beat values, for clamping the duration of play note, play drum and rest.
     * 100 beats at the default tempo of 60bpm is 100 seconds.
     * @type {{min: number, max: number}}
     */
    static get BEAT_RANGE () {
        return {min: 0, max: 100};
    }

    /** The minimum and maximum tempo values, in bpm.
     * @type {{min: number, max: number}}
     */
    static get TEMPO_RANGE () {
        return {min: 20, max: 500};
    }

    /** The minimum and maximum values for each sound effect.
     * @type {{effect:{min: number, max: number}}}
     */
    static get EFFECT_RANGE () {
        return {
            pitch: {min: -360, max: 360}, // -3 to 3 octaves
            pan: {min: -100, max: 100} // 100% left to 100% right
        };
    }

    /**
     * @param {Target} target - collect sound state for this target.
     * @returns {SoundState} the mutable sound state associated with that target. This will be created if necessary.
     * @private
     */
    _getSoundState (target) {
        let soundState = target.getCustomState(Scratch3SoundBlocks.STATE_KEY);
        if (!soundState) {
            soundState = Clone.simple(Scratch3SoundBlocks.DEFAULT_SOUND_STATE);
            target.setCustomState(Scratch3SoundBlocks.STATE_KEY, soundState);
            target.soundEffects = soundState.effects;
        }
        return soundState;
    }

    /**
     * When a Target is cloned, clone the sound state.
     * @param {Target} newTarget - the newly created target.
     * @param {Target} [sourceTarget] - the target used as a source for the new clone, if any.
     * @listens Runtime#event:targetWasCreated
     * @private
     */
    _onTargetCreated (newTarget, sourceTarget) {
        if (sourceTarget) {
            const soundState = sourceTarget.getCustomState(Scratch3SoundBlocks.STATE_KEY);
            if (soundState && newTarget) {
                newTarget.setCustomState(Scratch3SoundBlocks.STATE_KEY, Clone.simple(soundState));
                this._syncEffectsForTarget(newTarget);
            }
        }
    }

    /**
     * Retrieve the block primitives implemented by this package.
     * @return {object.<string, Function>} Mapping of opcode to Function.
     */
    getPrimitives () {
        return {
            sound_play: this.playSound,
            sound_playuntildone: this.playSoundAndWait,
            sound_stopallsounds: this.stopAllSounds,
            sound_seteffectto: this.setEffect,
            sound_changeeffectby: this.changeEffect,
            sound_cleareffects: this.clearEffects,
            sound_sounds_menu: this.soundsMenu,
            sound_beats_menu: this.beatsMenu,
            sound_effects_menu: this.effectsMenu,
            sound_setvolumeto: this.setVolume,
            sound_changevolumeby: this.changeVolume,
            sound_volume: this.getVolume
        };
    }

    getMonitored () {
        return {
            sound_volume: {
                isSpriteSpecific: true,
                getId: targetId => `${targetId}_volume`
            }
        };
    }

    playSound (args, util) {
        // Don't return the promise, it's the only difference for AndWait
        this._playSound(args, util);
    }

    playSoundAndWait (args, util) {
        return this._playSound(args, util, STORE_WAITING);
    }

    _playSound (args, util, storeWaiting) {
        const index = this._getSoundIndex(args.SOUND_MENU, util);
        if (index >= 0) {
            const {target} = util;
            const {sprite} = target;
            const {soundId} = sprite.sounds[index];
            if (sprite.soundBank) {
                if (storeWaiting === STORE_WAITING) {
                    this._addWaitingSound(target.id, soundId);
                } else {
                    this._removeWaitingSound(target.id, soundId);
                }
                return sprite.soundBank.playSound(target, soundId);
            }
        }
    }

    _addWaitingSound (targetId, soundId) {
        if (!this.waitingSounds[targetId]) {
            this.waitingSounds[targetId] = new Set();
        }
        this.waitingSounds[targetId].add(soundId);
    }

    _removeWaitingSound (targetId, soundId) {
        if (!this.waitingSounds[targetId]) {
            return;
        }
        this.waitingSounds[targetId].delete(soundId);
    }

    _getSoundIndex (soundName, util) {
        // if the sprite has no sounds, return -1
        const len = util.target.sprite.sounds.length;
        if (len === 0) {
            return -1;
        }

        // look up by name first
        const index = this.getSoundIndexByName(soundName, util);
        if (index !== -1) {
            return index;
        }

        // then try using the sound name as a 1-indexed index
        const oneIndexedIndex = parseInt(soundName, 10);
        if (!isNaN(oneIndexedIndex)) {
            return MathUtil.wrapClamp(oneIndexedIndex - 1, 0, len - 1);
        }

        // could not be found as a name or converted to index, return -1
        return -1;
    }

    getSoundIndexByName (soundName, util) {
        const sounds = util.target.sprite.sounds;
        for (let i = 0; i < sounds.length; i++) {
            if (sounds[i].name === soundName) {
                return i;
            }
        }
        // if there is no sound by that name, return -1
        return -1;
    }

    stopAllSounds () {
        if (this.runtime.targets === null) return;
        const allTargets = this.runtime.targets;
        for (let i = 0; i < allTargets.length; i++) {
            this._stopAllSoundsForTarget(allTargets[i]);
        }
    }

    _stopAllSoundsForTarget (target) {
        if (target.sprite.soundBank) {
            target.sprite.soundBank.stopAllSounds(target);
            if (this.waitingSounds[target.id]) {
                this.waitingSounds[target.id].clear();
            }
        }
    }

    _stopWaitingSoundsForTarget (target) {
        if (target.sprite.soundBank) {
            if (this.waitingSounds[target.id]) {
                for (const soundId of this.waitingSounds[target.id].values()) {
                    target.sprite.soundBank.stop(target, soundId);
                }
                this.waitingSounds[target.id].clear();
            }
        }
    }

    setEffect (args, util) {
        return this._updateEffect(args, util, false);
    }

    changeEffect (args, util) {
        return this._updateEffect(args, util, true);
    }

    _updateEffect (args, util, change) {
        const effect = Cast.toString(args.EFFECT).toLowerCase();
        const value = Cast.toNumber(args.VALUE);

        const soundState = this._getSoundState(util.target);
        if (!soundState.effects.hasOwnProperty(effect)) return;

        if (change) {
            soundState.effects[effect] += value;
        } else {
            soundState.effects[effect] = value;
        }

        const {min, max} = Scratch3SoundBlocks.EFFECT_RANGE[effect];
        soundState.effects[effect] = MathUtil.clamp(soundState.effects[effect], min, max);

        this._syncEffectsForTarget(util.target);
        // Yield until the next tick.
        return Promise.resolve();
    }

    _syncEffectsForTarget (target) {
        if (!target || !target.sprite.soundBank) return;
        target.soundEffects = this._getSoundState(target).effects;

        target.sprite.soundBank.setEffects(target);
    }

    clearEffects (args, util) {
        this._clearEffectsForTarget(util.target);
    }

    _clearEffectsForTarget (target) {
        const soundState = this._getSoundState(target);
        for (const effect in soundState.effects) {
            if (!soundState.effects.hasOwnProperty(effect)) continue;
            soundState.effects[effect] = 0;
        }
        this._syncEffectsForTarget(target);
    }

    _clearEffectsForAllTargets () {
        if (this.runtime.targets === null) return;
        const allTargets = this.runtime.targets;
        for (let i = 0; i < allTargets.length; i++) {
            this._clearEffectsForTarget(allTargets[i]);
        }
    }

    setVolume (args, util) {
        const volume = Cast.toNumber(args.VOLUME);
        return this._updateVolume(volume, util);
    }

    changeVolume (args, util) {
        const volume = Cast.toNumber(args.VOLUME) + util.target.volume;
        return this._updateVolume(volume, util);
    }

    _updateVolume (volume, util) {
        volume = MathUtil.clamp(volume, 0, 100);
        util.target.volume = volume;
        this._syncEffectsForTarget(util.target);

        // Yield until the next tick.
        return Promise.resolve();
    }

    getVolume (args, util) {
        return util.target.volume;
    }

    soundsMenu (args) {
        return args.SOUND_MENU;
    }

    beatsMenu (args) {
        return args.BEATS;
    }

    effectsMenu (args) {
        return args.EFFECT;
    }
}

module.exports = Scratch3SoundBlocks;
