import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useOfflineJobStore } from '../../store/offlineJobStore';
import { useOfflineStore } from '../../store/offlineStore';
import {
  isManualOfflinePlaylist,
  isPlaylistPinnedOffline,
  isSourcePinnedOffline,
  schedulePinnedAlbumSync,
  schedulePinnedPlaylistSync,
  scheduleSyncPinnedAlbumsAndArtists,
  syncAllPinnedPlaylists,
  syncPinnedArtistIfNeeded,
} from './pinnedOfflineSync';
import { SMART_PREFIX } from '../componentHelpers/playlistDetailHelpers';

const getPlaylistMock = vi.fn();
const getAlbumForServerMock = vi.fn();
const getArtistForServerMock = vi.fn();
const filterSongsMock = vi.fn(async (songs: SubsonicSong[]) => songs);
const isReachableMock = vi.fn(() => true);
const enqueueMock = vi.fn((_task: unknown) => true);
const invokeMock = vi.fn(async (_cmd: string, _args?: unknown) => ({}));

vi.mock('../network/activeServerReachability', () => ({
  isActiveServerReachable: () => isReachableMock(),
  onActiveServerBecameReachable: () => () => {},
}));

vi.mock('../../api/subsonicPlaylists', () => ({
  getPlaylistForServer: (serverId: string, id: string) => getPlaylistMock(serverId, id),
}));

vi.mock('../../api/subsonicLibrary', () => ({
  getAlbumForServer: (serverId: string, id: string) => getAlbumForServerMock(serverId, id),
  filterSongsToServerLibrary: (songs: SubsonicSong[]) => filterSongsMock(songs),
}));

vi.mock('../../api/subsonicArtists', () => ({
  getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
}));

vi.mock('../../api/library', () => ({
  libraryGetTracksByAlbum: vi.fn(async () => []),
  subscribeLibrarySyncIdle: vi.fn(async () => () => {}),
}));

vi.mock('./offlinePinQueue', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./offlinePinQueue')>();
  return {
    ...actual,
    enqueueOfflinePin: (task: unknown) => enqueueMock(task),
  };
});

vi.mock('@tauri-apps/api/core', () => ({
  invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
}));

function song(id: string): SubsonicSong {
  return {
    id,
    title: id,
    artist: 'A',
    album: 'Al',
    albumId: 'al-1',
    duration: 100,
  };
}

function seedAuth(): void {
  useAuthStore.setState({
    activeServerId: 'srv-a',
    servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
  });
}

describe('isPlaylistPinnedOffline', () => {
  beforeEach(() => {
    useOfflineStore.setState({ albums: {} });
    useLocalPlaybackStore.setState({ entries: {} });
    seedAuth();
  });

  it('returns true when offline meta marks a playlist pin', () => {
    useOfflineStore.setState({
      albums: {
        'a.test:pl-1': {
          id: 'pl-1',
          serverId: 'a.test',
          name: 'Mix',
          artist: '',
          trackIds: ['t1'],
          type: 'playlist',
        },
      },
    });
    expect(isPlaylistPinnedOffline('pl-1', 'srv-a')).toBe(true);
  });

  it('returns false for uncached playlists', () => {
    expect(isPlaylistPinnedOffline('pl-9', 'srv-a')).toBe(false);
  });
});

describe('isManualOfflinePlaylist', () => {
  beforeEach(() => seedAuth());

  it('rejects smart playlist names', () => {
    expect(isManualOfflinePlaylist('pl-1', 'srv-a', `${SMART_PREFIX}Jazz`)).toBe(false);
  });

  it('allows regular playlist names', () => {
    expect(isManualOfflinePlaylist('pl-1', 'srv-a', 'Road mix')).toBe(true);
  });
});

describe('schedulePinnedPlaylistSync', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    isReachableMock.mockReturnValue(true);
    getPlaylistMock.mockReset();
    enqueueMock.mockReset();
    invokeMock.mockClear();
    useOfflineJobStoreReset();
    useOfflineStore.setState({
      albums: {
        'a.test:pl-1': {
          id: 'pl-1',
          serverId: 'a.test',
          name: 'Road mix',
          artist: '',
          trackIds: ['t1'],
          type: 'playlist',
        },
      },
    });
    useLocalPlaybackStore.setState({
      entries: {
        'a.test:t1': {
          serverIndexKey: 'a.test',
          trackId: 't1',
          localPath: '/media/library/a.test/a/al/t1.mp3',
          layoutFingerprint: 'fp',
          sizeBytes: 1000,
          tier: 'library',
          cachedAt: 1,
          suffix: 'mp3',
          pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' },
        },
      },
    });
    seedAuth();
    getPlaylistMock.mockResolvedValue({
      playlist: { id: 'pl-1', name: 'Road mix', songCount: 1 },
      songs: [song('t2')],
    });
  });

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

  it('does nothing when the playlist is not cached offline', async () => {
    schedulePinnedPlaylistSync('pl-9');
    await vi.advanceTimersByTimeAsync(700);
    expect(getPlaylistMock).not.toHaveBeenCalled();
  });

  it('does not sync smart playlists even when previously cached', async () => {
    useOfflineStore.setState({
      albums: {
        'a.test:pl-smart': {
          id: 'pl-smart',
          serverId: 'a.test',
          name: `${SMART_PREFIX}Daily`,
          artist: '',
          trackIds: ['t1'],
          type: 'playlist',
        },
      },
    });
    schedulePinnedPlaylistSync('pl-smart');
    await vi.advanceTimersByTimeAsync(700);
    expect(getPlaylistMock).not.toHaveBeenCalled();
  });

  it('prunes removed tracks and enqueues downloads for the new list', async () => {
    schedulePinnedPlaylistSync('pl-1');
    await vi.advanceTimersByTimeAsync(700);

    expect(getPlaylistMock).toHaveBeenCalledWith('srv-a', 'pl-1');
    expect(invokeMock).toHaveBeenCalledWith(
      'delete_media_file',
      expect.objectContaining({ localPath: '/media/library/a.test/a/al/t1.mp3' }),
    );
    expect(useLocalPlaybackStore.getState().entries['a.test:t1']).toBeUndefined();
    expect(enqueueMock).toHaveBeenCalledWith(
      expect.objectContaining({
        albumId: 'pl-1',
        type: 'playlist',
        songs: [expect.objectContaining({ id: 't2' })],
      }),
    );
    expect(useOfflineStore.getState().albums['a.test:pl-1']?.trackIds).toEqual(['t2']);
  });
});

describe('scheduleSyncPinnedAlbumsAndArtists', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    isReachableMock.mockReturnValue(true);
    getAlbumForServerMock.mockReset();
    enqueueMock.mockReset();
    useOfflineJobStoreReset();
    useOfflineStore.setState({
      albums: {
        'a.test:al-1': {
          id: 'al-1',
          serverId: 'a.test',
          name: 'Album',
          artist: 'Artist',
          trackIds: ['t1'],
          type: 'album',
        },
      },
    });
    seedAuth();
    getAlbumForServerMock.mockResolvedValue({
      album: { id: 'al-1', name: 'Album', artist: 'Artist', coverArt: 'c1' },
      songs: [song('t2')],
    });
  });

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

  it('reconciles cached albums after a debounced library-scope trigger', async () => {
    scheduleSyncPinnedAlbumsAndArtists('srv-a');
    await vi.advanceTimersByTimeAsync(700);
    expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-a', 'al-1');
    expect(useOfflineStore.getState().albums['a.test:al-1']?.trackIds).toEqual(['t2']);
  });
});

describe('schedulePinnedAlbumSync', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    isReachableMock.mockReturnValue(true);
    getAlbumForServerMock.mockReset();
    enqueueMock.mockReset();
    invokeMock.mockClear();
    useOfflineJobStoreReset();
    useOfflineStore.setState({
      albums: {
        'a.test:al-1': {
          id: 'al-1',
          serverId: 'a.test',
          name: 'Album',
          artist: 'Artist',
          trackIds: ['t1'],
          type: 'album',
        },
      },
    });
    useLocalPlaybackStore.setState({
      entries: {
        'a.test:t1': {
          serverIndexKey: 'a.test',
          trackId: 't1',
          localPath: '/media/library/a.test/a/al/t1.mp3',
          layoutFingerprint: 'fp',
          sizeBytes: 1000,
          tier: 'library',
          cachedAt: 1,
          suffix: 'mp3',
          pinSource: { kind: 'album', sourceId: 'al-1', displayName: 'Album' },
        },
      },
    });
    seedAuth();
    getAlbumForServerMock.mockResolvedValue({
      album: { id: 'al-1', name: 'Album', artist: 'Artist', coverArt: 'c1' },
      songs: [song('t2')],
    });
  });

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

  it('reconciles a cached album against the live track list', async () => {
    expect(isSourcePinnedOffline('al-1', 'srv-a', 'album')).toBe(true);
    schedulePinnedAlbumSync('al-1');
    await vi.advanceTimersByTimeAsync(700);

    expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-a', 'al-1');
    expect(enqueueMock).toHaveBeenCalledWith(
      expect.objectContaining({
        albumId: 'al-1',
        type: 'album',
        songs: [expect.objectContaining({ id: 't2' })],
      }),
    );
    expect(useOfflineStore.getState().albums['a.test:al-1']?.trackIds).toEqual(['t2']);
  });
});

function seedArtistAlbumPin(albumId: string, trackId: string, serverIndexKey = 'a.test'): void {
  useOfflineStore.setState(state => ({
    albums: {
      ...state.albums,
      [`${serverIndexKey}:${albumId}`]: {
        id: albumId,
        serverId: serverIndexKey,
        name: `Album ${albumId}`,
        artist: 'Artist',
        trackIds: [trackId],
        type: 'artist',
      },
    },
  }));
  useLocalPlaybackStore.setState(state => ({
    entries: {
      ...state.entries,
      [`${serverIndexKey}:${trackId}`]: {
        serverIndexKey,
        trackId,
        localPath: `/media/library/${serverIndexKey}/a/${albumId}/${trackId}.mp3`,
        layoutFingerprint: 'fp',
        sizeBytes: 1000,
        tier: 'library',
        cachedAt: 1,
        suffix: 'mp3',
        pinSource: { kind: 'artist', sourceId: albumId },
      },
    },
  }));
}

describe('syncPinnedArtistIfNeeded', () => {
  beforeEach(() => {
    isReachableMock.mockReturnValue(true);
    getArtistForServerMock.mockReset();
    getAlbumForServerMock.mockReset();
    enqueueMock.mockReset();
    invokeMock.mockClear();
    useOfflineJobStoreReset();
    useOfflineStore.setState({ albums: {} });
    useLocalPlaybackStore.setState({ entries: {} });
    seedAuth();
  });

  it('prunes albums removed from the live artist catalog', async () => {
    seedArtistAlbumPin('al-1', 't1');
    seedArtistAlbumPin('al-2', 't2');
    getArtistForServerMock.mockResolvedValue({
      artist: { id: 'art-1', name: 'Artist' },
      albums: [{ id: 'al-1', name: 'One', artist: 'Artist' }],
    });
    getAlbumForServerMock.mockImplementation(async (_sid: string, id: string) => ({
      album: { id, name: id, artist: 'Artist' },
      songs: [song(id === 'al-1' ? 't1' : 't9')],
    }));

    await syncPinnedArtistIfNeeded('art-1', 'srv-a');

    expect(useOfflineStore.getState().albums['a.test:al-2']).toBeUndefined();
    expect(invokeMock).toHaveBeenCalledWith(
      'delete_media_file',
      expect.objectContaining({ localPath: '/media/library/a.test/a/al-2/t2.mp3' }),
    );
    expect(useLocalPlaybackStore.getState().entries['a.test:t2']).toBeUndefined();
  });

  it('auto-downloads a new album when the full discography scope was pinned', async () => {
    seedArtistAlbumPin('al-1', 't1');
    seedArtistAlbumPin('al-2', 't2');
    getArtistForServerMock.mockResolvedValue({
      artist: { id: 'art-1', name: 'Artist' },
      albums: [
        { id: 'al-1', name: 'One', artist: 'Artist' },
        { id: 'al-2', name: 'Two', artist: 'Artist' },
        { id: 'al-3', name: 'Three', artist: 'Artist' },
      ],
    });
    getAlbumForServerMock.mockImplementation(async (_sid: string, id: string) => ({
      album: { id, name: id, artist: 'Artist' },
      songs: [song(`track-${id}`)],
    }));

    await syncPinnedArtistIfNeeded('art-1', 'srv-a', ['al-1', 'al-2']);

    expect(enqueueMock).toHaveBeenCalledWith(
      expect.objectContaining({
        albumId: 'al-3',
        type: 'artist',
        artistProgressGroupId: 'art-1',
      }),
    );
  });
});

describe('syncAllPinnedPlaylists', () => {
  beforeEach(() => {
    isReachableMock.mockReturnValue(true);
    getPlaylistMock.mockReset();
    enqueueMock.mockReset();
    useOfflineJobStoreReset();
    useOfflineStore.setState({
      albums: {
        'b.test:pl-b': {
          id: 'pl-b',
          serverId: 'b.test',
          name: 'Remote mix',
          artist: '',
          trackIds: ['tb1'],
          type: 'playlist',
        },
      },
    });
    useAuthStore.setState({
      activeServerId: 'srv-a',
      servers: [
        { id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
        { id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
      ],
    });
    getPlaylistMock.mockResolvedValue({
      playlist: { id: 'pl-b', name: 'Remote mix', songCount: 1 },
      songs: [song('tb2')],
    });
  });

  it('fetches each cached playlist from its owning server, not the active server', async () => {
    await syncAllPinnedPlaylists();
    expect(getPlaylistMock).toHaveBeenCalledWith('srv-b', 'pl-b');
    expect(useOfflineStore.getState().albums['b.test:pl-b']?.trackIds).toEqual(['tb2']);
  });
});

describe('schedulePinnedPlaylistSync dedupe', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    isReachableMock.mockReturnValue(true);
    getPlaylistMock.mockReset();
    enqueueMock.mockReset();
    useOfflineJobStoreReset();
    useOfflineStore.setState({
      albums: {
        'a.test:pl-1': {
          id: 'pl-1',
          serverId: 'a.test',
          name: 'Road mix',
          artist: '',
          trackIds: ['t1'],
          type: 'playlist',
        },
      },
    });
    seedAuth();
    getPlaylistMock.mockResolvedValue({
      playlist: { id: 'pl-1', name: 'Road mix', songCount: 1 },
      songs: [song('t1')],
    });
  });

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

  it('coalesces duplicate schedule calls in one debounce window', async () => {
    schedulePinnedPlaylistSync('pl-1');
    schedulePinnedPlaylistSync('pl-1');
    await vi.advanceTimersByTimeAsync(700);
    expect(getPlaylistMock).toHaveBeenCalledTimes(1);
  });
});

function useOfflineJobStoreReset(): void {
  useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
}
