/**
 * Unit tests for Html5PlayerAdapter.
 *
 * The Option-1 primitive design makes the adapter pure, decision-free mechanics
 * — it takes a mock <video> element + bridge + host, so we can assert each
 * primitive drives the element correctly without any real DOM or backend.
 */

import { describe, it, expect, vi, beforeEach } from "vitest";
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
import type { AdapterHost } from "./types";

/** A minimal fake <video> element that records mutations and fires events. */
function makeFakeVideo() {
  const listeners: Record<string, Array<() => void>> = {};
  const el: any = {
    paused: true,
    currentTime: 0,
    volume: 1,
    muted: false,
    src: "blob:existing",
    play: vi.fn(async () => {
      el.paused = false;
    }),
    pause: vi.fn(() => {
      el.paused = true;
    }),
    load: vi.fn(),
    removeAttribute: vi.fn((attr: string) => {
      if (attr === "src") el.src = "";
    }),
    addEventListener: (event: string, cb: () => void) => {
      (listeners[event] ??= []).push(cb);
    },
    removeEventListener: (event: string, cb: () => void) => {
      listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb);
    },
    // Test helper: fire an event so waitForEvent resolves immediately.
    _fire: (event: string) => {
      (listeners[event] ?? []).slice().forEach((f) => f());
    },
    querySelectorAll: () => [] as any,
    textTracks: [] as any,
  };
  return el;
}
type FakeVideo = ReturnType<typeof makeFakeVideo>;

function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBridge {
  let offset = 0;
  return {
    getElement: () => null,
    getSeekOffset: () => offset,
    setSeekOffset: vi.fn((o: number) => { offset = o; }),
    setStreamUrl: vi.fn(),
    destroyHls: vi.fn(),
    getMediaSourceId: () => "msid-1",
    ...overrides,
  };
}

function makeHost(): AdapterHost {
  return {
    onState: vi.fn(),
    onPosition: vi.fn(),
    onMediaLoaded: vi.fn(),
    onEnded: vi.fn(),
    onError: vi.fn(),
    onStreamUrlChanged: vi.fn(),
    onBuffering: vi.fn(),
    onReady: vi.fn(),
  };
}

describe("Html5PlayerAdapter", () => {
  let host: AdapterHost;
  let bridge: Html5ElementBridge;
  let adapter: Html5PlayerAdapter;
  let video: ReturnType<typeof makeFakeVideo>;

  beforeEach(() => {
    host = makeHost();
    bridge = makeBridge();
    adapter = new Html5PlayerAdapter(host, bridge);
    video = makeFakeVideo();
    adapter.attach(video);
  });

  it("is an html5-kind adapter", () => {
    expect(adapter.kind).toBe("html5");
  });

  it("play() calls element.play()", async () => {
    await adapter.play();
    expect(video.play).toHaveBeenCalledTimes(1);
  });

  // A stalling HLS stream makes hls.js' gap-controller nudge the element, which
  // aborts an in-flight play(). That AbortError is transient — the element is
  // still trying to play — so it must not be surfaced as a player error, or the
  // UI reports failure ~once a second for the whole stall.
  it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
    const abort = new DOMException(
      "The play() request was interrupted by a call to pause().",
      "AbortError"
    );
    video.play = vi.fn(async () => {
      throw abort;
    });

    await adapter.play();

    expect(host.onError).not.toHaveBeenCalled();
  });

  it("play() still reports a genuine failure", async () => {
    video.play = vi.fn(async () => {
      throw new DOMException("no supported source", "NotSupportedError");
    });

    await adapter.play();

    expect(host.onError).toHaveBeenCalledTimes(1);
    expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
  });

  it("play() coalesces concurrent attempts into one element.play() call", async () => {
    // During a stall the UI and recovery paths can both ask to play. Stacking
    // element.play() calls is what generates the AbortError storm.
    let resolvePlay: () => void = () => {};
    video.play = vi.fn(
      () =>
        new Promise<void>((r) => {
          resolvePlay = () => {
            video.paused = false;
            r();
          };
        })
    );

    const first = adapter.play();
    const second = adapter.play();
    resolvePlay();
    await Promise.all([first, second]);

    expect(video.play).toHaveBeenCalledTimes(1);
  });

  it("play() works again after a previous attempt settled", async () => {
    await adapter.play();
    await adapter.play();
    expect(video.play).toHaveBeenCalledTimes(2);
  });

  it("pause() calls element.pause()", async () => {
    video.paused = false;
    await adapter.pause();
    expect(video.pause).toHaveBeenCalledTimes(1);
  });

  it("toggle() plays when paused and reports the resulting state", async () => {
    video.paused = true;
    const playing = await adapter.toggle();
    expect(video.play).toHaveBeenCalled();
    expect(playing).toBe(true);
  });

  it("toggle() pauses when playing", async () => {
    video.paused = false;
    const playing = await adapter.toggle();
    expect(video.pause).toHaveBeenCalled();
    expect(playing).toBe(false);
  });

  it("seekElement() sets currentTime, offset, and waits for 'seeked'", async () => {
    const p = adapter.seekElement(42, 0);
    expect(video.currentTime).toBe(42);
    expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
    video._fire("seeked"); // resolve the wait
    await p;
  });

  it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
    video.paused = false; // was playing → should resume
    const p = adapter.reloadSource("http://new/master.m3u8", 120);

    // Teardown happened synchronously before the awaited canplay wait.
    expect(video.pause).toHaveBeenCalled();
    expect(bridge.destroyHls).toHaveBeenCalledTimes(1);
    expect(video.removeAttribute).toHaveBeenCalledWith("src");
    expect(video.load).toHaveBeenCalled();

    // Allow the internal 100ms settle delay, then fire canplay to resume.
    await new Promise((r) => setTimeout(r, 110));
    expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
    video._fire("canplay");
    video._fire("seeked");
    await p;
    expect(video.play).toHaveBeenCalled(); // resumed because it was playing
  });

  /**
   * The reload lands the viewer at the position they asked for — by *seeking*,
   * with no transcode offset left over.
   *
   * This used to be inverted: the offset was set to the position and nothing
   * seeked, which was right only while the reloaded URL itself began there via
   * `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
   * the server copies it onto every segment URI and then rejects each one with
   * `400`. With the URL starting at the item's zero, the old arithmetic leaves
   * `currentTime = offset + 0` — the scrubber reading 20:00 over the opening
   * titles, and the seek silently never happening.
   *
   * TRACES: UR-004, UR-005 | DR-181 | UT-183
   */
  it("reloadSource() seeks to the position and clears the transcode offset", async () => {
    video.paused = false;
    const p = adapter.reloadSource("http://new/master.m3u8", 1200);

    await new Promise((r) => setTimeout(r, 110));
    expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
    expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);

    // Nothing may seek before the new source is playable — the element drops it.
    expect(video.currentTime).not.toBe(1200);

    video._fire("canplay");
    await new Promise((r) => setTimeout(r, 0));
    expect(video.currentTime).toBe(1200);

    video._fire("seeked");
    await p;
    expect(video.play).toHaveBeenCalled();
  });

  /** A reload to the very start has nothing to seek to; it must not stall. */
  it("reloadSource() at position 0 does not wait for a seek", async () => {
    video.paused = false;
    const p = adapter.reloadSource("http://new/master.m3u8", 0);
    await new Promise((r) => setTimeout(r, 110));
    video._fire("canplay");
    await p; // resolves without any "seeked" event
    expect(video.play).toHaveBeenCalled();
  });

  /**
   * A reload that never becomes playable must be reported as a failure. It used
   * to resolve on the timeout, so a quality switch whose new stream the server
   * refused to serve (Jellyfin 400s the first segment when two transcode jobs
   * collide) looked like a success: the picker showed the new quality selected
   * over a stream that never played, and the caller had nothing to revert to.
   *
   * TRACES: UR-074 | DR-177 | UT-175
   */
  it("reloadSource() rejects when the new stream never becomes playable", async () => {
    vi.useFakeTimers();
    try {
      video.paused = false;
      const p = adapter.reloadSource("http://new/master.m3u8", 120);
      const assertion = expect(p).rejects.toThrow(/canplay/i);
      await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
      await assertion;
      expect(video.play).not.toHaveBeenCalled(); // nothing to resume into
    } finally {
      vi.useRealTimers();
    }
  });

  it("reloadSource() does not resume when it was paused", async () => {
    video.paused = true;
    const p = adapter.reloadSource("http://new/master.m3u8", 30);
    await new Promise((r) => setTimeout(r, 110));
    video._fire("canplay");
    video._fire("seeked");
    await p;
    expect(video.play).not.toHaveBeenCalled();
  });

  it("setVolume() clamps to 0..1", () => {
    adapter.setVolume(1.5);
    expect(video.volume).toBe(1);
    adapter.setVolume(-0.5);
    expect(video.volume).toBe(0);
    adapter.setVolume(0.4);
    expect(video.volume).toBeCloseTo(0.4);
  });

  it("setMuted() sets the element muted flag", () => {
    adapter.setMuted(true);
    expect(video.muted).toBe(true);
  });

  it("getPosition() returns element time plus the transcode offset", () => {
    video.currentTime = 10;
    (bridge.getSeekOffset as any) = () => 100;
    // Rebuild adapter with the offset-returning bridge.
    const a = new Html5PlayerAdapter(host, bridge);
    a.attach(video);
    expect(a.getPosition()).toBe(110);
  });

  it("dispose() tears down hls and clears the element", async () => {
    await adapter.dispose();
    expect(bridge.destroyHls).toHaveBeenCalled();
    expect(video.pause).toHaveBeenCalled();
    // After dispose, primitives are no-ops (element detached).
    await adapter.play();
    // play was called once during dispose teardown? no — play only on reload/resume.
    expect(video.play).not.toHaveBeenCalled();
  });

  it("primitives are safe no-ops before an element is attached", async () => {
    const bare = new Html5PlayerAdapter(host, bridge);
    await expect(bare.play()).resolves.toBeUndefined();
    await expect(bare.pause()).resolves.toBeUndefined();
    await expect(bare.seekElement(5, 0)).resolves.toBeUndefined();
    expect(await bare.toggle()).toBe(false);
  });
});
