import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AudioManager, type PlayOptions } from '../AudioManager';

/** Параметр WebAudio: значение + планировщик огибающей (мгновенно применяет). */
function makeParam(value = 1) {
    const p = {
        value,
        setValueAtTime: vi.fn((v: number) => {
            p.value = v;
        }),
        linearRampToValueAtTime: vi.fn((v: number) => {
            p.value = v;
        }),
        cancelScheduledValues: vi.fn()
    };
    return p;
}

/** Минимальный граф WebAudio: считает узлы, запоминает соединения. */
function makeCtx() {
    const graph = {
        gains: [] as { gain: ReturnType<typeof makeParam>; dests: unknown[] }[],
        sources: [] as { buffer: unknown; loop: boolean; playbackRate: ReturnType<typeof makeParam>; start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; dests: unknown[] }[],
        panners: [] as { pan: ReturnType<typeof makeParam>; dests: unknown[] }[],
        destination: { destination: true }
    };
    const connect = (dests: unknown[]) => (dest: unknown) => {
        dests.push(dest);
        return dest;
    };
    const ctx = {
        currentTime: 0,
        state: 'running',
        destination: graph.destination,
        resume: vi.fn(async () => undefined),
        createGain: () => {
            const node: {
                gain: ReturnType<typeof makeParam>;
                dests: unknown[];
                connect: (d: unknown) => unknown;
                disconnect: () => void;
            } = {
                gain: makeParam(1),
                dests: [],
                connect: () => undefined,
                disconnect: () => undefined
            };
            node.connect = connect(node.dests);
            graph.gains.push(node);
            return node;
        },
        createBufferSource: () => {
            const node: { buffer: unknown; loop: boolean; playbackRate: ReturnType<typeof makeParam>; start: ReturnType<typeof vi.fn>; stop: ReturnType<typeof vi.fn>; dests: unknown[]; connect: (d: unknown) => unknown } = {
                buffer: null,
                loop: false,
                playbackRate: makeParam(1),
                start: vi.fn(),
                stop: vi.fn(),
                dests: [],
                connect: () => undefined
            };
            node.connect = connect(node.dests);
            graph.sources.push(node);
            return node;
        },
        createStereoPanner: () => {
            const node: { pan: ReturnType<typeof makeParam>; dests: unknown[]; connect: (d: unknown) => unknown } = {
                pan: makeParam(0),
                dests: [],
                connect: () => undefined
            };
            node.connect = connect(node.dests);
            graph.panners.push(node);
            return node;
        },
        decodeAudioData: vi.fn(async () => ({ decoded: true, duration: 1.25 }))
    };
    return { ctx, graph };
}

function makeManager(opts: { maxVoices?: number } = {}) {
    const { ctx, graph } = makeCtx();
    const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts);
    return { audio, graph };
}

describe('AudioManager', () => {
    beforeEach(() => {
        vi.stubGlobal('fetch', vi.fn(async () => ({ arrayBuffer: async () => new ArrayBuffer(8) })));
        vi.useFakeTimers();
    });

    afterEach(() => {
        vi.unstubAllGlobals();
        vi.useRealTimers();
    });

    it('unlock строит 4 шины: music/sfx/ambience ведут в master, master — в выход', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();

        expect(graph.gains).toHaveLength(4);
        const [master, music, sfx, ambience] = graph.gains;
        expect(master!.dests).toContain(graph.destination);
        for (const bus of [music, sfx, ambience]) {
            expect(bus!.dests).toContain(master);
        }
    });

    it('setBusVolume: до unlock — без броска, после unlock — меняет громкость шины', async () => {
        const { audio, graph } = makeManager();
        expect(() => audio.setBusVolume('sfx', 0.3)).not.toThrow();
        expect(graph.gains).toHaveLength(0);

        await audio.unlock();
        audio.setBusVolume('sfx', 0.3);
        audio.setBusVolume('ambience', 0.4);
        expect(graph.gains[2]!.gain.value).toBe(0.3);
        expect(graph.gains[3]!.gain.value).toBe(0.4);
    });

    it('play(key, 0.5) ≡ play(key, {volume: 0.5}) — источник в sfx через gain', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('bell');

        await audio.play('bell', 0.5);
        await audio.play('bell', { volume: 0.5 });

        const sfxBus = graph.gains[2]!;
        for (const src of graph.sources) {
            const gainNode = src.dests[0] as { gain: ReturnType<typeof makeParam>; dests: unknown[] };
            expect(gainNode.gain.value).toBe(0.5);
            expect(gainNode.dests).toContain(sfxBus);
        }
    });

    it('rate и pan доезжают до узлов; pan ≈ 0 — StereoPanner не создаётся', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('bell');

        await audio.play('bell', { rate: 1.5, pan: 0.7 });
        expect(graph.sources[0]!.playbackRate.value).toBe(1.5);
        expect(graph.panners).toHaveLength(1);
        expect(graph.panners[0]!.pan.value).toBe(0.7);

        await audio.play('bell');
        expect(graph.panners).toHaveLength(1); // второй sfx без панорамы
        expect(graph.sources[1]!.playbackRate.value).toBe(1);
    });

    it('незагруженный ключ — тихо ничего (null), onPlayed не зовётся', async () => {
        const { audio } = makeManager();
        const played: { key: string; opts: PlayOptions }[] = [];
        audio.onPlayed = (key, opts) => played.push({ key, opts });

        await expect(audio.play('ghost')).resolves.toBeNull();
        expect(played).toHaveLength(0);
    });

    it('onPlayed зовётся после фактического запуска с нормализованными опциями', async () => {
        const { audio, graph } = makeManager();
        const played: { key: string; opts: PlayOptions }[] = [];
        audio.onPlayed = (key, opts) => played.push({ key, opts });
        await audio.unlock();
        await audio.load('bell');

        await audio.play('bell', { volume: 0.6, rate: 1.2, pan: -0.4 });
        await audio.play('bell', 0.5);

        expect(played).toEqual([
            { key: 'bell', opts: { volume: 0.6, rate: 1.2, pan: -0.4 } },
            { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } }
        ]);
        expect(graph.sources).toHaveLength(2);
    });

    it('play возвращает хендл: setVolume/setRate/setPan меняют узлы, stop глушит', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('bell');

        const h = await audio.play('bell', { volume: 0.5, pan: 0.3 });
        expect(h).not.toBeNull();

        const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } };
        h!.setVolume(0.8);
        expect(gainNode.gain.value).toBe(0.8);

        h!.setRate(1.4);
        expect(graph.sources[0]!.playbackRate.value).toBe(1.4);

        h!.setPan(-0.5);
        expect(graph.panners[0]!.pan.value).toBe(-0.5);

        h!.stop(0);
        vi.advanceTimersByTime(100);
        expect(graph.sources[0]!.stop).toHaveBeenCalled();
    });

    it('setPan создаёт panner, если его не было', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('bell');

        const h = await audio.play('bell', 0.5);
        expect(graph.panners).toHaveLength(0);

        h!.setPan(0.4);
        expect(graph.panners).toHaveLength(1);
        expect(graph.panners[0]!.pan.value).toBeCloseTo(0.4);
        // Пересоединение: gain теперь ведёт и в panner
        const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] };
        expect(gainNode.dests).toContain(graph.panners[0]);
    });

    it('после stop хендл мёртв: setVolume/setRate — no-op', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('bell');

        const h = await audio.play('bell', 0.5);
        h!.stop(0);
        h!.setVolume(0.9);
        h!.setRate(1.5);

        // stop затушил громкость; setVolume мёртвого хендла её не вернул
        const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number } };
        expect(gainNode.gain.value).toBeCloseTo(0.0001);
        expect(graph.sources[0]!.playbackRate.value).toBe(1);
        vi.advanceTimersByTime(100);
        expect(graph.sources[0]!.stop).toHaveBeenCalledTimes(1);
    });

    it('restart: новый экземпляр ключа глушит прежний; без restart — играют оба', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('bell');

        await audio.play('bell', 0.5);
        await audio.play('bell', { volume: 0.5, restart: true });
        vi.advanceTimersByTime(100);
        expect(graph.sources[0]!.stop).toHaveBeenCalled();
        expect(graph.sources[1]!.stop).not.toHaveBeenCalled();

        await audio.play('bell', 0.5);
        vi.advanceTimersByTime(100);
        expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // без restart живут оба
        expect(graph.sources[2]!.stop).not.toHaveBeenCalled();
    });

    it('maxVoices: при переполнении воруется самый тихий голос', async () => {
        const { audio, graph } = makeManager({ maxVoices: 2 });
        await audio.unlock();
        await audio.load('bell');

        await audio.play('bell', 0.9);
        await audio.play('bell', 0.1);
        await audio.play('bell', 0.5);
        vi.advanceTimersByTime(100);

        expect(graph.sources[1]!.stop).toHaveBeenCalled(); // самый тихий (0.1)
        expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
        expect(graph.sources[2]!.stop).not.toHaveBeenCalled();
    });

    it('onPlayed зовётся и для лупов (музыка/амбиент), и для sfx', async () => {
        const { audio } = makeManager();
        const played: { key: string; opts: PlayOptions }[] = [];
        audio.onPlayed = (key, opts) => played.push({ key, opts });
        await audio.unlock();
        await audio.load('m');
        await audio.load('a');
        await audio.load('bell');

        await audio.playMusic('m', { volume: 0.6, fade: 0 });
        await audio.playAmbience('a', { fade: 0 });
        await audio.play('bell', 0.5);

        expect(played).toEqual([
            { key: 'm', opts: { volume: 0.6, rate: 1, pan: 0 } },
            { key: 'a', opts: { volume: 1, rate: 1, pan: 0 } },
            { key: 'bell', opts: { volume: 0.5, rate: 1, pan: 0 } }
        ]);
    });

    it('onEnded зовётся на естественном конце звука', async () => {
        const { audio, graph } = makeManager();
        const ended: string[] = [];
        audio.onEnded = (key) => ended.push(key);
        await audio.unlock();
        await audio.load('bell');

        await audio.play('bell');
        expect(ended).toHaveLength(0);

        // Фейковый источник не завершается сам — дёргаем onended руками.
        (graph.sources[0] as unknown as { onended?: () => void }).onended?.();
        expect(ended).toEqual(['bell']);
    });

    it('заглушенный голос (stop) onEnded не зовёт', async () => {
        const { audio, graph } = makeManager();
        const ended: string[] = [];
        audio.onEnded = (key) => ended.push(key);
        await audio.unlock();
        await audio.load('bell');

        const h = await audio.play('bell');
        h!.stop(0);
        // Источник «остановился» после затухания — голос убит, событие не для него.
        (graph.sources[0] as unknown as { onended?: () => void }).onended?.();
        expect(ended).toEqual([]);
    });

    it('duration: после load+preload — секунды буфера, иначе null', async () => {
        const { audio } = makeManager();
        await audio.unlock();

        expect(audio.duration('m')).toBeNull();
        await audio.load('m');
        await audio.preload('m');
        expect(audio.duration('m')).toBeCloseTo(1.25);
    });

    it('setVolume хендла сразу задаёт громкость слота', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('m');

        const handle = await audio.playLoop('m', { fade: 0, volume: 1 });
        handle!.setVolume(0.3);
        const slotGain = (graph.sources[0]!.dests[0] as { gain: { value: number } }).gain;
        expect(slotGain.value).toBeCloseTo(0.3);
    });

    it('playMusic и playAmbience независимы: остановка музыки не глушит амбиент', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('m');
        await audio.load('a');

        const music = await audio.playMusic('m', { fade: 0 });
        const amb = await audio.playAmbience('a', { fade: 0 });
        expect(music).not.toBeNull();
        expect(amb).not.toBeNull();
        expect(graph.sources).toHaveLength(2);

        music!.stop(0);
        vi.advanceTimersByTime(100);
        expect(graph.sources[0]!.stop).toHaveBeenCalled();
        expect(graph.sources[1]!.stop).not.toHaveBeenCalled();
    });

    it('playAmbience дважды — прежний слот остановлен', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('a');

        const first = await audio.playAmbience('a', { fade: 0 });
        await audio.playAmbience('a', { fade: 0 });
        vi.advanceTimersByTime(100);

        expect(first).not.toBeNull();
        expect(graph.sources[0]!.stop).toHaveBeenCalled();
        expect(graph.sources[1]!.stop).not.toHaveBeenCalled(); // новый играет
    });

    it('playMusic с негодным ключом не глушит текущий трек', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('m');

        await audio.playMusic('m', { fade: 0 });
        const bad = await audio.playMusic('ghost', { fade: 0 });
        expect(bad).toBeNull();
        expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
    });

    it('playLoop играет в заданную шину и не занимает слот музыки', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.load('m');

        const layer = await audio.playLoop('m', { bus: 'sfx', fade: 0 });
        expect(layer).not.toBeNull();
        const sfxBus = graph.gains[2]!;
        expect(sfxBus.dests).not.toContain(graph.destination);
        // gain лупа ведёт в sfx
        const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] };
        expect(gainNode.dests).toContain(sfxBus);

        // музыка стартует отдельно, луп не заглушается
        await audio.playMusic('m', { fade: 0 });
        expect(graph.sources).toHaveLength(2);
        audio.stopMusic(0);
        vi.advanceTimersByTime(100);
        expect(graph.sources[0]!.stop).not.toHaveBeenCalled();
        expect(graph.sources[1]!.stop).toHaveBeenCalled();
    });
});