/**
 * Player Events Service — regression tests
 *
 * These tests intentionally use the REAL player store (and its real derived
 * stores), unlike playerEvents.test.ts which mocks the store away. The bugs
 * covered here live in the interaction between the event handler and the
 * store's position/duration fields, so a mocked store cannot catch them.
 *
 * TRACES: UR-005 | DR-001
 */

import { describe, it, expect, vi, beforeEach } from "vitest";
import { get, writable } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import type { PlayerStatusEvent } from "$lib/api/bindings";

// Capture the handler that initPlayerEvents registers so we can drive events
// directly, exactly as the Tauri event bridge would.
let registeredHandler: ((event: { payload: PlayerStatusEvent }) => void) | null = null;

vi.mock("@tauri-apps/api/event", () => ({
  listen: vi.fn(async (_event: string, handler: any) => {
    registeredHandler = handler;
    return () => {};
  }),
}));

vi.mock("@tauri-apps/api/core", () => ({
  invoke: vi.fn(),
}));

// The current queue item is what handleStateChanged seeds playing/paused state
// from. Back it with a writable so each test can install its own item.
const currentQueueItemStore = writable<MediaItem | null>(null);
vi.mock("$lib/stores/queue", () => ({
  queue: { subscribe: vi.fn() },
  currentQueueItem: { subscribe: (run: any) => currentQueueItemStore.subscribe(run) },
}));

// Keep the player in local mode so events aren't skipped as remote.
// playerEvents.ts reads `get(playbackMode)` (.mode/.isTransferring) and player.ts
// imports `isRemoteMode` from the same module — provide both.
vi.mock("$lib/stores/playbackMode", () => ({
  playbackMode: {
    setMode: vi.fn(),
    initializeSessionMonitoring: vi.fn(),
    subscribe: (run: any) => {
      run({ mode: "local", isTransferring: false });
      return () => {};
    },
  },
  isRemoteMode: { subscribe: (run: any) => (run(false), () => {}) },
}));

// Remote-mode merged stores in player.ts read this; stub as no remote session.
vi.mock("$lib/stores/sessions", () => ({
  selectedSession: { subscribe: (run: any) => (run(null), () => {}) },
}));

vi.mock("$lib/stores/sleepTimer", () => ({
  sleepTimer: { set: vi.fn() },
}));

vi.mock("$lib/stores/nextEpisode", () => ({
  nextEpisode: { showPopup: vi.fn(), updateCountdown: vi.fn() },
}));

vi.mock("$lib/services/preload", () => ({
  preloadUpcomingTracks: vi.fn().mockResolvedValue(undefined),
}));

function makeItem(overrides: Partial<MediaItem> = {}): MediaItem {
  return {
    id: "track-1",
    name: "Test Track",
    kind: "track",
    durationMs: null,
    ...overrides,
  } as MediaItem;
}

async function fire(event: PlayerStatusEvent): Promise<void> {
  if (!registeredHandler) throw new Error("handler not registered");
  await registeredHandler({ payload: event });
  // Let any async work inside the handler settle.
  await Promise.resolve();
}

describe("Player Events — pause must not zero the slider duration", () => {
  beforeEach(async () => {
    // initPlayerEvents is a singleton; reset it so each test re-registers its
    // own handler and starts from idle player state.
    const { cleanupPlayerEvents } = await import("./playerEvents");
    const { player } = await import("$lib/stores/player");
    cleanupPlayerEvents();
    player.setIdle();
    vi.clearAllMocks();
    registeredHandler = null;
    currentQueueItemStore.set(null);
  });

  it("preserves the live duration across pause when runTimeTicks is missing", async () => {
    // runTimeTicks is null — the previous code recomputed duration as 0 here,
    // which collapsed the slider's max and snapped the thumb to the start.
    const item = makeItem({ durationMs: null });
    currentQueueItemStore.set(item);

    const { initPlayerEvents } = await import("./playerEvents");
    const { player, playbackDuration, playbackPosition } = await import("$lib/stores/player");

    await initPlayerEvents();

    // 1. Track starts playing.
    await fire({ type: "state_changed", state: "playing", media_id: item.id });
    // 2. Backend reports the real duration once media is loaded / position ticks.
    await fire({ type: "position_update", position: 42, duration: 180 });

    expect(get(playbackPosition)).toBe(42);
    expect(get(playbackDuration)).toBe(180);

    // 3. User pauses. Position AND duration must survive — duration is what
    //    drives the slider's max, so a 0 here is what caused the regression.
    await fire({ type: "state_changed", state: "paused", media_id: item.id });

    expect(get(playbackPosition)).toBe(42);
    expect(get(playbackDuration)).toBe(180);

    // Sanity: the store is genuinely paused, not reset to idle/loading.
    expect(get(player).state.kind).toBe("paused");
  });

  it("falls back to the runTimeTicks estimate when no live duration is known yet", async () => {
    // 70 seconds = 70_000 ms.
    const item = makeItem({ durationMs: 70_000 });
    currentQueueItemStore.set(item);

    const { initPlayerEvents } = await import("./playerEvents");
    const { playbackDuration } = await import("$lib/stores/player");

    await initPlayerEvents();

    // No position_update yet, so there is no live duration to prefer.
    await fire({ type: "state_changed", state: "paused", media_id: item.id });

    expect(get(playbackDuration)).toBe(70);
  });
});

/**
 * A recoverable error is a network hiccup, not the end of playback. The handler
 * used to stop the player unconditionally, so a blip on wifi killed the track —
 * on Linux especially, where MPV's EndFile(ERROR) is the only signal a stream
 * died and there is no in-process controller for the backend to consult.
 *
 * TRACES: UR-004, UR-040 | DR-130
 */
describe("Player Events — recoverable errors get one chance before stopping", () => {
  beforeEach(async () => {
    const { cleanupPlayerEvents } = await import("./playerEvents");
    const { player } = await import("$lib/stores/player");
    cleanupPlayerEvents();
    player.setIdle();
    vi.clearAllMocks();
    registeredHandler = null;
    currentQueueItemStore.set(null);
  });

  it("does not stop the player when Rust re-opened the stream", async () => {
    const { invoke } = await import("@tauri-apps/api/core");
    vi.mocked(invoke).mockImplementation(async (cmd: string) =>
      cmd === "player_recover_stream" ? true : null
    );

    const { initPlayerEvents } = await import("./playerEvents");
    const { player } = await import("$lib/stores/player");
    await initPlayerEvents();
    await fire({ type: "state_changed", state: "playing", media_id: "track-1" });

    await fire({ type: "error", message: "Playback stream failed", recoverable: true });

    const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
    expect(calls).toContain("player_recover_stream");
    expect(calls).not.toContain("player_stop");
    expect(get(player).state.kind).not.toBe("error");
  });

  it("stops the player when recovery declines", async () => {
    const { invoke } = await import("@tauri-apps/api/core");
    vi.mocked(invoke).mockImplementation(async (cmd: string) =>
      cmd === "player_recover_stream" ? false : null
    );

    const { initPlayerEvents } = await import("./playerEvents");
    await initPlayerEvents();

    await fire({ type: "error", message: "Playback stream failed", recoverable: true });
    await Promise.resolve();

    const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
    expect(calls).toContain("player_recover_stream");
    expect(calls).toContain("player_stop");
  });

  it("does not attempt recovery for an unrecoverable error", async () => {
    // Android decides in its JNI callback and reports the errors it already
    // declined as unrecoverable, so this must not ask a second time.
    const { invoke } = await import("@tauri-apps/api/core");
    vi.mocked(invoke).mockResolvedValue(null);

    const { initPlayerEvents } = await import("./playerEvents");
    await initPlayerEvents();

    await fire({ type: "error", message: "Decoder failed", recoverable: false });
    await Promise.resolve();

    const calls = vi.mocked(invoke).mock.calls.map(([cmd]) => cmd);
    expect(calls).not.toContain("player_recover_stream");
    expect(calls).toContain("player_stop");
  });
});
