import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { GaplessEngine, type EngineTrack } from './gapless-engine';

// These tests exist because the failure they cover is inaudible to the code and obvious to the ear: two
// sources playing the same track at once. Nothing throws, no state looks wrong, `cur` points at a real
// node that is really playing — there is simply a second one nobody is holding. The only way to catch it
// is to count the nodes that were started.

type StartCall = { when: number; offset: number };

class FakeSource {
  buffer: AudioBuffer | null = null;
  onended: (() => void) | null = null;
  started: StartCall | null = null;
  stopped = false;
  disconnected = false;
  constructor(private ctx: FakeContext) {}
  connect(): void {}
  disconnect(): void {
    this.disconnected = true;
  }
  start(when = 0, offset = 0): void {
    if (this.started) throw new Error('InvalidStateError: already started');
    this.started = { when, offset };
    this.ctx.started.push(this);
  }
  stop(): void {
    if (!this.started) throw new Error('InvalidStateError: not started');
    this.stopped = true;
  }
  /** What the browser does at the end of the buffer (or at stop()) — the engine's advance trigger. */
  end(): void {
    this.onended?.();
  }
}

class FakeContext {
  currentTime = 0;
  state: 'running' | 'suspended' | 'closed' = 'running';
  destination = {};
  started: FakeSource[] = [];
  createGain() {
    return { gain: { value: 1 }, connect: () => {} };
  }
  createBufferSource() {
    return new FakeSource(this) as unknown as AudioBufferSourceNode & FakeSource;
  }
  async resume(): Promise<void> {
    this.state = 'running';
  }
  async suspend(): Promise<void> {
    this.state = 'suspended';
  }
  async close(): Promise<void> {
    this.state = 'closed';
  }
  async decodeAudioData(): Promise<AudioBuffer> {
    // Decoding is the slow step the bug hides inside — a macrotask is enough to model "not instant".
    await new Promise((r) => setTimeout(r, 0));
    return { duration: 100 } as AudioBuffer;
  }
}

/**
 * Sources producing sound RIGHT NOW. The `when <= currentTime` clause is not a detail: a gapless engine
 * always has the next track already started, scheduled at the current track's end. Counting those as
 * audible would make the healthy state look like the bug.
 */
const audible = (ctx: FakeContext) =>
  ctx.started.filter((s) => !s.stopped && s.started !== null && s.started.when <= ctx.currentTime);

let ctx: FakeContext;
const originalFetch = globalThis.fetch;
const originalCtor = (globalThis as Record<string, unknown>).AudioContext;

const QUEUE: EngineTrack[] = [
  { key: 'a', url: '/stream/a' },
  { key: 'b', url: '/stream/b' },
  { key: 'c', url: '/stream/c' },
];

// Let every pending decode + its continuation run.
const settle = async () => {
  for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0));
};

beforeEach(() => {
  ctx = new FakeContext();
  (globalThis as Record<string, unknown>).AudioContext = function () {
    return ctx;
  };
  globalThis.fetch = (async () => ({
    ok: true,
    arrayBuffer: async () => new ArrayBuffer(8),
  })) as unknown as typeof fetch;
});

afterEach(() => {
  globalThis.fetch = originalFetch;
  (globalThis as Record<string, unknown>).AudioContext = originalCtor;
});

describe('GaplessEngine', () => {
  test('load(autoplay) followed by play() starts the track exactly once', async () => {
    // The host's queue effect and playing effect both fire in the same commit when a queue starts from a
    // paused player. This is that commit, and it used to produce two sources of the same track.
    const engine = new GaplessEngine({});
    engine.load(QUEUE, 0, true);
    engine.play();
    await settle();

    expect(audible(ctx)).toHaveLength(1);
    engine.destroy();
  });

  test('repeated play() during the decode does not stack sources', async () => {
    const engine = new GaplessEngine({});
    engine.load(QUEUE, 0, true);
    engine.play();
    engine.play();
    engine.play();
    await settle();

    expect(audible(ctx)).toHaveLength(1);
    engine.destroy();
  });

  test('pause then play while still decoding does not stack sources', async () => {
    // The impatient-user path: nothing is audible yet because the file is still downloading, so the play
    // button gets hit again.
    const engine = new GaplessEngine({});
    engine.load(QUEUE, 0, true);
    engine.pause();
    engine.play();
    await settle();

    expect(audible(ctx)).toHaveLength(1);
    engine.destroy();
  });

  test('a stale source ending does not advance the queue', async () => {
    // Defence in depth: even if a source outlives its bookkeeping, only `cur` may move the index.
    const seen: number[] = [];
    const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
    engine.load(QUEUE, 0, true);
    await settle();

    const first = audible(ctx)[0]!;
    engine.skipTo(2); // bumps the generation and stops `first`
    await settle();
    first.end(); // the browser still delivers its onended

    expect(seen).toEqual([]); // skipTo is a user action; only a NATURAL boundary reports an index
    engine.destroy();
  });

  test('switching queues leaves nothing from the old one sounding', async () => {
    const engine = new GaplessEngine({});
    engine.load(QUEUE, 0, true);
    await settle();
    engine.load([{ key: 'z', url: '/stream/z' }], 0, true);
    await settle();

    expect(audible(ctx)).toHaveLength(1);
    expect(audible(ctx)[0]!.started).not.toBeNull();
    engine.destroy();
  });

  test('a natural boundary advances the index exactly once', async () => {
    const seen: number[] = [];
    const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) });
    engine.load(QUEUE, 0, true);
    await settle();

    const cur = audible(ctx)[0]!;
    cur.end();
    await settle();

    expect(seen).toEqual([1]);
    engine.destroy();
  });

  test('destroy() silences every source it started', async () => {
    const engine = new GaplessEngine({});
    engine.load(QUEUE, 0, true);
    await settle();
    engine.destroy();

    expect(audible(ctx)).toHaveLength(0);
  });
});
