/**
 * Playback-progress snapshot + subscriber characterization (Phase F1 / PR 2c).
 *
 * `getPlaybackProgressSnapshot` + `subscribePlaybackProgress` are the
 * low-overhead channel UI components (waveform, seekbar) read instead of
 * subscribing to the full Zustand store. Heavy state writes still go
 * through Zustand at the coarser store-commit interval — see
 * `playerStore.events.test.ts` for that side.
 *
 * Drive emits via the `audio:progress` Tauri event (the only public path
 * to `emitPlaybackProgress`).
 */
import { initAudioListeners } from '@/features/playback/store/initAudioListeners';
import { getPlaybackProgressSnapshot, subscribePlaybackProgress, type PlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@/lib/api/subsonic', async () => {
  const actual = await vi.importActual<typeof import('@/lib/api/subsonic')>('@/lib/api/subsonic');
  return {
    ...actual,
    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}`),
    getSong: vi.fn(async () => null),
    getSimilarSongs2: vi.fn(async () => []),
    getTopSongs: vi.fn(async () => []),
    getAlbumInfo2: vi.fn(async () => null),
    reportNowPlaying: vi.fn(async () => undefined),
    scrobbleSong: vi.fn(async () => undefined),
  };
});


import { usePlayerStore } from '@/features/playback/store/playerStore';
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories';

function stubInvokes(): void {
  onInvoke('audio_play', () => undefined);
  onInvoke('audio_pause', () => undefined);
  onInvoke('audio_resume', () => 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);
  onInvoke('discord_update_presence', () => undefined);
  onInvoke('frontend_debug_log', () => undefined);
}

let cleanupListeners: (() => void) | null = null;

beforeEach(() => {
  vi.useFakeTimers();
  resetPlayerStore();
  resetAuthStore();
  stubInvokes();
  cleanupListeners = initAudioListeners();
});

afterEach(() => {
  cleanupListeners?.();
  cleanupListeners = null;
  vi.runOnlyPendingTimers();
  vi.useRealTimers();
});

describe('getPlaybackProgressSnapshot', () => {
  it('returns the same shape (currentTime / progress / buffered)', () => {
    const snap = getPlaybackProgressSnapshot();
    expect(snap).toHaveProperty('currentTime');
    expect(snap).toHaveProperty('progress');
    expect(snap).toHaveProperty('buffered');
    expect(typeof snap.currentTime).toBe('number');
    expect(typeof snap.progress).toBe('number');
    expect(typeof snap.buffered).toBe('number');
  });

  it('reflects a fresh audio:progress event when transport is active', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });
    emitTauriEvent('audio:progress', { current_time: 42, duration: 100 });

    const snap = getPlaybackProgressSnapshot();
    expect(snap.currentTime).toBeCloseTo(42, 3);
    expect(snap.progress).toBeCloseTo(0.42, 3);
  });
});

describe('subscribePlaybackProgress', () => {
  it('notifies the subscriber on each observable update', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const calls: PlaybackProgressSnapshot[] = [];
    const unsub = subscribePlaybackProgress(next => calls.push(next));

    emitTauriEvent('audio:progress', { current_time: 10, duration: 100 });
    vi.advanceTimersByTime(2000); // beyond LIVE_PROGRESS_EMIT_MIN_MS = 1500
    emitTauriEvent('audio:progress', { current_time: 20, duration: 100 });

    expect(calls).toHaveLength(2);
    expect(calls[0]?.currentTime).toBeCloseTo(10, 3);
    expect(calls[1]?.currentTime).toBeCloseTo(20, 3);

    unsub();
  });

  it('passes (next, prev) snapshots to the subscriber', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const pairs: Array<[PlaybackProgressSnapshot, PlaybackProgressSnapshot]> = [];
    const unsub = subscribePlaybackProgress((next, prev) => pairs.push([next, prev]));

    emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
    vi.advanceTimersByTime(2000);
    emitTauriEvent('audio:progress', { current_time: 15, duration: 100 });

    expect(pairs).toHaveLength(2);
    const [[next2, prev2]] = pairs.slice(-1);
    expect(next2.currentTime).toBeCloseTo(15, 3);
    expect(prev2.currentTime).toBeCloseTo(5, 3);

    unsub();
  });

  it('coalesces near-duplicate snapshots (epsilon guard inside emitPlaybackProgress)', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const calls: PlaybackProgressSnapshot[] = [];
    const unsub = subscribePlaybackProgress(next => calls.push(next));

    emitTauriEvent('audio:progress', { current_time: 10, duration: 100 });
    vi.advanceTimersByTime(2000);
    // Re-emit with a delta smaller than the snapshot's 0.005 s epsilon.
    emitTauriEvent('audio:progress', { current_time: 10.001, duration: 100 });

    expect(calls).toHaveLength(1);
    unsub();
  });

  it('stops notifying after the returned unsubscribe runs', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const calls: PlaybackProgressSnapshot[] = [];
    const unsub = subscribePlaybackProgress(next => calls.push(next));

    emitTauriEvent('audio:progress', { current_time: 7, duration: 100 });
    expect(calls).toHaveLength(1);

    unsub();
    vi.advanceTimersByTime(2000);
    emitTauriEvent('audio:progress', { current_time: 50, duration: 100 });

    expect(calls).toHaveLength(1);
  });

  it('supports multiple subscribers independently', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const a: number[] = [];
    const b: number[] = [];
    const unsubA = subscribePlaybackProgress(n => a.push(n.currentTime));
    const unsubB = subscribePlaybackProgress(n => b.push(n.currentTime));

    emitTauriEvent('audio:progress', { current_time: 11, duration: 100 });
    expect(a).toHaveLength(1);
    expect(b).toHaveLength(1);

    unsubA();
    vi.advanceTimersByTime(2000);
    emitTauriEvent('audio:progress', { current_time: 21, duration: 100 });
    expect(a).toHaveLength(1);
    expect(b).toHaveLength(2);

    unsubB();
  });
});

describe('audio:progress throttling (live-emit guard)', () => {
  it('drops a second event that fires within the time threshold and below the delta threshold', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const calls: PlaybackProgressSnapshot[] = [];
    const unsub = subscribePlaybackProgress(next => calls.push(next));

    emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
    // Δt only 50 ms (< 1500 ms), Δs only 0.5 (< 0.9 s) — second event dropped.
    vi.advanceTimersByTime(50);
    emitTauriEvent('audio:progress', { current_time: 5.5, duration: 100 });

    expect(calls).toHaveLength(1);
    expect(calls[0]?.currentTime).toBeCloseTo(5, 3);
    unsub();
  });

  it('lets through a second event when the position delta is large enough (≥ 0.9 s)', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const calls: PlaybackProgressSnapshot[] = [];
    const unsub = subscribePlaybackProgress(next => calls.push(next));

    emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
    vi.advanceTimersByTime(50);
    // Δs = 1.0 ≥ 0.9 → emit passes the live-emit gate even though Δt is small.
    emitTauriEvent('audio:progress', { current_time: 6.0, duration: 100 });

    expect(calls).toHaveLength(2);
    unsub();
  });

  it('lets through a second event when enough time has passed (≥ 1500 ms)', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({ currentTrack: track, isPlaying: true });

    const calls: PlaybackProgressSnapshot[] = [];
    const unsub = subscribePlaybackProgress(next => calls.push(next));

    emitTauriEvent('audio:progress', { current_time: 5, duration: 100 });
    vi.advanceTimersByTime(1600); // ≥ LIVE_PROGRESS_EMIT_MIN_MS
    emitTauriEvent('audio:progress', { current_time: 5.05, duration: 100 });

    expect(calls).toHaveLength(2);
    unsub();
  });
});

describe('audio:progress buffering flag', () => {
  it('sets isPlaybackBuffering from the optional buffering field', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({
      currentTrack: track,
      isPlaying: true,
      isPlaybackBuffering: false,
    });

    emitTauriEvent('audio:progress', {
      current_time: 0,
      duration: 100,
      buffering: true,
    });
    expect(usePlayerStore.getState().isPlaybackBuffering).toBe(true);

    emitTauriEvent('audio:playing', 100);
    expect(usePlayerStore.getState().isPlaybackBuffering).toBe(false);
  });

  it('does not rewrite isPlaybackBuffering when the flag is unchanged', () => {
    const track = makeTrack({ duration: 100 });
    usePlayerStore.setState({
      currentTrack: track,
      isPlaying: true,
      isPlaybackBuffering: true,
    });
    const setStateSpy = vi.spyOn(usePlayerStore, 'setState');

    emitTauriEvent('audio:progress', {
      current_time: 0,
      duration: 100,
      buffering: true,
    });
    vi.advanceTimersByTime(2000);
    emitTauriEvent('audio:progress', {
      current_time: 0.9,
      duration: 100,
      buffering: true,
    });

    const bufferingWrites = setStateSpy.mock.calls.filter(
      call => typeof call[0] === 'object' && call[0] !== null && 'isPlaybackBuffering' in call[0],
    );
    expect(bufferingWrites).toHaveLength(0);

    setStateSpy.mockRestore();
  });
});
