import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';

import { playCallJoinSound, playCallLeaveSound, unlockCallSounds } from './callSounds';

// ── Minimal Web Audio mock ──────────────────────────────────────────────────
// callSounds.ts reaches for the global `AudioContext` lazily (inside its own
// getAudioContext), so we can swap a fake in before each test and inspect the
// oscillators/gains it schedules.

type RampCall = { value: number; time: number };

class FakeParam {
  value = 0;
  setValueAtTimeCalls: RampCall[] = [];
  linearRampCalls: RampCall[] = [];
  expRampCalls: RampCall[] = [];
  setValueAtTime(value: number, time: number): this {
    this.setValueAtTimeCalls.push({ value, time });
    return this;
  }
  linearRampToValueAtTime(value: number, time: number): this {
    this.linearRampCalls.push({ value, time });
    return this;
  }
  exponentialRampToValueAtTime(value: number, time: number): this {
    this.expRampCalls.push({ value, time });
    return this;
  }
}

class FakeOscillator {
  type = 'sine';
  frequency = { value: 0 };
  startCalls: number[] = [];
  stopCalls: number[] = [];
  connectedTo: unknown = null;
  connect(node: unknown): unknown {
    this.connectedTo = node;
    return node;
  }
  start(t: number): void {
    this.startCalls.push(t);
  }
  stop(t: number): void {
    this.stopCalls.push(t);
  }
}

class FakeGain {
  gain = new FakeParam();
  connectedTo: unknown = null;
  connect(node: unknown): unknown {
    this.connectedTo = node;
    return node;
  }
}

class FakeAudioContext {
  static instances: FakeAudioContext[] = [];
  static throwOnConstruct = false;

  state: 'running' | 'suspended' | 'closed' = 'running';
  // currentTime 0 keeps `start = now + at` exactly equal to the table offsets
  // (no float drift), so scheduling assertions stay precise.
  currentTime = 0;
  destination = { id: 'destination' };
  oscillators: FakeOscillator[] = [];
  gains: FakeGain[] = [];
  resumeCalls = 0;

  constructor() {
    if (FakeAudioContext.throwOnConstruct) throw new Error('AudioContext unavailable');
    FakeAudioContext.instances.push(this);
  }
  createOscillator(): FakeOscillator {
    const osc = new FakeOscillator();
    this.oscillators.push(osc);
    return osc;
  }
  createGain(): FakeGain {
    const gain = new FakeGain();
    this.gains.push(gain);
    return gain;
  }
  resume(): Promise<void> {
    this.resumeCalls += 1;
    this.state = 'running';
    return Promise.resolve();
  }
  close(): Promise<void> {
    this.state = 'closed';
    return Promise.resolve();
  }
}

const current = (): FakeAudioContext => {
  const ctx = FakeAudioContext.instances.at(-1);
  assert.ok(ctx, 'expected an AudioContext to have been created');
  return ctx;
};
const freqs = (ctx: FakeAudioContext): number[] => ctx.oscillators.map((o) => o.frequency.value);

beforeEach(() => {
  // The module keeps a single shared AudioContext. Close any instance it may
  // still hold so the next call forces a fresh one, then reset our registry.
  FakeAudioContext.instances.forEach((i) => {
    i.state = 'closed';
  });
  FakeAudioContext.instances = [];
  FakeAudioContext.throwOnConstruct = false;
  (globalThis as unknown as { AudioContext: unknown }).AudioContext = FakeAudioContext;
});

// ── Sound design (regression-pins the melodies) ─────────────────────────────

test('chime join plays D5 then A5 as sine notes, staggered', () => {
  playCallJoinSound('chime');
  const ctx = current();
  assert.equal(ctx.oscillators.length, 2);
  assert.deepEqual(
    ctx.oscillators.map((o) => o.type),
    ['sine', 'sine'],
  );
  assert.deepEqual(freqs(ctx), [587.33, 880]);
  assert.deepEqual(
    ctx.oscillators.map((o) => o.startCalls[0]),
    [0, 0.1],
  );
});

test('chime leave reverses the join melody', () => {
  playCallLeaveSound('chime');
  assert.deepEqual(freqs(current()), [880, 587.33]);
});

test('soft join is a single triangle note at the right gain', () => {
  playCallJoinSound('soft');
  const ctx = current();
  assert.equal(ctx.oscillators.length, 1);
  assert.equal(ctx.oscillators[0].type, 'triangle');
  assert.equal(ctx.oscillators[0].frequency.value, 523.25);
  assert.equal(ctx.gains[0].gain.linearRampCalls[0].value, 0.18);
});

test('soft leave drops to the lower triangle note', () => {
  playCallLeaveSound('soft');
  assert.deepEqual(freqs(current()), [392]);
});

test('retro join is three ascending square notes', () => {
  playCallJoinSound('retro');
  const ctx = current();
  assert.deepEqual(
    ctx.oscillators.map((o) => o.type),
    ['square', 'square', 'square'],
  );
  assert.deepEqual(freqs(ctx), [440, 554.37, 659.25]);
  assert.deepEqual(
    ctx.oscillators.map((o) => o.startCalls[0]),
    [0, 0.07, 0.14],
  );
});

test('retro leave is three descending square notes', () => {
  playCallLeaveSound('retro');
  assert.deepEqual(freqs(current()), [659.25, 554.37, 440]);
});

// ── Envelope (click-avoidance shape) ────────────────────────────────────────

test('each note ramps 0 -> peak -> ~0 to avoid clicks, and wires osc->gain->out', () => {
  playCallJoinSound('soft');
  const ctx = current();
  const { gain } = ctx.gains[0];
  assert.equal(gain.setValueAtTimeCalls[0].value, 0); // starts silent
  assert.equal(gain.linearRampCalls[0].value, 0.18); // attack to peak
  assert.equal(gain.expRampCalls[0].value, 0.0001); // exp decay can't hit 0
  // osc -> gain -> destination
  assert.equal(ctx.oscillators[0].connectedTo, ctx.gains[0]);
  assert.equal(ctx.gains[0].connectedTo, ctx.destination);
  assert.equal(ctx.oscillators[0].startCalls.length, 1);
  assert.equal(ctx.oscillators[0].stopCalls.length, 1);
});

// ── Dispatch / defensive contracts ──────────────────────────────────────────

test('an unknown style is a no-op and never even creates a context', () => {
  assert.doesNotThrow(() => playCallJoinSound('bogus' as never));
  assert.doesNotThrow(() => playCallLeaveSound('bogus' as never));
  assert.equal(FakeAudioContext.instances.length, 0);
});

test('does not throw when AudioContext construction fails (unsupported env)', () => {
  FakeAudioContext.throwOnConstruct = true;
  assert.doesNotThrow(() => playCallJoinSound('chime'));
  assert.equal(FakeAudioContext.instances.length, 0);
});

// ── Shared-context lifecycle ────────────────────────────────────────────────

test('unlockCallSounds primes a single running context', () => {
  unlockCallSounds();
  assert.equal(FakeAudioContext.instances.length, 1);
  assert.equal(current().state, 'running');
});

test('reuses one running context across multiple sounds', () => {
  playCallJoinSound('chime');
  playCallLeaveSound('chime');
  playCallJoinSound('soft');
  assert.equal(FakeAudioContext.instances.length, 1);
});

test('recreates the context after it has been closed', () => {
  playCallJoinSound('soft');
  current().state = 'closed';
  playCallJoinSound('soft');
  assert.equal(FakeAudioContext.instances.length, 2);
});

test('resumes (without recreating) a suspended context', () => {
  playCallJoinSound('soft');
  const ctx = current();
  ctx.state = 'suspended';
  playCallJoinSound('soft');
  assert.equal(FakeAudioContext.instances.length, 1);
  assert.equal(ctx.resumeCalls, 1);
});
