import { describe, expect, it, vi } from 'vitest';
import { EventBus, type AudioManager, type MusicHandle } from '@rpg/engine';
import { AudioSystem, type AudioSystemDeps } from '../AudioSystem';

/** Фейковый AudioManager: записывает вызовы play/playBuffer/playLoop. */
function fakeAudio() {
    const played: { key: string; opts?: { volume?: number; rate?: number; pan?: number } }[] = [];
    const loops: { key: string; opts?: { bus?: string; volume?: number; fade?: number } }[] = [];
    const audio = {
        play: vi.fn(async (key: string, opts?: { volume?: number; rate?: number; pan?: number }) => {
            played.push({ key, opts });
        }),
        playBuffer: vi.fn(async (key: string, _buf: AudioBuffer, opts?: { volume?: number; rate?: number; pan?: number }) => {
            played.push({ key, opts });
        }),
        playSpec: vi.fn(async (key: string, _spec: unknown, opts?: { volume?: number; rate?: number; pan?: number }) => {
            played.push({ key, opts });
        }),
        // По умолчанию контекст «не разблокирован» — буферы не создать.
        createBuffer: vi.fn((): AudioBuffer | null => null),
        playLoop: vi.fn(async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => {
            loops.push({ key, opts });
            return { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle;
        })
    } as unknown as AudioManager;
    return { audio, played, loops };
}

function makeDeps(over: Partial<AudioSystemDeps> = {}) {
    const calls = fakeAudio();
    const events = new EventBus();
    const deps: AudioSystemDeps = {
        audio: calls.audio,
        events,
        getListener: () => ({ x: 10, y: 10 }),
        ...over
    };
    return { deps, events, calls };
}

describe('AudioSystem.playAt — затухание и панорама', () => {
    it('рядом с героем — полная громкость, без pan (герой в центре)', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.playAt('sfx/bell_hit', { x: 10, y: 10 }, 0.8);

        expect(calls.played).toHaveLength(1);
        expect(calls.played[0]!.key).toBe('sfx/bell_hit');
        expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.8);
        expect(calls.played[0]!.opts!.pan).toBeUndefined(); // |pan| < 0.01 — без узла
    });

    it('за радиусом — тишина (вызова нет)', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.playAt('sfx/spit', { x: 30, y: 30 }, 0.6, 12);

        expect(calls.played).toHaveLength(0);
    });

    it('сбоку от слушателя — панорама со знаком, клэмп ±0.8', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.playAt('sfx/bell_low', { x: 12, y: 10 }, 0.9);

        const pan = calls.played[0]!.opts!.pan!;
        expect(pan).toBeGreaterThan(0);
        expect(pan).toBeLessThanOrEqual(0.8);
    });

    it('далеко сбоку — панорама клэмпится к 0.8', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.playAt('sfx/bell_low', { x: 18, y: 2 }, 2); // далеко и сбоку, громкий

        expect(calls.played[0]!.opts!.pan).toBe(0.8);
    });
});

describe('AudioSystem.playStep — поверхности и варианты', () => {
    it('без буферов (контекст не разблокирован) — файловый шаг по поверхности', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.playStep('grass', { x: 10, y: 10 }, 0.35);

        expect(calls.played).toHaveLength(1);
        expect(calls.played[0]!.key).toBe('sfx/step_grass');
        expect(calls.played[0]!.opts!.volume).toBeCloseTo(0.35);
    });

    it('буферы готовы — playBuffer вариантов по кругу', () => {
        const { deps, calls } = makeDeps();
        vi.mocked(calls.audio.createBuffer).mockImplementation(() => ({ duration: 0.12 }) as AudioBuffer);
        const sys = new AudioSystem(deps);

        sys.playStep('ash', { x: 10, y: 10 }, 0.35);
        sys.update(0.1);
        sys.playStep('ash', { x: 10, y: 10 }, 0.35);
        sys.update(0.1);
        sys.playStep('ash', { x: 10, y: 10 }, 0.35);

        expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_ash#0', 'sfx/step_ash#1', 'sfx/step_ash#2']);
    });

    it('дедуп: одна поверхность чаще 0.08 с не звучит', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.playStep('grass', { x: 10, y: 10 }, 0.35);
        sys.playStep('grass', { x: 10, y: 10 }, 0.35); // слишком рано — глушится
        sys.update(0.05);
        sys.playStep('grass', { x: 10, y: 10 }, 0.35); // всё ещё рано
        sys.update(0.05);
        sys.playStep('water', { x: 10, y: 10 }, 0.35); // другая поверхность — ок

        expect(calls.played.map((p) => p.key)).toEqual(['sfx/step_grass', 'sfx/step_water']);
    });

    it('далеко от слушателя — тишина', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.playStep('floor', { x: 20, y: 20 }, 0.35);

        expect(calls.played).toHaveLength(0);
    });
});

describe('AudioSystem.attach — звук мира подпиской', () => {
    it('combat:attack cone → bell_hit, resonance → bell_low; spit/awake/kill/playerHit', () => {
        const { deps, events, calls } = makeDeps();
        const sys = new AudioSystem(deps);
        sys.attach();

        events.emit('combat:attack', { mode: 'cone', origin: { x: 10, y: 10 } });
        events.emit('combat:attack', { mode: 'resonance', origin: { x: 10, y: 10 } });
        events.emit('combat:spit', { pos: { x: 10, y: 10 } });
        events.emit('combat:awake', { kind: 'crawler', pos: { x: 10, y: 10 } });
        events.emit('combat:kill', { kind: 'crawler', tile: { x: 10, y: 10 } });
        events.emit('combat:playerHit', { hp: 5 });

        expect(calls.played.map((p) => p.key)).toEqual([
            'sfx/bell_hit',
            'sfx/bell_low',
            'sfx/spit',
            'sfx/ash_hiss',
            'sfx/ash_die',
            'sfx/hurt'
        ]);
        // playerHit — урон герою, без панорамы
        expect(calls.played[5]!.opts!.pan).toBeUndefined();
    });

    it('off() снимает подписки', () => {
        const { deps, events, calls } = makeDeps();
        const sys = new AudioSystem(deps);
        const off = sys.attach();
        off();

        events.emit('combat:spit', { pos: { x: 10, y: 10 } });
        expect(calls.played).toHaveLength(0);
    });
});

describe('AudioSystem.setLayers — локальные амбиенты', () => {
    it('слой — playLoop в шине ambience; повтор с тем же ключом не перезапускает', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.setLayers([{ key: 'ambience/ponds_water' }]);
        sys.setLayers([{ key: 'ambience/ponds_water' }]); // тот же ключ — no-op

        expect(calls.loops).toHaveLength(1);
        expect(calls.loops[0]!.key).toBe('ambience/ponds_water');
        expect(calls.loops[0]!.opts!.bus).toBe('ambience');
    });

    it('два слоя — два независимых лупа', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);

        sys.setLayers([{ key: 'ambience/tower_hum' }, { key: 'ambience/houses' }]);

        expect(calls.loops).toHaveLength(2);
        expect(calls.loops[0]!.key).toBe('ambience/tower_hum');
        expect(calls.loops[1]!.key).toBe('ambience/houses');
    });

    it('пустой набор останавливает слои; повторный старт — новый луп', () => {
        const { deps, calls } = makeDeps();
        const sys = new AudioSystem(deps);
        const handles: MusicHandle[] = [];
        (deps.audio.playLoop as ReturnType<typeof vi.fn>).mockImplementation(
            async (key: string, opts?: { bus?: string; volume?: number; fade?: number }) => {
                calls.loops.push({ key, opts });
                const h = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle;
                handles.push(h);
                return h;
            }
        );

        sys.setLayers([{ key: 'ambience/tower_hum' }]);
        sys.setLayers([]);
        sys.setLayers([{ key: 'ambience/tower_hum' }]);

        expect(handles).toHaveLength(2); // второй старт после снятия
        expect(calls.loops).toHaveLength(2);
    });

    it('update доводит громкость слоя к цели', async () => {
        const { deps } = makeDeps();
        const sys = new AudioSystem(deps);
        let handle: MusicHandle | null = null;
        (deps.audio.playLoop as ReturnType<typeof vi.fn>).mockImplementation(async () => {
            handle = { stop: vi.fn(), setVolume: vi.fn() } as unknown as MusicHandle;
            return handle;
        });

        sys.setLayers([{ key: 'ambience/tower_hum', volume: 0.5, fade: 1 }]);
        await Promise.resolve(); // луп стартовал
        sys.update(0.5);
        expect(handle!.setVolume).toHaveBeenCalledWith(expect.closeTo(0.25));
        sys.update(0.5);
        expect(handle!.setVolume).toHaveBeenCalledWith(0.5);
    });
});