import { act, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';

const { advancePastUnplayableVibeTrack, reportVibeEvent } = vi.hoisted(() => ({
  advancePastUnplayableVibeTrack: vi.fn().mockResolvedValue(undefined),
  reportVibeEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../services/vibeSession', () => ({ advancePastUnplayableVibeTrack, reportVibeEvent }));

import { AudioEngine } from './AudioEngine';

const track = (id: string): Track => ({
  id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
  duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
});

describe('AudioEngine', () => {
  beforeEach(() => {
    advancePastUnplayableVibeTrack.mockResolvedValue(undefined);
    reportVibeEvent.mockResolvedValue(undefined);
    vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
    vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
    vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined);
    useVibeStore.getState().reset();
    useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
    usePlaybackStore.setState({
      currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false,
      queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, audioElsewhere: false,
    });
  });

  afterEach(() => vi.restoreAllMocks());

  /** jsdom media elements report no duration and never really play. */
  const fakeMedia = (audio: HTMLAudioElement, currentTime: number, duration = 180) => {
    Object.defineProperty(audio, 'duration', { value: duration, configurable: true });
    Object.defineProperty(audio, 'currentTime', { value: currentTime, writable: true, configurable: true });
    Object.defineProperty(audio, 'paused', { value: false, configurable: true });
    Object.defineProperty(audio, 'ended', { value: false, configurable: true });
  };

  it('loads no stream while another device holds the audio, and loads one when it comes back', () => {
    usePlaybackStore.setState({
      audioElsewhere: true, isPlaying: false, prefetchNext: true,
      queue: [track('song'), track('next')], currentIndex: 0,
    });
    const { container } = render(<AudioEngine />);
    const [active, idle] = Array.from(container.querySelectorAll('audio'));

    expect(active.getAttribute('src')).toBeNull();
    expect(idle.getAttribute('src')).toBeNull();
    // Mirroring is not playback: this device reports nothing to the session.
    expect(reportVibeEvent).not.toHaveBeenCalled();

    act(() => usePlaybackStore.getState().setAudioElsewhere(false));

    expect(active.src).toContain('/tracks/song/stream');
  });

  it('buffers the next queued track into the idle element before the current one ends', () => {
    usePlaybackStore.setState({
      queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
      queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
    });
    const { container } = render(<AudioEngine />);
    const [active, idle] = Array.from(container.querySelectorAll('audio'));

    fakeMedia(active, 170);
    active.dispatchEvent(new Event('timeupdate'));

    expect(idle.src).toContain('/tracks/next/stream');
    expect(active.src).toContain('/tracks/song/stream');
  });

  it('starts buffering the next track early in the current one, not only near its end', () => {
    usePlaybackStore.setState({
      queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
      queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
    });
    const { container } = render(<AudioEngine />);
    const [active, idle] = Array.from(container.querySelectorAll('audio'));

    fakeMedia(active, 20);
    active.dispatchEvent(new Event('timeupdate'));

    expect(idle.src).toContain('/tracks/next/stream');
  });

  it('re-points the buffered element when a replan changes what plays next', () => {
    usePlaybackStore.setState({
      queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
      queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
    });
    const { container } = render(<AudioEngine />);
    const [active, idle] = Array.from(container.querySelectorAll('audio'));

    fakeMedia(active, 20);
    active.dispatchEvent(new Event('timeupdate'));
    expect(idle.src).toContain('/tracks/next/stream');

    usePlaybackStore.setState({ queue: [track('song'), track('other')] });
    fakeMedia(active, 30);
    active.dispatchEvent(new Event('timeupdate'));

    expect(idle.src).toContain('/tracks/other/stream');
  });

  it('hands over to the next track inside the crossfade window instead of waiting for ended', () => {
    usePlaybackStore.setState({
      queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
      queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 500,
    });
    const { container } = render(<AudioEngine />);
    const [active, idle] = Array.from(container.querySelectorAll('audio'));

    fakeMedia(active, 179.8);
    active.dispatchEvent(new Event('timeupdate'));

    expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
    expect(idle.src).toContain('/tracks/next/stream');
  });

  it('joins gaplessly with crossfade off: resolves early, starts the next track when the tail ends', () => {
    usePlaybackStore.setState({
      queue: [track('song'), track('next')], currentIndex: 0, isPlaying: true,
      queueOwner: 'ordinary', vibeAdvanceHandler: null, prefetchNext: true, crossfadeMs: 0,
    });
    const { container } = render(<AudioEngine />);
    const [active, idle] = Array.from(container.querySelectorAll('audio'));
    const idlePlay = vi.spyOn(idle, 'play').mockResolvedValue(undefined);

    fakeMedia(active, 179);
    active.dispatchEvent(new Event('timeupdate'));

    // The store moved on so Vibe can resolve, but the tail keeps the audio.
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('next');
    expect(idle.src).toContain('/tracks/next/stream');
    expect(idlePlay).not.toHaveBeenCalled();

    Object.defineProperty(active, 'ended', { value: true, configurable: true });
    active.dispatchEvent(new Event('ended'));

    expect(idlePlay).toHaveBeenCalled();
  });

  it('does not resume the finished element while the next track is still being resolved', () => {
    usePlaybackStore.setState({
      queue: [track('song')], currentIndex: 0, isPlaying: true,
      queueOwner: 'vibe', vibeAdvanceHandler: () => undefined, crossfadeMs: 0,
    });
    const { container } = render(<AudioEngine />);
    const [active] = Array.from(container.querySelectorAll('audio'));
    const play = vi.spyOn(active, 'play').mockResolvedValue(undefined);

    Object.defineProperty(active, 'ended', { value: true, configurable: true });
    Object.defineProperty(active, 'paused', { value: true, configurable: true });
    active.dispatchEvent(new Event('ended'));
    // A queue/plan write during the Vibe handshake must not restart the tail.
    usePlaybackStore.getState().setQueue([track('song')]);

    expect(play).not.toHaveBeenCalled();
  });

  it('uses the durable unplayable advancement when a Vibe stream errors after metadata resolved', async () => {
    const { container } = render(<AudioEngine />);
    const audio = container.querySelector('audio')!;

    audio.dispatchEvent(new Event('error'));
    audio.dispatchEvent(new Event('error'));

    await waitFor(() => expect(advancePastUnplayableVibeTrack).toHaveBeenCalledWith('song'));
    expect(advancePastUnplayableVibeTrack).toHaveBeenCalledTimes(1);
    expect(reportVibeEvent).not.toHaveBeenCalledWith('skipped', 'song');
  });
});
