import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { attachStallRetry } from './stallRetry';

// Minimal HTMLAudioElement stand-in for vitest. Real DOM elements
// are over-featured; we only need the methods + addEventListener.
function makeAudioStub() {
  const handlers = new Map<string, Set<EventListener>>();
  const stub = {
    currentTime: 0,
    paused: false,
    readyState: 4,
    error: null as MediaError | null,
    load: vi.fn(),
    play: vi.fn().mockResolvedValue(undefined),
    addEventListener(type: string, fn: EventListener) {
      if (!handlers.has(type)) handlers.set(type, new Set());
      handlers.get(type)!.add(fn);
    },
    removeEventListener(type: string, fn: EventListener) {
      handlers.get(type)?.delete(fn);
    },
    fire(type: string) {
      handlers.get(type)?.forEach((fn) => fn(new Event(type)));
    }
  };
  return stub;
}

describe('attachStallRetry', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });
  afterEach(() => {
    vi.useRealTimers();
  });

  test('stalled event triggers reload after delayMs when buffer not recovered', () => {
    const el = makeAudioStub();
    el.currentTime = 42;
    el.readyState = 1; // not yet HAVE_FUTURE_DATA
    attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });

    el.fire('stalled');
    expect(el.load).not.toHaveBeenCalled(); // not yet — waiting

    vi.advanceTimersByTime(1000);
    expect(el.load).toHaveBeenCalledTimes(1);
    expect(el.currentTime).toBe(42); // restored after load()
    expect(el.play).toHaveBeenCalled(); // wasPlaying = true (paused: false)
  });

  test('skips reload when readyState recovered before delay elapses', () => {
    const el = makeAudioStub();
    el.readyState = 1;
    attachStallRetry(el as unknown as HTMLAudioElement, { delayMs: 1000 });

    el.fire('stalled');
    el.readyState = 4; // recovered
    vi.advanceTimersByTime(1000);
    expect(el.load).not.toHaveBeenCalled();
  });

  test('canplay clears retry counter so a later stall can retry again', () => {
    const el = makeAudioStub();
    el.readyState = 1;
    attachStallRetry(el as unknown as HTMLAudioElement, {
      delayMs: 100,
      maxRetries: 1
    });

    el.fire('stalled');
    vi.advanceTimersByTime(100);
    expect(el.load).toHaveBeenCalledTimes(1);

    el.fire('canplay'); // resets retry count
    el.fire('stalled');
    vi.advanceTimersByTime(100);
    expect(el.load).toHaveBeenCalledTimes(2);
  });

  test('maxRetries caps recovery attempts', () => {
    const el = makeAudioStub();
    el.readyState = 1;
    attachStallRetry(el as unknown as HTMLAudioElement, {
      delayMs: 100,
      maxRetries: 2
    });

    for (let i = 0; i < 5; i++) {
      el.fire('stalled');
      vi.advanceTimersByTime(100);
    }
    expect(el.load).toHaveBeenCalledTimes(2);
  });

  test('MEDIA_ERR_NETWORK error triggers immediate reload', () => {
    const el = makeAudioStub();
    el.error = { code: 2 } as MediaError;
    el.currentTime = 17;
    attachStallRetry(el as unknown as HTMLAudioElement);

    el.fire('error');
    expect(el.load).toHaveBeenCalled();
    expect(el.currentTime).toBe(17);
  });

  test('non-network errors (DECODE, SRC_NOT_SUPPORTED) are not retried', () => {
    const el = makeAudioStub();
    el.error = { code: 3 } as MediaError; // MEDIA_ERR_DECODE
    attachStallRetry(el as unknown as HTMLAudioElement);

    el.fire('error');
    expect(el.load).not.toHaveBeenCalled();
  });

  test('teardown removes listeners and clears pending timer', () => {
    const el = makeAudioStub();
    el.readyState = 1;
    const teardown = attachStallRetry(el as unknown as HTMLAudioElement, {
      delayMs: 100
    });

    el.fire('stalled');
    teardown();
    vi.advanceTimersByTime(100);
    expect(el.load).not.toHaveBeenCalled();

    // After teardown, firing events does nothing.
    el.fire('stalled');
    vi.advanceTimersByTime(100);
    expect(el.load).not.toHaveBeenCalled();
  });
});
