import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import { GameAudio } from '../game/audio';
import type { SoundEvent } from '../game/types';

class FakeParam {
  value = 0;
  lastSetTime = -1;
  setValueAtTime(value: number, time: number) { this.value = value; this.lastSetTime = time; }
  linearRampToValueAtTime(value: number) { this.value = value; }
  exponentialRampToValueAtTime(value: number) { this.value = value; }
  cancelScheduledValues() {}
}

class FakeNode {
  gain = new FakeParam();
  frequency = new FakeParam();
  type = '';
  buffer: unknown;
  onended: (() => void) | null = null;
  starts: number[] = [];
  scheduledStops: number[] = [];
  immediateStops = 0;
  disconnects = 0;
  connections: FakeNode[] = [];
  connect(node: FakeNode) { this.connections.push(node); }
  disconnect() { this.disconnects++; }
  start(time: number) { this.starts.push(time); }
  stop(time?: number) {
    if (time === undefined) this.immediateStops++;
    else this.scheduledStops.push(time);
  }
}

class FakeContext {
  state = 'suspended';
  currentTime = 0;
  sampleRate = 48000;
  destination = new FakeNode();
  sources: FakeNode[] = [];
  gains: FakeNode[] = [];
  resumeCalls = 0;
  closeCalls = 0;
  createGain() { const node = new FakeNode(); this.gains.push(node); return node; }
  createOscillator() { const node = new FakeNode(); this.sources.push(node); return node; }
  createBufferSource() { const node = new FakeNode(); this.sources.push(node); return node; }
  createBiquadFilter() { return new FakeNode(); }
  createBuffer(_channels: number, length: number) { const data = new Float32Array(length); return { getChannelData: () => data }; }
  async resume() { this.resumeCalls++; this.state = 'running'; }
  async close() { this.closeCalls++; this.state = 'closed'; }
}

function setup(t: TestContext, support: 'standard' | 'webkit' | 'none' | 'throws' = 'standard') {
  const contexts: FakeContext[] = [];
  const timers = new Map<number, () => void>();
  let nextTimer = 1;
  class Context extends FakeContext { constructor() { super(); contexts.push(this); } }
  const fakeWindow = support === 'standard' ? { AudioContext: Context }
    : support === 'webkit' ? { webkitAudioContext: Context }
      : support === 'throws' ? { AudioContext: class { constructor() { throw new Error('Audio disabled'); } } }
        : {};
  const globals = {
    window: fakeWindow,
    setInterval: (callback: () => void) => { const id = nextTimer++; timers.set(id, callback); return id; },
    clearInterval: (id: number) => { timers.delete(id); },
  };
  const previous = new Map<string, PropertyDescriptor | undefined>();
  for (const [key, value] of Object.entries(globals)) {
    previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
    Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
  }
  const audio = new GameAudio();
  t.after(() => {
    audio.dispose();
    for (const [key, descriptor] of previous) {
      if (descriptor) Object.defineProperty(globalThis, key, descriptor);
      else Reflect.deleteProperty(globalThis, key);
    }
  });
  return { audio, contexts, timers };
}

test('audio initializes only on unlock and reuses its context across gestures', async (t) => {
  const { audio, contexts, timers } = setup(t);
  audio.setActive(true);
  audio.play('shot');
  assert.equal(contexts.length, 0);
  assert.equal(timers.size, 0);
  await audio.unlock();
  assert.equal(contexts.length, 1);
  assert.equal(contexts[0].resumeCalls, 1);
  assert.equal(contexts[0].state, 'running');
  await audio.unlock();
  assert.equal(contexts.length, 1);
  assert.equal(timers.size, 1);
});

test('a saved mute preference stays silent through unlock and blocks new sounds', async (t) => {
  const { audio, contexts, timers } = setup(t);
  audio.setMuted(true);
  audio.setActive(true);
  await audio.unlock();
  const context = contexts[0];
  audio.play('win');
  assert.equal(context.gains[0].gain.value, 0);
  assert.equal(context.sources.length, 0);
  assert.equal(timers.size, 0);
  audio.setMuted(false);
  assert.ok(context.gains[0].gain.value > 0);
  assert.ok(context.sources.length > 0);
  assert.equal(timers.size, 1);
});

test('music follows active state without interrupting death or victory effects', async (t) => {
  const { audio, contexts, timers } = setup(t);
  await audio.unlock();
  assert.equal(timers.size, 0);
  audio.setActive(true);
  const context = contexts[0];
  const music = [...context.sources];
  assert.ok(music.length > 0);
  audio.setActive(true);
  assert.equal(timers.size, 1, 'repeated HUD updates must not start extra loops');
  audio.play('death');
  const effects = context.sources.slice(music.length);
  audio.setActive(false);
  assert.equal(timers.size, 0);
  assert.ok(music.every((node) => node.immediateStops === 1));
  assert.ok(effects.every((node) => node.immediateStops === 0));
  const beforeWin = context.sources.length;
  audio.play('win');
  assert.ok(context.sources.length > beforeWin);
  assert.equal(timers.size, 0);
});

test('every game sound event produces bounded, scheduled audio voices', async (t) => {
  const { audio, contexts } = setup(t);
  await audio.unlock();
  const context = contexts[0];
  const events: SoundEvent[] = ['jump', 'dash', 'shot', 'charge', 'charged-shot', 'hit', 'explosion', 'pickup', 'checkpoint', 'death', 'boss', 'win'];
  for (const event of events) {
    const before = context.sources.length;
    audio.play(event);
    const voices = context.sources.slice(before);
    assert.ok(voices.length > 0, `${event} must produce a sound`);
    for (const voice of voices) {
      assert.equal(voice.starts.length, 1);
      assert.equal(voice.scheduledStops.length, 1);
      assert.ok(voice.scheduledStops[0] > voice.starts[0]);
      assert.ok(voice.scheduledStops[0] - voice.starts[0] < 2);
      assert.ok(Number.isFinite(voice.frequency.value));
      voice.onended?.();
      assert.ok(voice.disconnects > 0, 'finished voices release their audio nodes');
    }
  }
});

test('mute immediately silences and stops both ongoing and future scheduled voices', async (t) => {
  const { audio, contexts, timers } = setup(t);
  audio.setActive(true);
  await audio.unlock();
  audio.play('win');
  const context = contexts[0];
  context.currentTime = 0.01;
  audio.setMuted(true);
  assert.equal(context.gains[0].gain.value, 0);
  assert.equal(context.gains[0].gain.lastSetTime, context.currentTime);
  assert.ok(context.sources.every((node) => node.immediateStops === 1));
  assert.equal(timers.size, 0);
  const before = context.sources.length;
  audio.play('charged-shot');
  assert.equal(context.sources.length, before);
});

test('dispose clears timers, stops audio, closes the context, and prevents reopening', async (t) => {
  const { audio, contexts, timers } = setup(t);
  audio.setActive(true);
  await audio.unlock();
  audio.play('checkpoint');
  const context = contexts[0];
  audio.dispose();
  audio.dispose();
  assert.equal(timers.size, 0);
  assert.equal(context.closeCalls, 1);
  assert.equal(context.state, 'closed');
  assert.ok(context.sources.every((node) => node.immediateStops === 1));
  const before = context.sources.length;
  audio.setActive(true);
  audio.setMuted(false);
  audio.play('shot');
  await audio.unlock();
  assert.equal(contexts.length, 1);
  assert.equal(context.sources.length, before);
});

test('the prefixed AudioContext fallback supports audio', async (t) => {
  const { audio, contexts } = setup(t, 'webkit');
  await audio.unlock();
  audio.play('shot');
  assert.equal(contexts.length, 1);
  assert.ok(contexts[0].sources.length > 0);
});

for (const support of ['none', 'throws'] as const) {
  test(`unavailable audio (${support}) leaves gameplay controls usable`, async (t) => {
    const { audio, contexts, timers } = setup(t, support);
    audio.setActive(true);
    await assert.doesNotReject(audio.unlock());
    audio.play('shot');
    audio.setMuted(true);
    audio.setMuted(false);
    audio.dispose();
    assert.equal(contexts.length, 0);
    assert.equal(timers.size, 0);
  });
}
