import { sound } from '@pixi/sound';

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

type AudioNodes = {
    visibilityMute: GainNode;
    muteAll: GainNode;
    volumeAll: GainNode;
    muteMusic: GainNode;
    volumeMusic: GainNode;
    fadeMusic: GainNode;
    fadeAmbient: GainNode;
    muteSFX: GainNode;
    volumeSFX: GainNode;
};

/**
 * Audio service that wires PIXI's {@link sound} graph into separate mixer buses
 * for music, ambience, and sound effects.
 */
export default class Audio implements Service
{
    public name = 'Audio';
    public readonly serviceType = 'audio';
    public status: 'sound_on' | 'sound_off' = 'sound_on';

    protected readonly context = sound.context;
    protected overlappingSounds: Record<string, number> = {};

    protected readonly nodes: AudioNodes;

    protected sfxEnterNode: GainNode;
    protected musicEnterNode: GainNode;
    protected ambientEnterNode: GainNode;

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

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

        this.nodes.visibilityMute.connect(this.context.destination);
        this.nodes.muteAll.connect(this.nodes.visibilityMute);
        this.nodes.volumeAll.connect(this.nodes.muteAll);

        this.nodes.muteMusic.connect(this.nodes.volumeAll);
        this.nodes.volumeMusic.connect(this.nodes.muteMusic);
        this.nodes.fadeMusic.connect(this.nodes.volumeMusic);
        this.nodes.fadeAmbient.connect(this.nodes.volumeMusic);

        this.nodes.muteSFX.connect(this.nodes.volumeAll);
        this.nodes.volumeSFX.connect(this.nodes.muteSFX);

        this.sfxEnterNode = this.nodes.volumeSFX;
        this.musicEnterNode = this.nodes.fadeMusic;
        this.ambientEnterNode = this.nodes.fadeAmbient;

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

        void this.checkAudioContext();
    }

    protected 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: PlayOptions = {}): IMediaInstance | undefined
    {
        if (!name)
        {
            return undefined;
        }

        return sound.play(name, options);
    }

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

        sound.stop(name);
    }

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

    public find(soundName: string): SoundAsset | undefined
    {
        return sound.find(soundName);
    }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    public connectToMusicNode(soundAlias: string): void
    {
        const asset = this.find(soundAlias);

        if (!asset)
        {
            return;
        }

        asset.group = 'music';
        this.rewireSound(asset, this.musicEnterNode);
    }

    public connectToAmbientNode(soundAlias: string): void
    {
        const asset = this.find(soundAlias);

        if (!asset)
        {
            return;
        }

        asset.group = 'ambient';
        this.rewireSound(asset, this.ambientEnterNode);
    }

    public connectToSFXNode(soundAlias: string): void
    {
        const asset = this.find(soundAlias);

        if (!asset)
        {
            return;
        }

        asset.group = 'sfx';
        this.rewireSound(asset, this.sfxEnterNode);
    }

    public isPlaying(name: string): boolean
    {
        return Boolean(sound._sounds[name]?.isPlaying);
    }

    public isOverlapping(name: string, overlappingDuration = Infinity): boolean
    {
        const lastPlayed = this.overlappingSounds[name] ?? 0;

        return performance.now() - lastPlayed < overlappingDuration;
    }

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

    public setSoundsProps(): void
    {
        // Implemented in subclasses when needed.
    }

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

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

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

    protected rewireSound(asset: SoundAsset, target: GainNode): void
    {
        try
        {
            asset.media.nodes.gain.disconnect(this.context.destination);
        }
        catch (error)
        {
            // Ignore connection errors from repeated rewiring.
        }

        try
        {
            asset.media.nodes.gain.connect(target);
        }
        catch (error)
        {
            // Ignore connection errors from repeated rewiring.
        }
    }
}
