import { AxiosError } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '../types';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';

const { start, resume, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
  start: vi.fn(), resume: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
}));
vi.mock('./vibeService', () => ({ vibeService: { start, resume, next, advancePastUnplayable, event, end } }));
vi.mock('./trackService', () => ({ trackService: { getTrack } }));

import {
  adoptVibeSession,
  advancePastUnplayableVibeTrack,
  advanceVibe,
  endVibeSession,
  releaseVibeDriving,
  reportVibeEvent,
  startVibeSession,
} from './vibeSession';

const track = (id: string): Track => ({
  id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
  album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
  play_count: 0, skip_count: 0, dislike_count: 0,
});

const item = (track_id: string, committed = false, ordinal = 0) => ({
  plan_version_id: 'plan', ordinal, track_id, slot_role: null, candidate_source: 'test',
  score: 1, score_breakdown: {}, explanation: [], committed,
});

const response = (planVersion: number, now: ReturnType<typeof item> | null = item('one', true), preview = [item('two')]) => ({
  sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null,
});

describe('durable Vibe session client', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useVibeStore.getState().reset();
    usePlaybackStore.setState({
      currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary',
    });
    getTrack.mockImplementation((id: string) => Promise.resolve(track(id)));
  });

  it('starts by version-serving and hydrating the first durable plan item', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('two')]));

    await expect(startVibeSession(track('one'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });

    expect(start).toHaveBeenCalledWith('one', expect.objectContaining({
      localHour: expect.any(Number), weekday: expect.any(Number), month: expect.any(Number),
    }));
    expect(next).toHaveBeenCalledWith('session-a', 1);
    expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] });
    expect(usePlaybackStore.getState().currentTrack).toEqual(track('one'));
  });

  it('drops a second recording of a song already in the queue', async () => {
    // Same title, different track: a cover or another artist's version. One sitting
    // should not play the same song twice.
    getTrack.mockImplementation((id: string) =>
      Promise.resolve({ ...track(id), title: id === 'cover' ? 'One' : track(id).title })
    );
    start.mockResolvedValue(response(1, item('one'), [item('cover'), item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('cover'), item('two')]));

    await startVibeSession({ ...track('one'), title: 'one' });

    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two']);
  });

  it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('stale')]));
    next
      .mockResolvedValueOnce(response(1, item('one', true), [item('stale')]))
      .mockResolvedValueOnce(response(2, item('two', true), [item('three')]));
    event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
    await startVibeSession(track('one'));

    await advanceVibe('skipped');

    expect(next).toHaveBeenLastCalledWith('session-a', 2);
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
    expect(useVibeStore.getState().buffer.map((entry) => entry.id)).toEqual(['three']);
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('stale');
    expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one', eventId: expect.any(String) }));
  });

  it('replaces only the future when a keep event replans', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('stale')]));
    next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
    event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false });
    await startVibeSession(track('one'));

    await reportVibeEvent('kept', 'one');

    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
    expect(useVibeStore.getState().planVersion).toBe(2);
  });

  it('reconciles the canonical replacement returned by an idempotent material-event retry', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('stale')]));
    next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
    // The first response was lost after it published revision 2. Retrying the
    // same client event returns that revision with replanned=false.
    event.mockResolvedValue({
      ...response(2, item('fresh'), [item('fresh'), item('later')]),
      replanned: false,
      event: {},
      idempotent: true,
    });
    await startVibeSession(track('one'));

    await reportVibeEvent('kept', 'one');

    expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh'), track('later')] });
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
  });

  it('cleans up local playback when the durable session is gone', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('stale')]));
    next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
    event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
      data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
    }));
    await startVibeSession(track('one'));

    await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');

    expect(useVibeStore.getState().activeSessionId).toBeNull();
    expect(usePlaybackStore.getState()).toMatchObject({ currentTrack: null, queue: [], isPlaying: false });
  });

  it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('two')]));
    await startVibeSession(track('one'));

    const ordinary = track('ordinary');
    const playback = usePlaybackStore.getState();
    playback.setQueue([ordinary, track('ordinary-next')]);
    playback.playTrack(ordinary);
    playback.nextWithReason('completed');

    expect(usePlaybackStore.getState()).toMatchObject({
      queueOwner: 'ordinary', currentTrack: track('ordinary-next'), vibeAdvanceHandler: null,
    });
    expect(event).not.toHaveBeenCalled();
  });

  it('serializes material events and ignores an older plan revision', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('old')]));
    next.mockResolvedValue(response(1, item('one', true), [item('old')]));
    await startVibeSession(track('one'));

    let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
    event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
    event.mockResolvedValueOnce({ ...response(1, item('stale'), [item('stale')]), replanned: true, event: {}, idempotent: false });

    const first = reportVibeEvent('kept', 'one');
    const second = reportVibeEvent('completed', 'one');
    await Promise.resolve();
    expect(event).toHaveBeenCalledTimes(1);
    resolveFirst({ ...response(2, item('fresh'), [item('fresh')]), replanned: true, event: {}, idempotent: false });
    await Promise.all([first, second]);

    expect(event).toHaveBeenCalledTimes(2);
    expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh')] });
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh']);
  });

  it('retries a failed event with the same idempotency key until it is acknowledged', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('two')]));
    event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
      ...response(1), event: {}, idempotent: true,
    });
    await startVibeSession(track('one'));

    await reportVibeEvent('progress', 'one', 30000, 180000);

    expect(event).toHaveBeenCalledTimes(2);
    expect(event.mock.calls[0][1].eventId).toBe(event.mock.calls[1][1].eventId);
  });

  it('skips a hidden plan item and starts from the next playable item', async () => {
    start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
    next
      .mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
    advancePastUnplayable.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
    getTrack.mockImplementation((id: string) => id === 'hidden'
      ? Promise.resolve({ ...track(id), state: 'HIDDEN' })
      : Promise.resolve(track(id)));

    await expect(startVibeSession(track('good'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });

    expect(next).toHaveBeenCalledTimes(1);
    expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
      planVersionId: 'plan', ordinal: 0, trackId: 'hidden', eventId: expect.any(String),
    }));
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('hidden');
  });

  it('advances consecutive hidden replacements directly without replaying an older served cursor', async () => {
    start.mockResolvedValue(response(1, item('hidden-one'), [item('hidden-two', false, 1)]));
    next.mockResolvedValueOnce(response(1, item('hidden-one', true), [item('hidden-two', false, 1)]));
    advancePastUnplayable
      .mockResolvedValueOnce(response(1, item('hidden-two', true, 1), [item('good', false, 2)]))
      .mockResolvedValueOnce(response(1, item('good', true, 2), [item('later', false, 3)]));
    getTrack.mockImplementation((id: string) => ['hidden-one', 'hidden-two'].includes(id)
      ? Promise.resolve({ ...track(id), state: 'HIDDEN' })
      : Promise.resolve(track(id)));

    await expect(startVibeSession(track('good'))).resolves.toMatchObject({
      status: 'complete', tracks: [track('good'), track('later')],
    });

    expect(next).toHaveBeenCalledTimes(1);
    expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
    expect(advancePastUnplayable.mock.calls.map(([, , input]) => [input.ordinal, input.trackId]))
      .toEqual([[0, 'hidden-one'], [1, 'hidden-two']]);
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
  });

  it('retries an unplayable advancement with its original event id after a lost response', async () => {
    start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
    next.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
    advancePastUnplayable
      .mockRejectedValueOnce(new Error('response dropped'))
      .mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
    getTrack.mockImplementation((id: string) => id === 'hidden'
      ? Promise.resolve({ ...track(id), state: 'MISSING' })
      : Promise.resolve(track(id)));

    await startVibeSession(track('good'));

    expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
    expect(advancePastUnplayable.mock.calls[0][2].eventId)
      .toBe(advancePastUnplayable.mock.calls[1][2].eventId);
  });

  it('advances a stream-error track through its stored durable cursor without ordinary feedback', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)]));
    next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)]));
    advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)]));
    await startVibeSession(track('one'));

    await advancePastUnplayableVibeTrack('one');

    expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
      planVersionId: 'plan', ordinal: 0, trackId: 'one', eventId: expect.any(String),
    }));
    expect(event).not.toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
    expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 });
  });

  // A locked phone drops its connection for a moment. Neither half of the
  // advance may treat that as the session's fault: the plan is still valid, the
  // prefetched future is still worth keeping, and the listener's action must
  // reach the director exactly once.
  const offline = () => new AxiosError('network error', 'ERR_NETWORK');

  /** Let the pending backoff hear the network come back, rather than waiting it out. */
  const comeBackOnline = async () => {
    await Promise.resolve();
    window.dispatchEvent(new Event('online'));
  };

  it('waits out a dropped connection and sends the same event once', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next
      .mockResolvedValueOnce(response(1, item('one', true), [item('two')]))
      .mockResolvedValue(response(2, item('two', true), [item('three')]));
    await startVibeSession(track('one'));

    // Fails, retries immediately, fails again, then waits for the network.
    event
      .mockRejectedValueOnce(offline())
      .mockRejectedValueOnce(offline())
      .mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });

    const advancing = advanceVibe('completed');
    await vi.waitFor(() => expect(event).toHaveBeenCalledTimes(2));
    await comeBackOnline();
    await advancing;

    const eventIds = event.mock.calls.map((call) => (call[1] as { eventId: string }).eventId);
    expect(new Set(eventIds).size).toBe(1);
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
  });

  it('retries only the serve when the connection drops after the event landed', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next
      .mockResolvedValueOnce(response(1, item('one', true), [item('two')]))
      .mockRejectedValueOnce(offline())
      .mockResolvedValue(response(2, item('two', true), [item('three')]));
    event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
    await startVibeSession(track('one'));

    const advancing = advanceVibe('completed');
    await vi.waitFor(() => expect(next).toHaveBeenCalledTimes(2));
    await comeBackOnline();
    await advancing;

    // The event is the listener's action and must not be replayed by a retry
    // that only the serve needed.
    expect(event).toHaveBeenCalledTimes(1);
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
  });

  it('keeps the prefetched future when a dropped connection outlives every retry', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('two')]));
    await startVibeSession(track('one'));
    const future = usePlaybackStore.getState().queue.map((entry) => entry.id);

    event.mockRejectedValue(new AxiosError('bad gateway', undefined, undefined, undefined, {
      data: {}, status: 502, statusText: 'Bad Gateway', headers: {}, config: {} as never,
    }));

    // Cut every backoff short so the retries exhaust without real waiting.
    const exhausting = advanceVibe('completed');
    const pump = setInterval(() => window.dispatchEvent(new Event('online')), 0);
    await expect(exhausting).rejects.toThrow('bad gateway');
    clearInterval(pump);

    expect(useVibeStore.getState().activeSessionId).toBe('session-a');
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(future);
    expect(usePlaybackStore.getState().isPlaying).toBe(false);
  });

  it('plays the seed first and steps off it without reporting plan feedback', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('two')]));

    await startVibeSession(track('seed'));

    expect(usePlaybackStore.getState().currentTrack?.id).toBe('seed');
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['seed', 'one', 'two']);

    await advanceVibe('completed');

    // The seed is not a plan item: no feedback, no second serve, and the plan's
    // own first item is what plays next.
    expect(event).not.toHaveBeenCalled();
    expect(next).toHaveBeenCalledTimes(1);
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
  });

  it('adopts a session running on another device and drives it from here', async () => {
    // What a device that has just been handed the audio starts from: the queue
    // and playing track came off the playback snapshot, the session did not.
    usePlaybackStore.setState({
      currentTrack: track('one'), queue: [track('one')], currentIndex: 0, isPlaying: true,
    });
    resume.mockResolvedValue({ ...response(3, null, [item('two'), item('three')]), session: { id: 'session-a', seed_track_id: 'one' } });

    await expect(adoptVibeSession('session-a')).resolves.toBe(true);

    expect(resume).toHaveBeenCalledWith('session-a');
    expect(useVibeStore.getState()).toMatchObject({
      activeSessionId: 'session-a', seedTrackId: 'one', planVersion: 3, buffer: [track('two'), track('three')],
    });
    // No cursor is recoverable for the track already playing; the next advance sets one.
    expect(useVibeStore.getState().currentPlanItem).toBeNull();
    expect(usePlaybackStore.getState().queueOwner).toBe('vibe');
    expect(usePlaybackStore.getState().vibeAdvanceHandler).not.toBeNull();
    expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
    // The track that was playing keeps playing: adoption replaces the future only.
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
  });

  it('keeps playing the ordinary queue when the session it was told about is gone', async () => {
    usePlaybackStore.setState({ currentTrack: track('one'), queue: [track('one')], currentIndex: 0 });
    resume.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
      data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
    }));

    await expect(adoptVibeSession('session-a')).resolves.toBe(false);

    expect(usePlaybackStore.getState()).toMatchObject({ queueOwner: 'ordinary', vibeAdvanceHandler: null });
    expect(useVibeStore.getState().activeSessionId).toBeNull();
  });

  it('stops driving a session without ending it when the audio moves away', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('two')]));
    await startVibeSession(track('one'));

    releaseVibeDriving();

    expect(usePlaybackStore.getState().vibeAdvanceHandler).toBeNull();
    expect(useVibeStore.getState().activeSessionId).toBe('session-a');
    expect(end).not.toHaveBeenCalled();
  });

  it('reports an unplayable stream as a skip while an adopted session has no cursor', async () => {
    usePlaybackStore.setState({
      currentTrack: track('one'), queue: [track('one')], currentIndex: 0,
      queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
    });
    resume.mockResolvedValue(response(3, null, [item('two')]));
    await adoptVibeSession('session-a');
    event.mockResolvedValue(response(4, null, [item('two')]));
    next.mockResolvedValue(response(4, item('two', true), []));

    await advancePastUnplayableVibeTrack('one');

    expect(advancePastUnplayable).not.toHaveBeenCalled();
    expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
    expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
  });

  it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
    start.mockResolvedValue(response(1, item('one'), [item('two')]));
    next.mockResolvedValue(response(1, item('one', true), [item('two')]));
    end.mockResolvedValue(response(1));
    await startVibeSession(track('one'));

    await endVibeSession();

    expect(end).toHaveBeenCalledWith('session-a');
    expect(useVibeStore.getState().activeSessionId).toBeNull();
    expect(usePlaybackStore.getState()).toMatchObject({
      queueOwner: 'ordinary', vibeAdvanceHandler: null, currentTrack: null, queue: [], isPlaying: false,
    });
  });
});
