/**
 * VideoPlayer scrub regression tests (Android backend path)
 *
 * Reproduces the reported bug: with a sleep timer active, scrubbing the
 * video seek bar "seeks, then jumps back to the old position".
 *
 * Root cause history:
 * - Native init called onDestroy() after an await -> lifecycle_outside_component
 *   -> the catch treated init as failed and silently flipped useHtml5Element to
 *   true, so seeks went down the HTML5 path while ExoPlayer kept playing.
 * - The native SurfaceView has never been visible through the webview, so the
 *   INTERIM behavior (until the video-player API refactor) is: when the backend
 *   reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
 *   and stops the native backend (single audio source, webview owns playback).
 *
 * These tests pin the interim behavior: Android's native response is
 * overridden, the backend is stopped exactly once, and scrubbing keeps
 * working (and holds its position) with a sleep timer active.
 */

import { describe, it, expect, vi, beforeEach } from "vitest";

// ---- Mocks (must precede component import) --------------------------------

const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
// off, VideoPlayer overrides Android's native backend response to HTML5
// rendering and stops the native backend. That is the default again (DR-172,
// after native video shipped as audio with no picture), so this mock now agrees
// with the default rather than opposing it — kept explicit so the tests state
// which path they guard instead of inheriting whatever the default happens to be.
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
  const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
  return {
    ...actual,
    experimentalNativeVideo: {
      subscribe: (run: (v: boolean) => void) => {
        run(false);
        return () => {};
      },
      set: () => {},
      current: () => false,
    },
  };
});

vi.mock("@tauri-apps/api/event", () => ({
  listen: vi.fn(async (channel: string, handler: any) => {
    channelHandlers[channel] = handler;
    return () => {
      delete channelHandlers[channel];
    };
  }),
}));

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

const playerPlayItem = vi.fn(async () => ({
  // What Android reports: native ExoPlayer backend
  useHtml5Element: false,
  backend: "exoplayer",
  state: { kind: "playing" },
}));
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
  strategy: "native",
  position,
}));
const playerStop = vi.fn(async () => ({}));
const playerToggle = vi.fn(async () => ({ state: "playing" }));
const playerSetSleepTimer = vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 }));
const playerCancelSleepTimer = vi.fn(async () => ({ mode: { kind: "off" }, remainingSeconds: 0 }));

vi.mock("$lib/api/bindings", () => ({
  commands: {
    playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
    playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])),
    playerStop: (...a: any[]) => playerStop(...(a as [])),
    playerToggle: (...a: any[]) => playerToggle(...(a as [])),
    playerSetSleepTimer: (...a: any[]) => playerSetSleepTimer(...(a as [any])),
    playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
    playerSetSubtitleTrack: vi.fn(async () => ({})),
    playerSwitchAudioTrack: vi.fn(async () => ({})),
    // The player loads the streaming-quality picker on mount; without these the
    // mock throws and every test in the file fails before it starts.
    playerGetStreamingQualities: vi.fn(async () => []),
    playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
    storageGetSeriesAudioPreference: vi.fn(async () => null),
    storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
  },
  events: {
    playerStatusEvent: { listen: vi.fn(async () => () => {}) },
  },
}));

vi.mock("$lib/stores/auth", () => ({
  auth: {
    getUserId: () => "user-1",
    getRepository: () => ({
      getHandle: () => "repo-1",
      getSubtitleUrl: async () => "",
      jrayActorsAt: async () => [],
    }),
  },
}));

vi.mock("$app/navigation", () => ({
  goto: vi.fn(),
}));

// Use the REAL sleepTimer store module so timer activation flows exactly as
// in production (playerEvents.ts writes to it on every backend tick).

import { render, fireEvent, waitFor } from "@testing-library/svelte";
import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import type { MediaItem } from "$lib/api/types";

function makeEpisode(): MediaItem {
  return {
    id: "ep1",
    name: "Episode 1",
    kind: "episode",
    durationMs: 24 * 60 * 1000, // 24 min
  } as MediaItem;
}

/** Simulate one backend sleep-timer tick, exactly as playerEvents.ts does. */
function sleepTimerTick(remaining = 2) {
  sleepTimer.set({
    mode: { kind: "episodes", remaining },
    remainingSeconds: 0,
  });
}

async function mountAndroidPlayer() {
  const utils = render(VideoPlayer, {
    props: {
      media: makeEpisode(),
      streamUrl: "http://server/videos/ep1/master.m3u8",
      mediaSourceId: "src-1",
      needsTranscoding: false,
      onClose: vi.fn(),
    },
  });

  // Init: backend reports native, component overrides to HTML5 and stops it.
  await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
  await waitFor(() => expect(playerStop).toHaveBeenCalled());

  const slider = utils.container.querySelector(
    'input[type="range"]'
  ) as HTMLInputElement;
  const video = utils.container.querySelector("video") as HTMLVideoElement;
  expect(slider).not.toBeNull();
  expect(video).not.toBeNull();
  return { ...utils, slider, video };
}

/** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo(
  slider: HTMLInputElement,
  video: HTMLVideoElement,
  target: number
) {
  await fireEvent.mouseDown(slider);
  slider.value = String(target);
  await fireEvent.input(slider);
  await fireEvent.change(slider);
  await fireEvent.mouseUp(slider);
  // Resolve the "wait for seeked" step of the HTML5 native-seek path.
  await fireEvent(video, new Event("seeked"));
  await tick();
}

describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
    sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
    sleepTimerExpiredSignal.set(0);
  });

  it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => {
    await mountAndroidPlayer();
    // The native backend must be stopped so it doesn't play audio behind the
    // webview (frozen picture + double audio source).
    expect(playerStop).toHaveBeenCalledTimes(1);
  });

  it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
    const { slider, video } = await mountAndroidPlayer();

    await scrubTo(slider, video, 600);

    await waitFor(() =>
      expect(playerSeekVideo).toHaveBeenCalledWith(
        "repo-1",
        600,
        "src-1",
        null,
        true // HTML5 path: the webview owns playback after the override
      )
    );
    expect(parseFloat(slider.value)).toBeCloseTo(600);
  });

  it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
    const { slider, video } = await mountAndroidPlayer();

    // Enable "2 more episodes" timer; backend then ticks every second.
    sleepTimerTick(2);
    await tick();
    sleepTimerTick(2);
    await tick();

    await scrubTo(slider, video, 600);
    await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
    expect(parseFloat(slider.value)).toBeCloseTo(600);

    // Timer ticks after the seek must not snap the bar back.
    sleepTimerTick(2);
    await tick();
    expect(parseFloat(slider.value)).toBeCloseTo(600);

    // A second scrub must also work.
    await scrubTo(slider, video, 900);
    await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
    expect(parseFloat(slider.value)).toBeCloseTo(900);
  });

  it("sleep-timer ticks alone never move the seek bar", async () => {
    const { slider } = await mountAndroidPlayer();

    const before = slider.value;
    for (let i = 0; i < 5; i++) {
      sleepTimerTick(2);
      await tick();
    }

    expect(slider.value).toBe(before);
    expect(playerSeekVideo).not.toHaveBeenCalled();
  });
});
