/**
 * Miscellaneous-action characterization for `playerStore` — pushes Phase F1
 * past the 50 % line-coverage floor without touching `playTrack` (which is
 * its own async beast).
 *
 * Covers the smaller surfaces 2a / 2b / 2c skipped: shuffleQueue,
 * shuffleUpcomingQueue, stop, setStarredOverride / setUserRatingOverride,
 * toggleQueue / setQueueVisible, toggleFullscreen, openContextMenu /
 * closeContextMenu, openSongInfo / closeSongInfo, setNetworkLoved /
 * setNetworkLovedForSong, pruneUpcomingToCurrent, setProgress.
 */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const runtimeMock = {
  getEnrichmentPrimaryId: vi.fn<() => string | null>(() => null),
  setTrackLoved: vi.fn(async () => undefined),
  isTrackLoved: vi.fn(async () => false),
  syncLovedTracks: vi.fn(async () => ({})),
};

vi.mock('@/api/subsonic', () => ({
  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}`),
  buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`),
  coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`),
  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),
  star: vi.fn(async () => undefined),
  unstar: vi.fn(async () => undefined),
}));

vi.mock('@/music-network', () => ({
  getMusicNetworkRuntime: () => runtimeMock,
  getMusicNetworkRuntimeOrNull: () => runtimeMock,
}));

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

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

beforeEach(() => {
  resetPlayerStore();
  resetAuthStore();
  runtimeMock.getEnrichmentPrimaryId.mockReturnValue(null);
  runtimeMock.setTrackLoved.mockClear();
  runtimeMock.isTrackLoved.mockResolvedValue(false);
  runtimeMock.syncLovedTracks.mockResolvedValue({});
  onInvoke('audio_play', () => undefined);
  onInvoke('audio_pause', () => 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);
});

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

describe('setStarredOverride', () => {
  it('stores per-track starred booleans', () => {
    usePlayerStore.getState().setStarredOverride('t-1', true);
    usePlayerStore.getState().setStarredOverride('t-2', false);
    expect(usePlayerStore.getState().starredOverrides).toEqual({
      't-1': true,
      't-2': false,
    });
  });
});

describe('setUserRatingOverride', () => {
  it('stores per-track rating overrides', () => {
    usePlayerStore.getState().setUserRatingOverride('t-1', 4);
    usePlayerStore.getState().setUserRatingOverride('t-2', 5);
    expect(usePlayerStore.getState().userRatingOverrides).toEqual({
      't-1': 4,
      't-2': 5,
    });
  });
});

describe('openContextMenu / closeContextMenu', () => {
  it('opens with position + item + type + queueIndex', () => {
    const track = makeTrack();
    usePlayerStore.getState().openContextMenu(100, 200, track, 'song', 5);
    const cm = usePlayerStore.getState().contextMenu;
    expect(cm.isOpen).toBe(true);
    expect(cm.x).toBe(100);
    expect(cm.y).toBe(200);
    expect(cm.type).toBe('song');
    expect(cm.queueIndex).toBe(5);
  });

  it('closeContextMenu flips isOpen but preserves the rest of the menu state', () => {
    const track = makeTrack();
    usePlayerStore.getState().openContextMenu(50, 50, track, 'song');
    usePlayerStore.getState().closeContextMenu();
    const cm = usePlayerStore.getState().contextMenu;
    expect(cm.isOpen).toBe(false);
    expect(cm.x).toBe(50);
    expect(cm.type).toBe('song');
  });
});

describe('openSongInfo / closeSongInfo', () => {
  it('opens with the song id and clears on close', () => {
    usePlayerStore.getState().openSongInfo('song-1');
    expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' });

    usePlayerStore.getState().closeSongInfo();
    expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null });
  });
});

describe('toggleQueue / setQueueVisible', () => {
  it('toggleQueue flips isQueueVisible', () => {
    const before = usePlayerStore.getState().isQueueVisible;
    usePlayerStore.getState().toggleQueue();
    expect(usePlayerStore.getState().isQueueVisible).toBe(!before);
    usePlayerStore.getState().toggleQueue();
    expect(usePlayerStore.getState().isQueueVisible).toBe(before);
  });

  it('setQueueVisible writes through verbatim', () => {
    usePlayerStore.getState().setQueueVisible(true);
    expect(usePlayerStore.getState().isQueueVisible).toBe(true);
    usePlayerStore.getState().setQueueVisible(false);
    expect(usePlayerStore.getState().isQueueVisible).toBe(false);
  });
});

describe('toggleFullscreen', () => {
  it('flips isFullscreenOpen', () => {
    expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
    usePlayerStore.getState().toggleFullscreen();
    expect(usePlayerStore.getState().isFullscreenOpen).toBe(true);
    usePlayerStore.getState().toggleFullscreen();
    expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
  });
});

describe('setNetworkLoved / toggleNetworkLove', () => {
  it('setNetworkLoved writes the flag verbatim (no primary gate inside the setter)', () => {
    usePlayerStore.setState({ currentTrack: makeTrack(), networkLoved: false });
    usePlayerStore.getState().setNetworkLoved(true);
    expect(usePlayerStore.getState().networkLoved).toBe(true);
  });

  it('setNetworkLoved also caches the value under "title::artist" when there is a current track', () => {
    usePlayerStore.setState({
      currentTrack: makeTrack({ title: 'Hello', artist: 'Adele' }),
      networkLoved: false,
    });
    usePlayerStore.getState().setNetworkLoved(true);
    expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);
  });

  it('setNetworkLoved without a current track only updates the flag, not the cache', () => {
    usePlayerStore.setState({ currentTrack: null, networkLoved: false, networkLovedCache: {} });
    usePlayerStore.getState().setNetworkLoved(true);
    expect(usePlayerStore.getState().networkLoved).toBe(true);
    expect(usePlayerStore.getState().networkLovedCache).toEqual({});
  });

  it('toggleNetworkLove is a no-op without a current track', () => {
    runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
    usePlayerStore.setState({ currentTrack: null, networkLoved: false });
    usePlayerStore.getState().toggleNetworkLove();
    expect(usePlayerStore.getState().networkLoved).toBe(false);
    expect(runtimeMock.setTrackLoved).not.toHaveBeenCalled();
  });

  it('toggleNetworkLove flips state and writes through the runtime when a track + primary are present', () => {
    runtimeMock.getEnrichmentPrimaryId.mockReturnValue('primary');
    usePlayerStore.setState({ currentTrack: makeTrack({ title: 'T', artist: 'A' }), networkLoved: false });

    usePlayerStore.getState().toggleNetworkLove();
    expect(usePlayerStore.getState().networkLoved).toBe(true);
    expect(usePlayerStore.getState().networkLovedCache['T::A']).toBe(true);
    expect(runtimeMock.setTrackLoved).toHaveBeenCalledWith({ title: 'T', artist: 'A' }, true);
  });
});

describe('setNetworkLovedForSong', () => {
  it('caches loved state under the "title::artist" key', () => {
    usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', true);
    expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(true);

    usePlayerStore.getState().setNetworkLovedForSong('Hello', 'Adele', false);
    expect(usePlayerStore.getState().networkLovedCache['Hello::Adele']).toBe(false);
  });
});

describe('setProgress', () => {
  it('writes currentTime / progress / duration', () => {
    usePlayerStore.setState({ currentTrack: makeTrack({ duration: 200 }) });
    usePlayerStore.getState().setProgress(50, 200);
    const s = usePlayerStore.getState();
    expect(s.currentTime).toBe(50);
    expect(s.progress).toBeCloseTo(0.25, 4);
  });
});

describe('stop', () => {
  it('invokes audio_stop and clears playback state', () => {
    seedQueue(makeTracks(2), { index: 0, currentTrack: makeTrack() });
    usePlayerStore.setState({
      isPlaying: true,
      progress: 0.5,
      currentTime: 60,
    });
    usePlayerStore.getState().stop();
    expect(invokeMock).toHaveBeenCalledWith('audio_stop');
    const s = usePlayerStore.getState();
    expect(s.isPlaying).toBe(false);
    expect(s.progress).toBe(0);
    expect(s.currentTime).toBe(0);
  });

  it('keeps the waveform of the still-shown track and re-hydrates it from the DB', () => {
    const track = makeTrack({ id: 'wf-keep' });
    seedQueue([track], { index: 0, currentTrack: track });
    usePlayerStore.setState({ isPlaying: true, waveformBins: [10, 20, 30] });
    onInvoke('analysis_get_waveform_for_track', () => null);
    usePlayerStore.getState().stop();
    // currentTrack survives a stop, so its waveform bins must not be wiped.
    expect(usePlayerStore.getState().waveformBins).toEqual([10, 20, 30]);
    expect(invokeMock).toHaveBeenCalledWith(
      'analysis_get_waveform_for_track',
      expect.objectContaining({ trackId: 'wf-keep' }),
    );
  });
});

describe('shuffleQueue', () => {
  it('is a no-op when the queue has fewer than 2 tracks', () => {
    const t = makeTrack({ id: 'only' });
    seedQueue([t], { index: 0, currentTrack: t });
    usePlayerStore.getState().shuffleQueue();
    expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['only']);
  });

  it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
    const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
    const current = tracks[2];
    seedQueue(tracks, { index: 2, currentTrack: current });

    // Pin the RNG so the shuffle is deterministic.
    vi.spyOn(Math, 'random').mockReturnValue(0);
    usePlayerStore.getState().shuffleQueue();
    vi.restoreAllMocks();

    const s = usePlayerStore.getState();
    expect(s.queueItems[0].trackId).toBe(current.id);
    expect(s.queueIndex).toBe(0);
    // The set of ids is preserved.
    expect([...s.queueItems.map(r => r.trackId)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
  });
});

describe('shuffleUpcomingQueue', () => {
  it('is a no-op when fewer than 2 upcoming tracks remain', () => {
    const tracks = makeTracks(3, i => ({ id: `t-${i}` }));
    seedQueue(tracks, { index: 2, currentTrack: tracks[2] });
    const beforeIds = tracks.map(t => t.id);
    usePlayerStore.getState().shuffleUpcomingQueue();
    expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(beforeIds);
  });

  it('keeps the head + current in place and shuffles only the upcoming tail', () => {
    const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
    seedQueue(tracks, { index: 1, currentTrack: tracks[1] });

    vi.spyOn(Math, 'random').mockReturnValue(0);
    usePlayerStore.getState().shuffleUpcomingQueue();
    vi.restoreAllMocks();

    const s = usePlayerStore.getState();
    // First two entries unchanged (head + current).
    expect(s.queueItems[0].trackId).toBe('t-0');
    expect(s.queueItems[1].trackId).toBe('t-1');
    // The tail still contains the same ids in some order.
    expect([...s.queueItems.slice(2).map(r => r.trackId)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
  });
});

describe('pruneUpcomingToCurrent', () => {
  it('drops everything after queueIndex', () => {
    const tracks = makeTracks(5);
    seedQueue(tracks, { index: 1, currentTrack: tracks[1] });
    usePlayerStore.getState().pruneUpcomingToCurrent();
    const s = usePlayerStore.getState();
    expect(s.queueItems.map(r => r.trackId)).toEqual([tracks[0].id, tracks[1].id]);
    expect(s.queueIndex).toBe(1);
  });

  it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => {
    seedQueue(makeTracks(3), { index: 0, currentTrack: null });
    usePlayerStore.getState().pruneUpcomingToCurrent();
    const s = usePlayerStore.getState();
    expect(s.queueItems).toEqual([]);
    expect(s.queueIndex).toBe(0);
  });

  it('returns early without clearing when no current track AND queue is already empty', () => {
    usePlayerStore.setState({ queueItems: [], queueIndex: 0, currentTrack: null });
    usePlayerStore.getState().pruneUpcomingToCurrent();
    expect(usePlayerStore.getState().queueItems).toEqual([]);
  });
});

describe('setRadioArtistId', () => {
  it('accepts an artist id without throwing (module-level state, observable via radio playback)', () => {
    // No public getter for radioArtistId — assertion via does-not-throw.
    expect(() => usePlayerStore.getState().setRadioArtistId('ar-1')).not.toThrow();
    expect(() => usePlayerStore.getState().setRadioArtistId('ar-2')).not.toThrow();
  });
});
