/**
 * The queue store's delta reducer is the most intricate pure logic in
 * the frontend: four mutation actions arriving as events, applied to a
 * cached array that must stay identical to the Go queue's own. `move` in
 * particular adjusts its insertion index for elements removed before it,
 * and gets that wrong silently.
 */
import { describe, expect, it, beforeEach } from 'vitest';

import { queueStore, type QueueTrack } from '@store/queue-store';
import { Events } from '../../src/events';
import { emit, flush, lastArgs, calls } from '@test/support/harness';

function track(n: number): QueueTrack {
  return {
    id: n,
    audioFileId: n,
    filePath: `/music/${n}.mp3`,
    position: n,
    title: `Track ${n}`,
    artist: 'Artist',
    album: 'Album',
    coverArtPath: '',
    artistMbid: '',
    releaseGroupMbid: '',
    recordingMbid: '',
  };
}

/** Titles of the cached queue, the cheapest readable assertion. */
function titles(): string[] {
  return queueStore.getState().tracks.map((t) => t.title);
}

/** Push an authoritative full-state sync, as the backend does on
 *  startup and after SetQueue. */
function sync(tracks: QueueTrack[], currentIndex = 0): void {
  emit(Events.QueueChanged, {
    tracks,
    currentIndex,
    shuffleMode: false,
    repeatMode: 'off',
    source: { type: '', id: 0, label: '' },
  });
}

describe('queue store: full-state sync', () => {
  beforeEach(() => {
    sync([]);
  });

  it('replaces cached state wholesale', () => {
    sync([track(1), track(2)], 1);

    expect(queueStore.getState()).toEqual({
      tracks: [track(1), track(2)],
      currentIndex: 1,
      shuffleMode: false,
      repeatMode: 'off',
      source: { type: '', id: 0, label: '' },
    });
  });

  it('carries a non-empty source through from the backend', () => {
    emit(Events.QueueChanged, {
      tracks: [track(1)],
      currentIndex: 0,
      shuffleMode: false,
      repeatMode: 'off',
      source: { type: 'album', id: 7, label: 'Abbey Road' },
    });

    expect(queueStore.getState().source).toEqual({
      type: 'album',
      id: 7,
      label: 'Abbey Road',
    });
  });

  it('tolerates a null track list, which Go sends for an empty queue', () => {
    emit(Events.QueueChanged, {
      tracks: null,
      currentIndex: -1,
      shuffleMode: false,
      repeatMode: 'off',
      source: { type: '', id: 0, label: '' },
    });

    expect(queueStore.getState().tracks).toEqual([]);
  });
});

describe('queue store: track deltas', () => {
  beforeEach(() => {
    sync([track(1), track(2), track(3)], 0);
  });

  it('appends on add', () => {
    emit(Events.QueueTracksModified, {
      action: 'add',
      tracks: [track(4)],
      index: 0,
      currentIndex: 0,
    });

    expect(titles()).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']);
  });

  it('splices at the index on insert', () => {
    emit(Events.QueueTracksModified, {
      action: 'insert',
      tracks: [track(9)],
      index: 1,
      currentIndex: 0,
    });

    expect(titles()).toEqual(['Track 1', 'Track 9', 'Track 2', 'Track 3']);
  });

  it('removes every listed position at once, not one at a time', () => {
    // Removing 0 then 2 sequentially would take the wrong second track;
    // the reducer must treat the positions as indices into the original.
    emit(Events.QueueTracksModified, {
      action: 'remove',
      positions: [0, 2],
      index: 0,
      currentIndex: 0,
    });

    expect(titles()).toEqual(['Track 2']);
  });

  it('adopts the backend current index from every delta', () => {
    emit(Events.QueueTracksModified, {
      action: 'remove',
      positions: [0],
      index: 0,
      currentIndex: 1,
    });

    expect(queueStore.getState().currentIndex).toBe(1);
  });
});

describe('queue store: move', () => {
  beforeEach(() => {
    sync([track(1), track(2), track(3), track(4)], 0);
  });

  it('moves a track forward, compensating for its own removal', () => {
    // Move index 0 to index 2. After removing it, the target shifts
    // down by one, so track 1 lands between 2 and 3 — not after 3.
    emit(Events.QueueTracksModified, {
      action: 'move',
      tracks: [track(1)],
      positions: [0],
      index: 2,
      currentIndex: 1,
    });

    expect(titles()).toEqual(['Track 2', 'Track 1', 'Track 3', 'Track 4']);
  });

  it('moves a track backward without compensating', () => {
    emit(Events.QueueTracksModified, {
      action: 'move',
      tracks: [track(4)],
      positions: [3],
      index: 1,
      currentIndex: 0,
    });

    expect(titles()).toEqual(['Track 1', 'Track 4', 'Track 2', 'Track 3']);
  });

  it('moves a multi-selection, compensating once per element before the target', () => {
    emit(Events.QueueTracksModified, {
      action: 'move',
      tracks: [track(1), track(2)],
      positions: [0, 1],
      index: 3,
      currentIndex: 0,
    });

    expect(titles()).toEqual(['Track 3', 'Track 1', 'Track 2', 'Track 4']);
  });

  it('clamps a target past the end of the shortened list', () => {
    emit(Events.QueueTracksModified, {
      action: 'move',
      tracks: [track(1)],
      positions: [0],
      index: 99,
      currentIndex: 3,
    });

    expect(titles()).toEqual(['Track 2', 'Track 3', 'Track 4', 'Track 1']);
  });
});

/**
 * "Playing from X" is a claim that everything queued came from X, and
 * appending a track from anywhere else makes it false. The backend
 * clears the source on every add/insert path — but the delta is the
 * only event those paths emit, so the label corrects itself here or
 * not at all.
 */
describe('queue store: the source travels on the delta', () => {
  function syncWithAlbum(): void {
    emit(Events.QueueChanged, {
      tracks: [track(1), track(2)],
      currentIndex: 0,
      shuffleMode: false,
      repeatMode: 'off',
      source: { type: 'album', id: 7, label: 'Abbey Road' },
    });
  }

  beforeEach(() => {
    syncWithAlbum();
  });

  it('drops the label when an append clears it backend-side', () => {
    emit(Events.QueueTracksModified, {
      action: 'add',
      tracks: [track(3)],
      index: 2,
      currentIndex: 0,
      source: { type: '', id: 0, label: '' },
    });

    expect(queueStore.getState().source).toEqual({
      type: '',
      id: 0,
      label: '',
    });
  });

  it('keeps a label the backend still reports, as on a removal', () => {
    emit(Events.QueueTracksModified, {
      action: 'remove',
      positions: [1],
      index: 0,
      currentIndex: 0,
      source: { type: 'album', id: 7, label: 'Abbey Road' },
    });

    expect(queueStore.getState().source).toEqual({
      type: 'album',
      id: 7,
      label: 'Abbey Road',
    });
  });
});

describe('queue store: mode deltas', () => {
  beforeEach(() => {
    sync([track(1)], 0);
  });

  it('applies shuffle and repeat together', () => {
    emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' });

    const state = queueStore.getState();

    expect([state.shuffleMode, state.repeatMode]).toEqual([true, 'one']);
  });

  it('leaves the track list untouched', () => {
    emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' });

    expect(titles()).toEqual(['Track 1']);
  });

  it('applies an index-only delta', () => {
    emit(Events.QueueIndexChanged, { currentIndex: 7 });

    expect(queueStore.getState().currentIndex).toBe(7);
  });
});

describe('queue store: subscriber notification', () => {
  beforeEach(() => {
    sync([]);
  });

  it('coalesces a burst of events into one notification', async () => {
    let notifications = 0;
    const unsubscribe = queueStore.subscribe(() => {
      notifications += 1;
    });

    emit(Events.QueueIndexChanged, { currentIndex: 1 });
    emit(Events.QueueIndexChanged, { currentIndex: 2 });
    emit(Events.QueueIndexChanged, { currentIndex: 3 });
    await flush();

    unsubscribe();

    expect(notifications).toBe(1);
  });

  it('stops notifying after unsubscribe', async () => {
    let notifications = 0;
    const unsubscribe = queueStore.subscribe(() => {
      notifications += 1;
    });

    unsubscribe();
    emit(Events.QueueIndexChanged, { currentIndex: 1 });
    await flush();

    expect(notifications).toBe(0);
  });
});

describe('queue store: actions reach the backend', () => {
  it('forwards setQueue with its default shuffleStart and no source', () => {
    queueStore.setQueue(['/a.mp3', '/b.mp3'], 1);

    expect(lastArgs('queue.Queue.SetQueue')).toEqual([
      ['/a.mp3', '/b.mp3'],
      1,
      false,
      { type: '', id: 0, label: '' },
    ]);
  });

  it('forwards setQueue with the given source', () => {
    queueStore.setQueue(['/a.mp3'], 0, true, {
      type: 'playlist',
      id: 7,
      label: 'Road Trip',
    });

    expect(lastArgs('queue.Queue.SetQueue')).toEqual([
      ['/a.mp3'],
      0,
      true,
      { type: 'playlist', id: 7, label: 'Road Trip' },
    ]);
  });

  it('maps each mutation onto its own bound method', () => {
    queueStore.addToQueue('/a.mp3');
    queueStore.playNext('/b.mp3');
    queueStore.removeFromQueue(2);
    queueStore.moveTracksInQueue([0, 1], 3);
    queueStore.toggleShuffle();
    queueStore.cycleRepeat();
    queueStore.clearQueue();

    expect(calls().map((c) => c.path)).toEqual([
      'queue.Queue.AddTrack',
      'queue.Queue.InsertNext',
      'queue.Queue.RemoveTrack',
      'queue.Queue.MoveQueueTracks',
      'queue.Queue.ToggleShuffle',
      'queue.Queue.CycleRepeat',
      'queue.Queue.Clear',
    ]);
  });

  it('does not optimistically mutate cached state', () => {
    sync([track(1)], 0);
    queueStore.clearQueue();

    // The backend is the only writer; the cache waits for the event.
    expect(titles()).toEqual(['Track 1']);
  });
});
