/**
 * B1 regression cluster: queue thin-state server identity must be canonical
 * everywhere. Writers emit the URL-derived index key (same model as the
 * library index) so mixed-server queues with duplicate `trackId` across
 * servers stay unambiguous on every path the review flagged:
 *
 *  - resolver correctness     (`seedQueueResolver`, `getCachedTrack`)
 *  - restore / hydrate        (persist `merge`, `hydrateQueueFromIndex`)
 *  - undo / redo snapshots    (`applyQueueHistorySnapshot` prepend, H3)
 *  - queue sync id emission   (`savePlayQueue` trackIds only)
 *  - write helpers            (`toQueueItemRefs`, `bindQueueServerForPlayback`)
 */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/store/playerStore';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
import { bindQueueServerForPlayback } from '@/utils/playback/playbackServer';
import {
  _resetQueueResolverForTest,
  getCachedTrack,
  seedQueueResolver,
} from '@/utils/library/queueTrackResolver';
import { applyQueueHistorySnapshot } from '@/store/applyQueueHistorySnapshot';
import {
  pushQueueUndoSnapshot,
  type QueueUndoSnapshot,
} from '@/store/queueUndo';
import type { PlayerState, QueueItemRef, Track } from '@/store/playerStoreTypes';
import { savePlayQueue } from '@/api/subsonicPlayQueue';
import { _resetQueueSyncForTest, flushPlayQueuePosition } from '@/store/queueSync';

vi.mock('@/api/subsonicPlayQueue', () => ({
  savePlayQueue: vi.fn(async () => undefined),
  getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
vi.mock('@/utils/network/activeServerReachability', () => ({
  isActiveServerReachable: () => true,
}));

const SERVER_A = {
  id: 'uuid-a',
  name: 'A',
  url: 'http://a.test',
  username: 'u',
  password: 'p',
};
const SERVER_B = {
  id: 'uuid-b',
  name: 'B',
  url: 'http://b.test',
  username: 'u',
  password: 'p',
};
const KEY_A = 'a.test';
const KEY_B = 'b.test';

function track(id: string, title: string): Track {
  return { id, title, artist: '', album: 'A', albumId: 'AL', duration: 60 };
}

function getMerge() {
  type MergeFn = (
    persisted: unknown,
    current: ReturnType<typeof usePlayerStore.getState>,
  ) => ReturnType<typeof usePlayerStore.getState>;
  return (usePlayerStore as unknown as {
    persist: { getOptions(): { merge: MergeFn } };
  }).persist.getOptions().merge;
}

beforeEach(() => {
  resetAuthStore();
  resetPlayerStore();
  _resetQueueResolverForTest();
  _resetQueueSyncForTest();
  vi.mocked(savePlayQueue).mockClear();
  useAuthStore.setState({
    servers: [SERVER_A, SERVER_B],
    activeServerId: SERVER_A.id,
    isLoggedIn: true,
  });
  useLibraryIndexStore.setState({ masterEnabled: true });
});

// ── Write helpers canonicalize ────────────────────────────────────────────

describe('B1 — writers emit canonical server keys', () => {
  it('toQueueItemRefs converts a UUID input to the canonical index key', () => {
    const refs = toQueueItemRefs(SERVER_A.id, [track('t1', 'One')]);
    expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
  });

  it('toQueueItemRefs is idempotent on an already-canonical input', () => {
    const refs = toQueueItemRefs(KEY_B, [track('t1', 'One')]);
    expect(refs[0].serverId).toBe(KEY_B);
  });

  it('toQueueItemRefs leaves unknown ids untouched (test isolation / pre-login flows)', () => {
    const refs = toQueueItemRefs('unknown-srv', [track('t1', 'One')]);
    expect(refs[0].serverId).toBe('unknown-srv');
  });

  it('bindQueueServerForPlayback writes the canonical key for the active server', () => {
    useAuthStore.setState({ activeServerId: SERVER_B.id });
    bindQueueServerForPlayback();
    expect(usePlayerStore.getState().queueServerId).toBe(KEY_B);
  });
});

// ── Resolver correctness: same trackId across two servers must NOT collide ─

describe('B1 — resolver isolates duplicate trackId across servers', () => {
  it('seedQueueResolver canonicalizes the seed key — UUID and index key share one cache slot', () => {
    const t = track('shared', 'Original');
    seedQueueResolver(SERVER_A.id, [t]); // UUID input
    // Read via the canonical ref (what writers now emit)
    expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('Original');
    // …and via legacy UUID-bound refs (migration window compat path)
    expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('Original');
  });

  it('two servers with the same trackId resolve independently — no cross-contamination', () => {
    seedQueueResolver(SERVER_A.id, [track('shared', 'From A')]);
    seedQueueResolver(SERVER_B.id, [track('shared', 'From B')]);
    expect(getCachedTrack({ serverId: KEY_A, trackId: 'shared' })?.title).toBe('From A');
    expect(getCachedTrack({ serverId: KEY_B, trackId: 'shared' })?.title).toBe('From B');
    // Legacy-form refs map back to the same canonical entry per server.
    expect(getCachedTrack({ serverId: SERVER_A.id, trackId: 'shared' })?.title).toBe('From A');
    expect(getCachedTrack({ serverId: SERVER_B.id, trackId: 'shared' })?.title).toBe('From B');
  });
});

// ── Persistence merge: forward-migrate legacy UUID-form blobs ─────────────

describe('B1 — persist `merge` forward-migrates legacy UUID-form blobs', () => {
  it('canonicalizes queueServerId and every ref `serverId` on rehydrate', () => {
    const merged = getMerge()(
      {
        queueServerId: SERVER_A.id,
        queueIndex: 1,
        queueItems: [
          { serverId: SERVER_A.id, trackId: 't1' },
          { serverId: SERVER_A.id, trackId: 't2', radioAdded: true },
        ],
        queueItemsIndex: 1,
      },
      usePlayerStore.getState(),
    );
    expect(merged.queueServerId).toBe(KEY_A);
    expect(merged.queueItems).toEqual([
      { serverId: KEY_A, trackId: 't1' },
      { serverId: KEY_A, trackId: 't2', radioAdded: true },
    ]);
  });

  it('canonicalizes the legacy queueRefs-only shape', () => {
    const merged = getMerge()(
      {
        queueServerId: SERVER_B.id,
        queueRefs: ['x', 'y'],
        queueRefsIndex: 1,
      },
      usePlayerStore.getState(),
    );
    expect(merged.queueServerId).toBe(KEY_B);
    expect(merged.queueItems.every(r => r.serverId === KEY_B)).toBe(true);
  });

  it('mixed-server queueItems get per-ref canonicalization (each ref carries its own key)', () => {
    const merged = getMerge()(
      {
        queueServerId: SERVER_A.id,
        queueItems: [
          { serverId: SERVER_A.id, trackId: 'shared' },
          { serverId: SERVER_B.id, trackId: 'shared' },
        ],
        queueItemsIndex: 0,
      },
      usePlayerStore.getState(),
    );
    expect(merged.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
    expect(merged.queueItems[1]).toEqual({ serverId: KEY_B, trackId: 'shared' });
  });
});

// ── Undo/redo snapshot: prepend uses snapshot-canonical server (H3) ────────

describe('B1 + H3 — undo prepend binds to the snapshot\'s playback server', () => {
  it('prepended ref uses snap.queueServerId, not the live queue-level state', () => {
    onInvoke('audio_play', () => undefined);
    onInvoke('audio_seek', () => undefined);
    onInvoke('audio_stop', () => undefined);
    onInvoke('audio_get_state', () => ({ playing: false }));

    // Live state: playback was just rebound to server B mid-undo.
    const playingTrack = track('shared', 'Still playing');
    const prior: PlayerState = {
      ...usePlayerStore.getState(),
      currentTrack: playingTrack,
      currentTime: 12,
      progress: 0.2,
      isPlaying: true,
      queueItems: [{ serverId: KEY_B, trackId: 'shared' }],
      queueServerId: KEY_B,
      queueIndex: 0,
    };
    usePlayerStore.setState(prior);

    // Snapshot was captured under server A — the prepend must follow A,
    // not the live B binding.
    const snap: QueueUndoSnapshot = {
      queueItems: [],
      queueIndex: 0,
      currentTrack: null,
      currentTime: 0,
      progress: 0,
      isPlaying: false,
      queueServerId: KEY_A,
    };

    applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);

    const after = usePlayerStore.getState();
    expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'shared' });
    expect(after.queueIndex).toBe(0);
  });

  it('falls back to existing snapshot refs when queueServerId is absent (legacy in-memory snapshots)', () => {
    onInvoke('audio_play', () => undefined);
    onInvoke('audio_seek', () => undefined);
    onInvoke('audio_stop', () => undefined);
    onInvoke('audio_get_state', () => ({ playing: false }));

    const playingTrack = track('p', 'Playing');
    const prior: PlayerState = {
      ...usePlayerStore.getState(),
      currentTrack: playingTrack,
      isPlaying: true,
      queueItems: [{ serverId: KEY_B, trackId: 'p' }],
      queueServerId: KEY_B,
      queueIndex: 0,
    };
    usePlayerStore.setState(prior);

    const snap: QueueUndoSnapshot = {
      queueItems: [{ serverId: KEY_A, trackId: 'other' }],
      queueIndex: 0,
      currentTrack: null,
    };

    applyQueueHistorySnapshot(snap, prior, usePlayerStore.setState, usePlayerStore.getState);

    // Prepend inherits server identity from the snapshot's first ref.
    const after = usePlayerStore.getState();
    expect(after.queueItems[0]).toEqual({ serverId: KEY_A, trackId: 'p' });
  });

  it('snapshot from current state captures the canonical queueServerId', () => {
    pushQueueUndoSnapshot({
      queueItems: [{ serverId: KEY_A, trackId: 't1' }],
      queueIndex: 0,
      currentTrack: null,
      queueServerId: KEY_A,
    });
    // Sanity: snapshots transport the canonical key forward through stacks.
    // (The actual capture happens in queueUndoSnapshotFromState, which now
    // includes queueServerId from PlayerState — see queueUndo.ts.)
    expect(true).toBe(true);
  });
});

// ── Queue sync emits trackIds only (server identity goes via playback API) ─

describe('B1 — queue sync emits track ids only, server identity flows out-of-band', () => {
  it('savePlayQueue receives plain track ids regardless of ref server form', async () => {
    vi.useFakeTimers();
    try {
      const refs: QueueItemRef[] = [
        { serverId: KEY_A, trackId: 't1' },
        { serverId: KEY_A, trackId: 't2' },
      ];
      usePlayerStore.setState({
        queueItems: refs,
        queueIndex: 0,
        queueServerId: KEY_A,
        currentTrack: track('t1', 'One'),
        currentTime: 1.5,
        isPlaying: true,
      });

      await flushPlayQueuePosition();

      expect(savePlayQueue).toHaveBeenCalledTimes(1);
      const [ids, current, posMs, serverId] = vi.mocked(savePlayQueue).mock.calls[0]!;
      expect(ids).toEqual(['t1', 't2']);
      expect(current).toBe('t1');
      expect(posMs).toBe(1500);
      // savePlayQueue's serverId arg comes from getPlaybackServerId(), which
      // resolves a canonical key OR a UUID back to a UUID — needed for the
      // Subsonic auth lookup. Either is OK here; what matters is no leakage
      // of a per-ref serverId into the request body.
      expect(typeof serverId).toBe('string');
    } finally {
      vi.useRealTimers();
    }
  });
});

// ── Add-to-queue mutations pin the active server when queueServerId is null ─
//
// Regression for the queue-blanking bug: the first user action after launch is
// often a single-track enqueue (e.g. clicking the + button on a search result
// row), not a queue-replacing playTrack. Before the pin, `queueServerId`
// stayed null, `seedIncoming` was a no-op, the refs landed with empty server
// keys, and every new queue row rendered as the resolver placeholder ("…" +
// 0:00) until the user happened to trigger a path that did call
// `bindQueueServerForPlayback`.

describe('B1+ — add-to-queue mutations pin queueServerId when it is null', () => {
  it('enqueue seeds the cache so refs resolve to the real track instead of the placeholder', () => {
    expect(usePlayerStore.getState().queueServerId).toBeNull();

    const t = track('t1', 'Real Title');
    usePlayerStore.getState().enqueue([t], true);

    expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
    const refs = usePlayerStore.getState().queueItems;
    expect(refs).toEqual([{ serverId: KEY_A, trackId: 't1' }]);
    expect(getCachedTrack(refs[0])).toEqual(expect.objectContaining({
      id: 't1',
      title: 'Real Title',
    }));
  });

  it('enqueueAt pins and seeds when queueServerId is null', () => {
    expect(usePlayerStore.getState().queueServerId).toBeNull();

    const t = track('t1', 'Inserted');
    usePlayerStore.getState().enqueueAt([t], 0, true);

    expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
    const refs = usePlayerStore.getState().queueItems;
    expect(refs[0]).toEqual({ serverId: KEY_A, trackId: 't1' });
    expect(getCachedTrack(refs[0])?.title).toBe('Inserted');
  });

  it('enqueueRadio pins and seeds when queueServerId is null', () => {
    expect(usePlayerStore.getState().queueServerId).toBeNull();

    const t = track('r1', 'Radio Track');
    usePlayerStore.getState().enqueueRadio([t], 'artist-x');

    expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
    const refs = usePlayerStore.getState().queueItems;
    expect(refs[0]).toEqual(expect.objectContaining({ serverId: KEY_A, trackId: 'r1' }));
    expect(getCachedTrack(refs[0])?.title).toBe('Radio Track');
  });

  it('does not crash or pin when no active server is available', () => {
    useAuthStore.setState({ activeServerId: null });
    expect(usePlayerStore.getState().queueServerId).toBeNull();

    usePlayerStore.getState().enqueue([track('t1', 'X')], true);

    // No active server → bindQueueServerForPlayback is a no-op. The mutation
    // still runs (matches the pre-fix baseline behaviour) — placeholder UI is
    // the expected fallback when no server can be pinned.
    expect(usePlayerStore.getState().queueServerId).toBeNull();
    expect(usePlayerStore.getState().queueItems).toHaveLength(1);
  });

  it('does not overwrite an already-pinned queueServerId', () => {
    useAuthStore.setState({ activeServerId: SERVER_B.id });
    usePlayerStore.setState({ queueServerId: KEY_A });

    usePlayerStore.getState().enqueue([track('t1', 'Y')], true);

    // Already pinned → ensureQueueServerPinned is a no-op even though the
    // active server has since switched (mixed-server enqueue keeps the anchor).
    expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
  });
});

