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;
        }),
        exponentialRampToValueAtTime: 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),
        suspend: vi.fn(async () => {
            ctx.state = 'suspended';
        }),
        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 })),
        createBuffer: vi.fn((channels: number, length: number, rate: number) => {
            const buf: { channels: number; length: number; sampleRate: number; data: Float32Array | null; copyToChannel: ReturnType<typeof vi.fn> } = {
                channels,
                length,
                sampleRate: rate,
                data: null,
                copyToChannel: vi.fn((data: Float32Array) => {
                    buf.data = data;
                })
            };
            return buf;
        })
    };
    return { ctx, graph };
}

function makeManager(opts: { maxVoices?: number; pauseOnHide?: boolean } = {}) {
    const { ctx, graph } = makeCtx();
    const audio = new AudioManager((key) => `url:${key}`, () => ctx as unknown as AudioContext, opts);
    return {
        audio,
        graph,
        ctx: ctx as unknown as {
            createBuffer: ReturnType<typeof vi.fn>;
            suspend: ReturnType<typeof vi.fn>;
            resume: ReturnType<typeof vi.fn>;
            state: string;
        }
    };
}

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.register('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.register('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.register('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.register('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.register('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.register('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.register('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.register('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.register('m');
        await audio.register('a');
        await audio.register('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.register('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.register('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.register('m');
        await audio.preload('m');
        expect(audio.duration('m')).toBeCloseTo(1.25);
    });

    it('createBuffer: до unlock — null, после — буфер с сэмплами', async () => {
        const { audio, ctx } = makeManager();
        const data = new Float32Array([0, 0.5, -0.5]);
        expect(audio.createBuffer(data)).toBeNull(); // контекста ещё нет

        await audio.unlock();
        const buf = audio.createBuffer(data, 44100) as unknown as { data: Float32Array | null; copyToChannel: (d: Float32Array, ch: number) => void };
        expect(ctx.createBuffer).toHaveBeenCalledWith(1, 3, 44100);
        expect(buf.data).toBe(data);
        expect(buf!.copyToChannel).toHaveBeenCalledWith(data, 0);
    });

    it('createBuffer: channels 2 — interleaved L,R деинтерливится в два канала', async () => {
        const { audio, ctx } = makeManager();
        await audio.unlock();
        const copied: { ch: number; data: Float32Array }[] = [];
        const data = new Float32Array([0.1, 0.9, -0.2, -0.8]);
        const buf = audio.createBuffer(data, 22050, 2) as unknown as {
            copyToChannel: (d: Float32Array, ch: number) => void;
        };
        // Собираем вызовы по каналам: фейк хранит только последний, читаем аргументы.
        (ctx.createBuffer as ReturnType<typeof vi.fn>).mock.results.at(-1)!.value.copyToChannel.mock.calls.forEach(
            ([d, ch]: [Float32Array, number]) => copied.push({ ch, data: d })
        );
        expect(ctx.createBuffer).toHaveBeenCalledWith(2, 2, 22050);
        expect(copied).toEqual([
            { ch: 0, data: new Float32Array([0.1, -0.2]) },
            { ch: 1, data: new Float32Array([0.9, -0.8]) }
        ]);
        void buf;
    });

    it('playBuffer играет буфер в sfx с заданным ключом и возвращает хендл', async () => {
        const { audio, graph } = makeManager();
        const played: { key: string; opts: PlayOptions }[] = [];
        audio.onPlayed = (key, opts) => played.push({ key, opts });
        await audio.unlock();

        const buf = audio.createBuffer(new Float32Array(100).fill(0.3))!;
        const h = await audio.playBuffer('sfx/step_grass#0', buf, { volume: 0.4 });

        expect(h).not.toBeNull();
        const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] };
        expect(gainNode.gain.value).toBe(0.4);
        expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx
        expect(played).toEqual([{ key: 'sfx/step_grass#0', opts: { volume: 0.4, rate: 1, pan: 0 } }]);

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

    it('playSpec: спек компилируется в буфер и кэшируется по ключу', async () => {
        const { audio, graph, ctx } = makeManager();
        const played: { key: string; opts: PlayOptions }[] = [];
        audio.onPlayed = (key, opts) => played.push({ key, opts });
        await audio.unlock();

        const spec = { kind: 'chime', dur: 0.1, freq: 660 } as const;
        const h1 = await audio.playSpec('agent/door', spec, 0.7);
        await audio.playSpec('agent/door', spec, 0.7); // из кэша

        expect(graph.sources).toHaveLength(2);
        expect(ctx.createBuffer).toHaveBeenCalledTimes(1); // компиляция один раз
        const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] };
        expect(gainNode.gain.value).toBe(0.7);
        expect(gainNode.dests).toContain(graph.gains[2]); // шина sfx
        expect(played.map((p) => p.key)).toEqual(['agent/door', 'agent/door']);
        expect(h1).not.toBeNull();

        // Другой ключ — своя компиляция
        await audio.playSpec('agent/bush', spec, 0.5);
        expect(ctx.createBuffer).toHaveBeenCalledTimes(2);
    });

    it('playSpec: спек рендерится в буфер с длиной dur×RATE (моно, 22050)', async () => {
        const { audio, ctx } = makeManager();
        await audio.playSpec('x', { kind: 'hit', dur: 0.1 });
        expect(ctx.createBuffer).toHaveBeenCalledWith(1, 2205, 22050);
    });

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

    it('bus в PlayOptions: разовый голос уходит в заданную шину', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.register('bell');

        // graph.gains: master, music, sfx, ambience
        await audio.play('bell', { volume: 0.5, bus: 'music' });
        const gainNode = graph.sources[0]!.dests[0] as { dests: unknown[] };
        expect(gainNode.dests).toContain(graph.gains[1]); // шина music
        expect(gainNode.dests).not.toContain(graph.gains[2]);

        await audio.play('bell'); // без bus — по-прежнему sfx
        const plain = graph.sources[1]!.dests[0] as { dests: unknown[] };
        expect(plain.dests).toContain(graph.gains[2]);
    });

    it('fadeOut: экспоненциальный спад от нуля поднимается до минимума', async () => {
        const { audio, graph } = makeManager();
        await audio.unlock();
        await audio.register('m');

        const layer = await audio.playLoop('m', { fade: 0, volume: 0 });
        layer!.stop(0.5);
        const slotGain = graph.sources[0]!.dests[0] as { gain: ReturnType<typeof makeParam> };
        // from = max(0, MIN) → setValueAtTime(MIN), затем exponential- ramp
        expect(slotGain.gain.setValueAtTime).toHaveBeenCalledWith(0.0001, 0);
        expect(slotGain.gain.exponentialRampToValueAtTime).toHaveBeenCalledWith(0.0001, 0.5);
    });

    it('visibilitychange: скрыли таб — suspend, вернули — resume', async () => {
        const listeners: Record<string, (() => void)[]> = {};
        const doc = {
            hidden: false,
            addEventListener: (type: string, fn: () => void) => {
                (listeners[type] ??= []).push(fn);
            }
        };
        vi.stubGlobal('document', doc);
        try {
            const { audio, ctx } = makeManager();
            await audio.unlock();
            expect(ctx.suspend).not.toHaveBeenCalled();

            doc.hidden = true;
            listeners.visibilitychange![0]!();
            expect(ctx.suspend).toHaveBeenCalledTimes(1);

            doc.hidden = false;
            ctx.state = 'suspended';
            listeners.visibilitychange![0]!();
            expect(ctx.resume).toHaveBeenCalledTimes(1);
        } finally {
            vi.unstubAllGlobals();
        }
    });

    it('playLoopBuffer: луп из памяти на заданной шине, хендл стопит', async () => {
        const { audio, graph } = makeManager();
        const played: { key: string; opts: PlayOptions }[] = [];
        audio.onPlayed = (key, opts) => played.push({ key, opts });
        await audio.unlock();
        await audio.register('m'); // createBuffer фейка требует unlock — берём его буфер

        const buf = audio.createBuffer(new Float32Array(22050).fill(0.2))!;
        const handle = await audio.playLoopBuffer('music/meadows', buf, { fade: 0, volume: 0.7 });
        expect(handle).not.toBeNull();
        const gainNode = graph.sources[0]!.dests[0] as { gain: { value: number }; dests: unknown[] };
        expect(gainNode.gain.value).toBe(0.7);
        expect(gainNode.dests).toContain(graph.gains[1]); // шина music по умолчанию
        expect(played).toEqual([{ key: 'music/meadows', opts: { volume: 0.7, rate: 1, pan: 0 } }]);

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

    it('pauseOnHide: false — visibilitychange игнорируется', async () => {
        const listeners: Record<string, (() => void)[]> = {};
        vi.stubGlobal('document', {
            hidden: true,
            addEventListener: (type: string, fn: () => void) => {
                (listeners[type] ??= []).push(fn);
            }
        });
        try {
            const { ctx } = makeManager({ pauseOnHide: false });
            expect(listeners.visibilitychange).toBeUndefined(); // listener не ставился
            expect(ctx.suspend).not.toHaveBeenCalled();
        } finally {
            vi.unstubAllGlobals();
        }
    });
});