/**
 * Playback-action characterization for `playerStore` (Phase F1 / PR 2b).
 *
 * Covers transport actions — pause / resume / togglePlay / seek / next /
 * previous / toggleRepeat — and asserts that a failing Tauri invoke
 * produces controlled error state, not partial mutation. Audio event
 * handlers live in `playerStore.events.test.ts`.
 *
 * Heavy module-level mocking: `subsonic.ts` (server APIs) and the music-network
 * runtime (scrobble + loved lookups) are mocked to no-ops so navigation-style
 * `playTrack` calls (from `next` / `previous`) don't try to hit a real
 * server. The store's own action bodies still run for real.
 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@/api/subsonic', async () => {
  const actual = await vi.importActual<typeof import('@/api/subsonic')>('@/api/subsonic');
  return {
    ...actual,
    savePlayQueue: vi.fn(async () => undefined),
    getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
    buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
    buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
    getSong: vi.fn(async () => null),
    getRandomSongs: vi.fn(async () => []),
    getSimilarSongs2: vi.fn(async () => []),
    getTopSongs: vi.fn(async () => []),
    getAlbumInfo2: vi.fn(async () => null),
    reportNowPlaying: vi.fn(async () => undefined),
    scrobbleSong: vi.fn(async () => undefined),
    setRating: vi.fn(async () => undefined),
  };
});

vi.mock('@/music-network', () => {
  const runtime = {
    getEnrichmentPrimaryId: vi.fn(() => null),
    dispatchScrobble: vi.fn(async () => undefined),
    dispatchNowPlaying: vi.fn(async () => undefined),
    isTrackLoved: vi.fn(async () => false),
    setTrackLoved: vi.fn(async () => undefined),
    syncLovedTracks: vi.fn(async () => ({})),
  };
  return {
    getMusicNetworkRuntime: () => runtime,
    getMusicNetworkRuntimeOrNull: () => runtime,
  };
});

vi.mock('@/utils/orbitBulkGuard', () => ({
  orbitBulkGuard: vi.fn(async () => true),
}));

import { usePlayerStore } from './playerStore';
import { useAuthStore } from './authStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack, makeTracks, seedQueue } from '@/test/helpers/factories';

function stubPlaybackInvokes(): void {
  onInvoke('audio_play', () => undefined);
  onInvoke('audio_pause', () => undefined);
  onInvoke('audio_resume', () => undefined);
  onInvoke('audio_stop', () => undefined);
  onInvoke('audio_seek', () => undefined);
  onInvoke('audio_get_state', () => ({ playing: false }));
  onInvoke('audio_update_replay_gain', () => undefined);
  onInvoke('audio_set_normalization', () => undefined);
  onInvoke('discord_update_presence', () => undefined);
  onInvoke('frontend_debug_log', () => undefined);
}

beforeEach(() => {
  // Fake timers across the file so module-scoped locks (`togglePlayLock`,
  // `seekDebounce`) don't bleed between tests. afterEach drains pending
  // timer callbacks so each next test sees a clean slate.
  vi.useFakeTimers();
  resetPlayerStore();
  resetAuthStore();
  stubPlaybackInvokes();
});

afterEach(() => {
  vi.runOnlyPendingTimers();
  vi.useRealTimers();
});

describe('pause', () => {
  it('invokes audio_pause and clears isPlaying', () => {
    usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
    usePlayerStore.getState().pause();
    expect(invokeMock).toHaveBeenCalledWith('audio_pause');
    expect(usePlayerStore.getState().isPlaying).toBe(false);
  });

  it('still clears isPlaying when the engine invoke rejects (controlled error)', () => {
    onInvoke('audio_pause', () => { throw new Error('engine gone'); });
    usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
    usePlayerStore.getState().pause();
    // `invoke('audio_pause').catch(...)` is fire-and-forget — state mutation
    // happens regardless of whether the engine call succeeds.
    expect(usePlayerStore.getState().isPlaying).toBe(false);
  });

  it('clears any pending scheduled pause / resume timers', () => {
    usePlayerStore.setState({
      isPlaying: true,
      currentTrack: makeTrack(),
      scheduledPauseAtMs: Date.now() + 60_000,
      scheduledPauseStartMs: Date.now(),
      scheduledResumeAtMs: Date.now() + 120_000,
      scheduledResumeStartMs: Date.now(),
    });
    usePlayerStore.getState().pause();
    const s = usePlayerStore.getState();
    expect(s.scheduledPauseAtMs).toBeNull();
    expect(s.scheduledPauseStartMs).toBeNull();
    expect(s.scheduledResumeAtMs).toBeNull();
    expect(s.scheduledResumeStartMs).toBeNull();
  });
});

describe('resume — warm path (engine has the track loaded, just paused)', () => {
  it('invokes audio_resume and sets isPlaying', () => {
    // Set up a "warm" state: pause was called previously so isAudioPaused=true.
    usePlayerStore.setState({ currentTrack: makeTrack(), isPlaying: true });
    usePlayerStore.getState().pause();
    invokeMock.mockClear();

    usePlayerStore.getState().resume();
    expect(invokeMock).toHaveBeenCalledWith('audio_resume');
    expect(usePlayerStore.getState().isPlaying).toBe(true);
  });

  it('returns without invoking when there is no current track', () => {
    usePlayerStore.setState({ currentTrack: null });
    usePlayerStore.getState().resume();
    expect(invokeMock).not.toHaveBeenCalledWith('audio_resume');
  });
});

describe('togglePlay', () => {
  it('calls pause when isPlaying is true', () => {
    usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
    usePlayerStore.getState().togglePlay();
    expect(invokeMock).toHaveBeenCalledWith('audio_pause');
    expect(usePlayerStore.getState().isPlaying).toBe(false);
  });

  it('calls resume (warm path) when isPlaying is false', () => {
    // Bring the engine into the "paused-but-loaded" state first.
    usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
    usePlayerStore.getState().pause();
    invokeMock.mockClear();

    usePlayerStore.getState().togglePlay();
    expect(invokeMock).toHaveBeenCalledWith('audio_resume');
    expect(usePlayerStore.getState().isPlaying).toBe(true);
  });
});

describe('seek', () => {
  it('clamps to duration - 0.25 and updates optimistic progress immediately', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track });
    usePlayerStore.getState().seek(1.0); // 100% — should clamp to 99.75
    const s = usePlayerStore.getState();
    expect(s.currentTime).toBeCloseTo(99.75, 5);
    expect(s.progress).toBeCloseTo(99.75 / 100, 5);
  });

  it('debounces 100 ms before invoking audio_seek', () => {
    const track = makeTrack({ duration: 120 });
    usePlayerStore.setState({ currentTrack: track });
    usePlayerStore.getState().seek(0.5);
    expect(invokeMock).not.toHaveBeenCalledWith('audio_seek', expect.anything());
    vi.advanceTimersByTime(100);
    expect(invokeMock).toHaveBeenCalledWith('audio_seek', expect.objectContaining({ seconds: 60 }));
  });

  it('coalesces rapid drags into a single backend seek', () => {
    const track = makeTrack({ duration: 120 });
    usePlayerStore.setState({ currentTrack: track });
    const s = usePlayerStore.getState();
    s.seek(0.25);
    s.seek(0.5);
    s.seek(0.75);
    vi.advanceTimersByTime(100);
    const seekCalls = invokeMock.mock.calls.filter(c => c[0] === 'audio_seek');
    expect(seekCalls).toHaveLength(1);
    expect(seekCalls[0]?.[1]).toEqual(expect.objectContaining({ seconds: 90 }));
  });

  it('is a no-op when there is no current track', () => {
    usePlayerStore.setState({ currentTrack: null });
    usePlayerStore.getState().seek(0.5);
    expect(usePlayerStore.getState().currentTime).toBe(0);
  });

  it('is a no-op when the current track has zero duration', () => {
    usePlayerStore.setState({ currentTrack: makeTrack({ duration: 0 }) });
    usePlayerStore.getState().seek(0.5);
    expect(usePlayerStore.getState().currentTime).toBe(0);
  });
});

describe('next', () => {
  it('advances to queue[queueIndex + 1] when one is available', () => {
    const queue = makeTracks(3);
    seedQueue(queue, { index: 0, currentTrack: queue[0] });
    usePlayerStore.getState().next();
    expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
    expect(usePlayerStore.getState().queueIndex).toBe(1);
  });

  it('wraps to queue[0] when at the end with repeatMode=all', () => {
    const queue = makeTracks(3);
    seedQueue(queue, { index: 2, currentTrack: queue[2] });
    usePlayerStore.setState({ repeatMode: 'all' });
    usePlayerStore.getState().next();
    expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
    expect(usePlayerStore.getState().queueIndex).toBe(0);
  });

  it('stops the engine and clears playback when at the end with repeatMode=off', () => {
    // infiniteQueueEnabled and the radio fetch path are both off by default,
    // so the no-next branch falls through to `audio_stop`.
    const queue = makeTracks(2);
    seedQueue(queue, { index: 1, currentTrack: queue[1] });
    usePlayerStore.setState({ repeatMode: 'off', isPlaying: true });
    usePlayerStore.getState().next();
    expect(invokeMock).toHaveBeenCalledWith('audio_stop');
    const s = usePlayerStore.getState();
    expect(s.isPlaying).toBe(false);
    expect(s.currentTime).toBe(0);
    expect(s.progress).toBe(0);
  });
});

describe('previous', () => {
  it('restarts the current track when currentTime > 3 s', () => {
    const queue = makeTracks(3);
    seedQueue(queue, { index: 1, currentTrack: queue[1] });
    // The store's `currentTime` is the source for the "restart vs jump back"
    // branch. `getPlaybackProgressSnapshot` reads from the same field.
    usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration });
    usePlayerStore.getState().previous();
    expect(invokeMock).toHaveBeenCalledWith('audio_seek', expect.objectContaining({ seconds: 0 }));
    expect(usePlayerStore.getState().queueIndex).toBe(1); // stayed on the same track
  });

  it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => {
    const queue = makeTracks(3);
    seedQueue(queue, { index: 2, currentTrack: queue[2] });
    usePlayerStore.setState({ currentTime: 1.0 });
    usePlayerStore.getState().previous();
    expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
    expect(usePlayerStore.getState().queueIndex).toBe(1);
  });

  it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => {
    const queue = makeTracks(2);
    seedQueue(queue, { index: 0, currentTrack: queue[0] });
    usePlayerStore.setState({ currentTime: 0.5 });
    usePlayerStore.getState().previous();
    expect(usePlayerStore.getState().queueIndex).toBe(0);
    expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
  });
});

describe('toggleRepeat', () => {
  it('cycles off → all → one → off', () => {
    expect(usePlayerStore.getState().repeatMode).toBe('off');
    usePlayerStore.getState().toggleRepeat();
    expect(usePlayerStore.getState().repeatMode).toBe('all');
    usePlayerStore.getState().toggleRepeat();
    expect(usePlayerStore.getState().repeatMode).toBe('one');
    usePlayerStore.getState().toggleRepeat();
    expect(usePlayerStore.getState().repeatMode).toBe('off');
  });
});
