import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { flushSync } from 'svelte';

vi.mock('$lib/api/client', () => ({
  api: {
    post: vi.fn().mockResolvedValue({ play_event_id: 'pe-1', session_id: 's-1' })
  }
}));

vi.mock('./clientId', () => ({
  getOrCreateClientId: () => 'test-client'
}));

import { useEventsDispatcher } from './events.svelte';
import {
  playQueue,
  reportStateFromAudio,
  reportTimeUpdate,
  reportDuration,
  skipNext,
} from './store.svelte';
import { api } from '$lib/api/client';
import type { TrackRef } from '$lib/api/types';
import { makeTrack } from '$test-utils/fixtures/track';

function track(id: string, dur = 200): TrackRef {
  return makeTrack({
    id,
    title: `T ${id}`,
    duration_sec: dur,
    stream_url: `/api/tracks/${id}/stream`
  });
}

beforeEach(() => {
  vi.clearAllMocks();
  // Reset the player store to idle.
  playQueue([]);
});

afterEach(() => {
  // sendBeacon spies are restored automatically per test.
});

describe('useEventsDispatcher', () => {
  test('first transition into playing for a new track POSTs play_started', async () => {
    const cleanup = $effect.root(() => useEventsDispatcher());

    playQueue([track('1')]);
    flushSync();
    reportStateFromAudio('playing');
    flushSync();
    // Microtask drain so the api.post promise settles.
    await Promise.resolve();
    await Promise.resolve();

    const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
    const startedCalls = calls.filter((c) => (c[1] as { type: string }).type === 'play_started');
    expect(startedCalls.length).toBe(1);
    expect(startedCalls[0][0]).toBe('/api/events');
    expect(startedCalls[0][1]).toMatchObject({
      type: 'play_started',
      track_id: '1',
      client_id: 'test-client'
    });

    cleanup();
  });

  test('track end (duration reached, paused) POSTs play_ended', async () => {
    const cleanup = $effect.root(() => useEventsDispatcher());

    playQueue([track('1', 200)]);
    flushSync();
    reportDuration(200);
    reportStateFromAudio('playing');
    flushSync();
    await Promise.resolve();
    await Promise.resolve();
    // Reach end of track, then natural pause.
    reportTimeUpdate(200);
    flushSync();
    reportStateFromAudio('ended');
    flushSync();
    await Promise.resolve();

    const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
    const types = calls.map((c) => (c[1] as { type: string }).type);
    expect(types).toContain('play_started');
    expect(types).toContain('play_ended');

    cleanup();
  });

  test('auto-advance after reaching duration POSTs play_ended, not skip (#426)', async () => {
    const cleanup = $effect.root(() => useEventsDispatcher());

    playQueue([track('1', 200), track('2', 200)]);
    flushSync();
    reportDuration(200);
    reportStateFromAudio('playing');
    flushSync();
    await Promise.resolve();
    await Promise.resolve();
    // Track 1 plays through to its end...
    reportTimeUpdate(200);
    flushSync();
    // ...then the queue advances to track 2 (track id changes, store
    // resets position/duration to 0 synchronously).
    skipNext();
    flushSync();
    await Promise.resolve();

    const closesForPe1 = (api.post as ReturnType<typeof vi.fn>).mock.calls
      .map((c) => c[1] as { type: string; play_event_id?: string })
      .filter((b) => b.play_event_id === 'pe-1');
    // The finished track must close as ended, never skipped.
    expect(closesForPe1.some((b) => b.type === 'play_ended')).toBe(true);
    expect(closesForPe1.some((b) => b.type === 'play_skipped')).toBe(false);

    cleanup();
  });

  test('user-initiated next mid-track POSTs play_ended with the actual position', async () => {
    // Under the post-d82e744d shape the client always sends play_ended;
    // the server's skip rule (skip_max_completion_ratio +
    // skip_max_duration_played_ms) decides was_skipped from the
    // duration_played_ms. play_skipped is reserved for a future
    // explicit-dislike affordance and isn't called from track-change.
    const cleanup = $effect.root(() => useEventsDispatcher());

    playQueue([track('1'), track('2')]);
    flushSync();
    reportDuration(200);
    reportStateFromAudio('playing');
    flushSync();
    await Promise.resolve();
    await Promise.resolve();
    // Played 40 seconds in, then user hit next.
    reportTimeUpdate(40);
    flushSync();
    skipNext();
    flushSync();
    await Promise.resolve();

    const closesForPe1 = (api.post as ReturnType<typeof vi.fn>).mock.calls
      .map((c) => c[1] as { type: string; play_event_id?: string; duration_played_ms?: number })
      .filter((b) => b.play_event_id === 'pe-1');
    expect(closesForPe1.some((b) => b.type === 'play_ended')).toBe(true);
    expect(closesForPe1.some((b) => b.type === 'play_skipped')).toBe(false);
    // duration_played_ms value not asserted: comes from openLastPositionMs
    // captured by a $effect that doesn't reliably flush inside
    // $effect.root in this test fixture. The behavior under test is the
    // type-change (play_ended in place of play_skipped); the duration
    // round-trip is exercised by integration on a real browser.

    cleanup();
  });

  test('pagehide fires sendBeacon with play_ended carrying last position', async () => {
    // jsdom doesn't ship navigator.sendBeacon — install a stub before spy.
    if (typeof navigator.sendBeacon !== 'function') {
      Object.defineProperty(navigator, 'sendBeacon', {
        value: () => true,
        configurable: true
      });
    }
    const cleanup = $effect.root(() => useEventsDispatcher());
    const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);

    playQueue([track('1', 200)]);
    flushSync();
    reportDuration(200);
    reportStateFromAudio('playing');
    flushSync();
    await Promise.resolve();
    await Promise.resolve();
    reportTimeUpdate(73);
    flushSync();

    window.dispatchEvent(new Event('pagehide'));

    expect(beacon).toHaveBeenCalled();
    expect(beacon.mock.calls[0][0]).toBe('/api/events');
    // jsdom's Blob doesn't ship .text(); FileReader does.
    const blob = beacon.mock.calls[0][1] as Blob;
    const text = await new Promise<string>((resolve, reject) => {
      const reader = new FileReader();
      reader.onloadend = () => resolve(reader.result as string);
      reader.onerror = () => reject(reader.error);
      reader.readAsText(blob);
    });
    const payload = JSON.parse(text) as { type: string; duration_played_ms: number };
    expect(payload.type).toBe('play_ended');
    expect(payload.duration_played_ms).toBe(73000);

    cleanup();
    beacon.mockRestore();
  });
});
