import { utils } from 'pixi.js';
import { sound } from '@pixi/sound';
import Locator from '../Locator';

import type { IMediaInstance, PlayOptions, SoundAsset, SoundSpriteDefinition } from '@pixi/sound';
import type { Service } from '../types';

type SpritePlayOptions = PlayOptions & {
    singleInstance?: boolean;
    singleInstanceDuration?: number;
};

type AudioSpriteNodes = {
    visibilityMute: number;
    muteAll: number;
    volumeAll: number;
    muteMusic: number;
    volumeMusic: number;
    fadeMusic: number;
    fadeAmbient: number;
    muteSFX: number;
    volumeSFX: number;
};

interface PlayingSound
{
    id: number;
    name: string;
    webAudioInstance: IMediaInstance;
}

/** Audio mixer tailored for sprite sheets exported via PIXI Sound's sprite support. */
export default class AudioSprites implements Service
{
    public name = 'AudioSprites';
    public readonly serviceType = 'audio';
    public status: 'sound_on' | 'sound_off' = 'sound_on';
    public readonly context = sound.context;

    private readonly nodes: AudioSpriteNodes;
    private _singleInstanceSounds: Record<string, number> = {};
    private _playingSounds: PlayingSound[] = [];

    private readonly handleVisibilityChange = (): void =>
    {
        this.nodes.visibilityMute = document.visibilityState !== 'visible' ? 0 : 1;
        this.setAllPlayingSoundsVolume();
    };

    constructor()
    {
        this.nodes = {
            visibilityMute: 1,
            muteAll: 1,
            volumeAll: 1,
            muteMusic: 1,
            volumeMusic: 1,
            fadeMusic: 1,
            fadeAmbient: 1,
            muteSFX: 1,
            volumeSFX: 1,
        };

        sound.disableAutoPause = true;
        document.addEventListener('visibilitychange', this.handleVisibilityChange);

        void this.checkAudioContext();
    }

    private async checkAudioContext(): Promise<void>
    {
        if (this.context.audioContext.state === 'suspended')
        {
            await new Promise((resolve) => setTimeout(resolve, 500));
            await this.context.audioContext.resume();
            await this.checkAudioContext();
        }
    }

    public play(name: string, options: SpritePlayOptions = {}): IMediaInstance | undefined
    {
        if (!name)
        {
            console.warn('AudioSprites: sound name is not provided:', name);

            return undefined;
        }

        const sprite = this.findSoundSpriteByName(name);

        if (!sprite)
        {
            console.warn('AudioSprites: sound sprite not found:', name);

            return undefined;
        }

        if ((sprite.singleInstance || options.singleInstance) && this.isPlaying(name))
        {
            return undefined;
        }

        if (options.singleInstanceDuration)
        {
            if (this.isOverlapping(name, options.singleInstanceDuration))
            {
                return undefined;
            }

            this._singleInstanceSounds[name] = performance.now();
        }

        const soundSheetName = this.findSpriteParentName(name);

        if (!soundSheetName)
        {
            return undefined;
        }

        const id = utils.uid();
        const complete = (): void =>
        {
            options.complete?.();
            this.removePlayingSoundById(id);
        };
        const webAudioInstance = sound.play(soundSheetName, { ...options, sprite: name, complete });

        const instance: PlayingSound = { id, name, webAudioInstance };

        this._playingSounds.push(instance);
        this.setPlayingSoundVolume(id);

        return webAudioInstance;
    }

    public stop(name?: string): void
    {
        if (!name)
        {
            return;
        }

        this.stopPlayingSoundsByName(name);
    }

    public stopAll(): void
    {
        this._playingSounds.forEach((ps) => ps.webAudioInstance.stop());
        this._playingSounds = [];
        sound.stopAll();
    }

    public findSoundSpriteByName(soundName: string): SoundSpriteDefinition | undefined
    {
        return this.soundSprites[soundName];
    }

    private findSpriteParentName(soundName: string): string
    {
        return Object.entries(sound._sounds)
            .reduce((acc, [sheetName, soundSheet]) =>
            {
                const sprites = soundSheet._sprites ?? {};

                return Object.prototype.hasOwnProperty.call(sprites, soundName) ? sheetName : acc;
            }, '');
    }

    public get volumeAll(): number
    {
        return this.nodes.volumeAll;
    }

    public set volumeAll(value: number)
    {
        this.nodes.volumeAll = value;
        this.setAllPlayingSoundsVolume();
    }

    public get muteAll(): boolean
    {
        return this.nodes.muteAll === 0;
    }

    public set muteAll(value: boolean)
    {
        this.status = value ? 'sound_off' : 'sound_on';
        this.nodes.muteAll = value ? 0 : 1;
        this.setAllPlayingSoundsVolume();
    }

    public get muteMusic(): boolean
    {
        return this.nodes.muteMusic === 0;
    }

    public set muteMusic(value: boolean)
    {
        this.nodes.muteMusic = value ? 0 : 1;
        this.setAllPlayingSoundsVolume();
    }

    public get musicVolume(): number
    {
        return this.nodes.volumeMusic;
    }

    public set musicVolume(value: number)
    {
        this.nodes.volumeMusic = value;
        this.setAllPlayingSoundsVolume();
    }

    public get musicFade(): number
    {
        return this.nodes.fadeMusic;
    }

    public set musicFade(value: number)
    {
        this.nodes.fadeMusic = value;
        this.setAllPlayingSoundsVolume();
    }

    public get ambientFade(): number
    {
        return this.nodes.fadeAmbient;
    }

    public set ambientFade(value: number)
    {
        this.nodes.fadeAmbient = value;
        this.setAllPlayingSoundsVolume();
    }

    public get muteSFX(): boolean
    {
        return this.nodes.muteSFX === 0;
    }

    public set muteSFX(value: boolean)
    {
        this.nodes.muteSFX = value ? 0 : 1;
        this.setAllPlayingSoundsVolume();
    }

    public get sfxVolume(): number
    {
        return this.nodes.volumeSFX;
    }

    public set sfxVolume(value: number)
    {
        this.nodes.volumeSFX = value;
        this.setAllPlayingSoundsVolume();
    }

    public connectToNode(soundAlias: string, group: string): void
    {
        const sprite = this.findSoundSpriteByName(soundAlias);

        if (!sprite)
        {
            return;
        }

        sprite.group = group;
        sprite.volume = 1;
        sprite.singleInstance = false;
        Locator.events.emit('audio_node_connected');
    }

    public connectToMusicNode(soundAlias: string): void
    {
        this.connectToNode(soundAlias, 'music');
    }

    public connectToAmbientNode(soundAlias: string): void
    {
        this.connectToNode(soundAlias, 'ambient');
    }

    public connectToSFXNode(soundAlias: string): void
    {
        this.connectToNode(soundAlias, 'sfx');
    }

    public toggleMute(): void
    {
        this.muteAll = !this.muteAll;
    }

    public isPlaying(name: string): boolean
    {
        return this._playingSounds.some((ps) => ps.name === name);
    }

    public isOverlapping(name: string, singleInstanceDuration = Infinity): boolean
    {
        const started = this._singleInstanceSounds[name] ?? 0;

        return performance.now() - started < singleInstanceDuration;
    }

    public findPlayingSoundById(id: number): PlayingSound | undefined
    {
        return this._playingSounds.find((ps) => ps.id === id);
    }

    public filterPlayingSoundsByName(name: string): PlayingSound[]
    {
        return this._playingSounds.filter((ps) => ps.name === name);
    }

    public stopPlayingSoundsByName(name: string): void
    {
        const soundsToRemove: number[] = [];

        this.filterPlayingSoundsByName(name).forEach((soundInstance) =>
        {
            soundInstance.webAudioInstance.stop();
            soundsToRemove.push(soundInstance.id);
        });

        this._playingSounds = this._playingSounds.filter((ps) => !soundsToRemove.includes(ps.id));
    }

    public removePlayingSoundById(id: number): void
    {
        this._playingSounds = this._playingSounds.filter((ps) => ps.id !== id);
    }

    public getSoundVolume(soundName: string): number
    {
        const sprite = this.findSoundSpriteByName(soundName);

        if (!sprite)
        {
            return 0;
        }

        const {
            visibilityMute,
            muteAll,
            volumeAll,
            muteMusic,
            volumeMusic,
            fadeMusic,
            fadeAmbient,
            muteSFX,
            volumeSFX,
        } = this.nodes;

        let volume = (sprite.volume ?? 1) * visibilityMute * muteAll * volumeAll;

        switch (sprite.group)
        {
            case 'music':
                volume *= muteMusic * volumeMusic * fadeMusic;
                break;
            case 'ambient':
                volume *= fadeAmbient * muteSFX * volumeSFX;
                break;
            case 'sfx':
                volume *= muteSFX * volumeSFX;
                break;
        }

        return volume;
    }

    public setAllPlayingSoundsVolume = (): void =>
    {
        this._playingSounds?.forEach((ps) => this.setPlayingSoundVolume(ps.id));
        Locator.events.emit('audio/setAllPlayingSoundsVolume');
    };

    public setPlayingSoundVolume(id: number): void
    {
        const playingSound = this.findPlayingSoundById(id);

        if (!playingSound)
        {
            return;
        }

        playingSound.webAudioInstance.volume = this.getSoundVolume(playingSound.name);
    }

    public getPlayingSoundsVolume(soundName: string): number[]
    {
        return this.filterPlayingSoundsByName(soundName).map((ps) => ps.webAudioInstance.volume);
    }

    public get soundSprites(): Record<string, SoundSpriteDefinition>
    {
        return Object.values(sound._sounds).reduce<Record<string, SoundSpriteDefinition>>((acc, soundSheet: SoundAsset) =>
            ({ ...acc, ...(soundSheet._sprites ?? {}) }), {});
    }

    public pauseAll(): void
    {
        sound.pauseAll();
    }

    public resumeAll(): void
    {
        sound.resumeAll();
    }

    public dispose(): void
    {
        document.removeEventListener('visibilitychange', this.handleVisibilityChange);
        this.stopAll();
        this._singleInstanceSounds = {};
    }
}
