import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type {
  IMediaSource,
  ISourceBuffer,
  ISourceBufferList,
} from "../../compat/browser_compatibility_types";
import { SourceBufferError } from "../../errors";
import noop from "../../utils/noop";
import MainMediaSourceInterface, {
  MainSourceBufferInterface,
} from "../main_media_source_interface";
import { SourceBufferType } from "../types";
import MediaSourceDurationUpdater from "../utils/media_source_duration_updater";

const {
  MockMediaSource,
  MockSourceBuffer,
  getLastMediaSourceMock,
  mediaSourceProps,
  makeMockSourceBufferList,
  mockMaintainEndOfStream,
  mockOnSourceClose,
  mockOnSourceEnded,
  mockOnSourceOpen,
  mockTryToChangeSourceBufferType,
} = vi.hoisted(() => {
  /** Creates a minimal mock SourceBuffer with controllable event dispatching */
  class InnerMockSourceBuffer implements ISourceBuffer {
    private _listeners: Record<string, Array<(e?: unknown) => void>> = {};
    updating: boolean = false;
    buffered: TimeRanges = {
      length: 0,
      start: vi.fn().mockReturnValue(0),
      end: vi.fn().mockReturnValue(0),
    };
    timestampOffset: number = 0;
    appendWindowStart: number = 0;
    appendWindowEnd: number = Infinity;
    onabort = null;
    onerror = null;
    onupdate = null;
    onupdateend = null;
    onupdatestart = null;
    changeType(): void {
      // noop
    }
    appendBuffer(): void {
      // noop
    }
    remove(): void {
      // noop
    }
    abort(): void {
      // noop
    }
    /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
    addEventListener(event: any, cb: (e?: any) => void) {
      this._listeners[event] = this._listeners[event] ?? [];
      this._listeners[event].push(cb);
    }
    removeEventListener(event?: any, cb?: ((e?: any) => void) | undefined) {
      if (event === undefined) {
        this._listeners = {};
        return;
      }
      this._listeners[event] = (this._listeners[event] ?? []).filter((fn) =>
        cb === undefined ? false : fn !== cb,
      );
    }

    /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
    _emit(event: string, arg?: unknown) {
      for (const cb of this._listeners[event] ?? []) {
        cb(arg);
      }
    }
  }

  /**
   * Creates a minimal ISourceBufferList-compatible object from an array of
   * ISourceBuffer instances.
   */
  function makeMockSourceBufferListInner(buffers: ISourceBuffer[]): ISourceBufferList {
    return {
      onaddsourcebuffer: null,
      onremovesourcebuffer: null,
      get length() {
        return buffers.length;
      },
      addEventListener: vi.fn(),
      removeEventListener: vi.fn(),
      ...Object.fromEntries(buffers.map((buf, i) => [i, buf])),
    };
  }

  const mediaSourcePropsObj: {
    handle: MediaProvider | undefined;
    streaming: boolean | undefined;
  } = { handle: undefined, streaming: undefined };

  let lastCreatedMockMS: InnerMockMediaSource | null = null;
  class InnerMockMediaSource implements IMediaSource {
    private _mediaSourceListeners: Record<string, Array<() => void>>;
    readyState: ReadyState = "open";
    duration: number = 0;
    onsourceclose = null;
    onsourceended = null;
    onsourceopen = null;
    constructor() {
      this._mediaSourceListeners = {};
      // eslint-disable-next-line @typescript-eslint/no-this-alias
      lastCreatedMockMS = this;
    }
    clearLiveSeekableRange(): never {
      throw new Error("not implemented");
    }
    setLiveSeekableRange(): never {
      throw new Error("not implemented");
    }
    sourceBuffers = makeMockSourceBufferListInner([]);
    addSourceBuffer(): ISourceBuffer {
      throw new Error("not implemented");
    }
    removeSourceBuffer(): never {
      throw new Error("not implemented");
    }
    endOfStream(): void {
      // noop
    }
    get handle(): MediaProvider | undefined {
      return mediaSourcePropsObj.handle;
    }
    get streaming(): boolean | undefined {
      return mediaSourcePropsObj.streaming;
    }
    /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */
    addEventListener(event: any, cb: (e?: any) => void) {
      this._mediaSourceListeners[event] = this._mediaSourceListeners[event] ?? [];
      this._mediaSourceListeners[event].push(cb);
    }
    removeEventListener(event?: any, cb?: ((e?: any) => void) | undefined) {
      if (event === undefined) {
        this._mediaSourceListeners = {};
        return;
      }
      this._mediaSourceListeners[event] = (
        this._mediaSourceListeners[event] ?? []
      ).filter((fn) => (cb === undefined ? false : fn !== cb));
    }
    /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */

    _emit(event: string) {
      for (const cb of this._mediaSourceListeners[event] ?? []) {
        cb();
      }
    }
  }

  return {
    mockTryToChangeSourceBufferType: vi.fn().mockReturnValue(true),
    mockOnSourceOpen: vi.fn(),
    mockOnSourceEnded: vi.fn(),
    mockOnSourceClose: vi.fn(),
    mockMaintainEndOfStream: vi.fn(),
    MockMediaSource: InnerMockMediaSource,
    MockSourceBuffer: InnerMockSourceBuffer,
    makeMockSourceBufferList: makeMockSourceBufferListInner,
    mediaSourceProps: mediaSourcePropsObj,
    getLastMediaSourceMock: (): InnerMockMediaSource => {
      if (lastCreatedMockMS === null) {
        throw new Error("No MockMediaSource has been instantiated yet");
      }
      return lastCreatedMockMS;
    },
  };
});

vi.mock("../../compat/browser_compatibility_types", () => {
  return {
    default: {
      MediaSource_: MockMediaSource,
    },
  };
});

vi.mock("../../compat/change_source_buffer_type", () => ({
  default: mockTryToChangeSourceBufferType,
}));

vi.mock("../../compat/event_listeners", () => ({
  onSourceOpen: mockOnSourceOpen,
  onSourceEnded: mockOnSourceEnded,
  onSourceClose: mockOnSourceClose,
}));

vi.mock("../../log", () => ({
  default: {
    info: vi.fn(),
    debug: vi.fn(),
    error: vi.fn(),
    warn: vi.fn(),
  },
}));

vi.mock("../utils/end_of_stream", () => ({
  maintainEndOfStream: mockMaintainEndOfStream,
}));

describe("MainMediaSourceInterface", () => {
  const mockUpdateDuration = vi.spyOn(
    MediaSourceDurationUpdater.prototype,
    "updateDuration",
  );
  const mockStopUpdating = vi.spyOn(MediaSourceDurationUpdater.prototype, "stopUpdating");

  let mockMS: InstanceType<typeof MockMediaSource>;

  beforeEach(() => {
    mockOnSourceOpen.mockReset();
    mockOnSourceEnded.mockReset();
    mockOnSourceClose.mockReset();
    mockUpdateDuration.mockReset();
    mockStopUpdating.mockReset();
    mockMaintainEndOfStream.mockReset();
  });

  afterEach(() => {
    mediaSourceProps.handle = undefined;
    mediaSourceProps.streaming = undefined;
    vi.resetAllMocks();
  });

  it("constructs correctly and registers event listeners", () => {
    const iface = new MainMediaSourceInterface("test-id");
    expect(iface.id).toBe("test-id");
    expect(iface.sourceBuffers).toEqual([]);
    expect(iface.readyState).toBe("open");
    expect(mockOnSourceOpen).toHaveBeenCalledOnce();
    expect(mockOnSourceEnded).toHaveBeenCalledOnce();
    expect(mockOnSourceClose).toHaveBeenCalledOnce();
  });

  it("uses forcedMediaSource when provided", () => {
    const error = new Error("toto");
    class ExtentedMediaSource extends MockMediaSource {
      addSourceBuffer(): ISourceBuffer {
        throw error;
      }
    }
    const ForcedConstructor = ExtentedMediaSource;
    const iface = new MainMediaSourceInterface("forced-id", ForcedConstructor);
    expect(() => iface.addSourceBuffer(SourceBufferType.Video, "test")).toThrow(error);
  });

  it("sets handle to media-source type when mediaSource.handle is nullish", () => {
    mediaSourceProps.handle = undefined;
    const iface = new MainMediaSourceInterface("h-id");
    expect(iface.handle).toMatchObject({ type: "media-source" });
  });

  it("sets handle to 'handle' type when mediaSource.handle is defined", () => {
    const fakeHandle = new Blob();
    mediaSourceProps.handle = fakeHandle;
    const iface = new MainMediaSourceInterface("h-id");
    expect(iface.handle).toMatchObject({ type: "handle", value: fakeHandle });
  });

  it("tracks streaming from the MediaSource when it is defined", () => {
    mediaSourceProps.streaming = true;
    const iface1 = new MainMediaSourceInterface("stream-id");
    expect(iface1.streaming).toBe(true);
    mediaSourceProps.streaming = false;
    const iface2 = new MainMediaSourceInterface("stream-id");
    expect(iface2.streaming).toBe(false);
  });

  it("updates streaming to true when 'startstreaming' fires", () => {
    mediaSourceProps.streaming = false;
    const iface = new MainMediaSourceInterface("stream-id");
    mockMS = getLastMediaSourceMock();
    mockMS._emit("startstreaming");
    expect(iface.streaming).toBe(true);
  });

  it("updates streaming to false when 'endstreaming' fires", () => {
    mediaSourceProps.streaming = true;
    const iface = new MainMediaSourceInterface("stream-id");
    mockMS = getLastMediaSourceMock();
    mockMS._emit("endstreaming");
    expect(iface.streaming).toBe(false);
  });

  it("triggers mediaSourceOpen when onSourceOpen callback fires", () => {
    const iface = new MainMediaSourceInterface("open-id");
    const listener = vi.fn();
    iface.addEventListener("mediaSourceOpen", listener);
    const [[, cb]] = mockOnSourceOpen.mock.calls;
    cb();
    expect(listener).toHaveBeenCalledOnce();
  });

  it("triggers mediaSourceEnded when onSourceEnded callback fires", () => {
    const iface = new MainMediaSourceInterface("ended-id");
    const listener = vi.fn();
    iface.addEventListener("mediaSourceEnded", listener);
    const [[, cb]] = mockOnSourceEnded.mock.calls;
    cb();
    expect(listener).toHaveBeenCalledOnce();
  });

  it("triggers mediaSourceClose when onSourceClose callback fires", () => {
    const iface = new MainMediaSourceInterface("close-id");
    const listener = vi.fn();
    iface.addEventListener("mediaSourceClose", listener);
    const [[, cb]] = mockOnSourceClose.mock.calls;
    cb();
    expect(listener).toHaveBeenCalledOnce();
  });

  it("addSourceBuffer creates and stores a MainSourceBufferInterface", () => {
    const sb = new MockSourceBuffer();
    vi.spyOn(MockMediaSource.prototype, "addSourceBuffer").mockReturnValue(sb);
    const iface = new MainMediaSourceInterface("sb-id");
    const result = iface.addSourceBuffer(
      SourceBufferType.Video,
      "video/mp4; codecs=avc1",
    );
    expect(result).toBeInstanceOf(MainSourceBufferInterface);
    expect(iface.sourceBuffers).toHaveLength(1);
    expect(iface.sourceBuffers[0]).toBe(result);
  });

  it("setDuration delegates to durationUpdater", () => {
    const iface = new MainMediaSourceInterface("dur-id");
    iface.setDuration(120, true);
    expect(mockUpdateDuration).toHaveBeenCalledWith(120, true);
  });

  it("interruptDurationSetting delegates to durationUpdater", () => {
    const iface = new MainMediaSourceInterface("dur-id");
    iface.interruptDurationSetting("test-reason");
    expect(mockStopUpdating).toHaveBeenCalledWith("test-reason");
  });

  it("maintainEndOfStream calls maintainEndOfStream util only once", () => {
    const iface = new MainMediaSourceInterface("eos-id");
    iface.maintainEndOfStream();
    iface.maintainEndOfStream(); // second call should not create another canceller
    expect(mockMaintainEndOfStream).toHaveBeenCalledOnce();
  });

  it("stopEndOfStream cancels active end-of-stream", () => {
    const iface = new MainMediaSourceInterface("eos-id");
    iface.maintainEndOfStream();
    expect(mockMaintainEndOfStream).toHaveBeenCalledOnce();
    iface.stopEndOfStream();
    // Calling again should be a no-op (no second cancel call means no throw)
    iface.stopEndOfStream();
  });

  it("dispose cleans up sourceBuffers and resets media source", () => {
    const sb = new MockSourceBuffer();
    const mockAbort = vi.spyOn(sb, "abort");
    vi.spyOn(MockMediaSource.prototype, "addSourceBuffer").mockReturnValue(sb);
    const iface = new MainMediaSourceInterface("dispose-id");
    mockMS = getLastMediaSourceMock();
    mockMS.sourceBuffers = makeMockSourceBufferList([sb]);
    iface.addSourceBuffer(SourceBufferType.Audio, "audio/mp4; codecs=mp4a");
    iface.dispose("test-dispose");
    expect(mockAbort).toHaveBeenCalled();
  });
});

describe("MainSourceBufferInterface", () => {
  let sb: ISourceBuffer & {
    _emit(event: string, arg?: unknown): void;
  };

  beforeEach(() => {
    sb = new MockSourceBuffer();
    mockTryToChangeSourceBufferType.mockReturnValue(true);
  });

  afterEach(() => {
    vi.resetAllMocks();
  });

  function createMainSourceBufferInterface(
    type = SourceBufferType.Video,
    codec = "video/mp4",
  ) {
    return new MainSourceBufferInterface(type, codec, sb);
  }

  it("appendBuffer returns a promise that resolves on updateend", async () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1, 2, 3]);
    const promise = iface.appendBuffer(data, { codec: "video/mp4" });
    sb._emit("updateend");
    const result = await promise;
    expect(Array.isArray(result)).toBe(true);
    expect(mockAppendBuffer).toHaveBeenCalledWith(data);
  });

  it("appendBuffer rejects on SourceBuffer error event", async () => {
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/mp4" });
    const err = new Error("Some Message");
    err.name = "SomeError";
    sb._emit("error", err);
    const thrownError = new SourceBufferError("SomeError", "Some Message", false);
    await expect(promise).rejects.toMatchObject(thrownError);
  });

  it("appendBuffer rejects with QuotaExceededError flagged correctly", async () => {
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/mp4" });
    const err = new Error("QuotaExceeded");
    err.name = "QuotaExceededError";
    sb._emit("error", err);
    const thrownError = new SourceBufferError(
      "QuotaExceededError",
      "QuotaExceeded",
      true,
    );
    await expect(promise).rejects.toMatchObject(thrownError);
  });

  it("updates codec via tryToChangeSourceBufferType when a different codec is passed", async () => {
    const iface = createMainSourceBufferInterface(SourceBufferType.Video, "video/mp4");
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/webm" });
    sb._emit("updateend");
    await promise;
    expect(mockTryToChangeSourceBufferType).toHaveBeenCalledWith(sb, "video/webm");
    expect(iface.codec).toBe("video/webm");
  });

  it("does not update codec when tryToChangeSourceBufferType returns false", async () => {
    mockTryToChangeSourceBufferType.mockReturnValue(false);
    const iface = createMainSourceBufferInterface(SourceBufferType.Video, "video/mp4");
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/webm" });
    sb._emit("updateend");
    await promise;
    expect(iface.codec).toBe("video/mp4");
  });

  it("sets timestampOffset when it differs from current value", async () => {
    const iface = createMainSourceBufferInterface();
    sb.timestampOffset = 0;
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/mp4", timestampOffset: 5 });
    sb._emit("updateend");
    await promise;
    expect(sb.timestampOffset).toBe(5);
  });

  it("does not set timestampOffset when it matches current value", async () => {
    const iface = createMainSourceBufferInterface();
    sb.timestampOffset = 5;
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/mp4", timestampOffset: 5 });
    sb._emit("updateend");
    await promise;
    expect(sb.timestampOffset).toBe(5);
  });

  it("resets appendWindowStart to 0 when appendWindow[0] is undefined and was > 0", async () => {
    const iface = createMainSourceBufferInterface();
    sb.appendWindowStart = 2;
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, {
      codec: "video/mp4",
      appendWindow: [undefined, undefined],
    });
    sb._emit("updateend");
    await promise;
    expect(sb.appendWindowStart).toBe(0);
  });

  it("sets appendWindowStart when it differs", async () => {
    const iface = createMainSourceBufferInterface();
    sb.appendWindowStart = 0;
    sb.appendWindowEnd = Infinity;
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, {
      codec: "video/mp4",
      appendWindow: [3, 10],
    });
    sb._emit("updateend");
    await promise;
    expect(sb.appendWindowStart).toBe(3);
    expect(sb.appendWindowEnd).toBe(10);
  });

  it("pre-updates appendWindowEnd before setting appendWindowStart when needed", async () => {
    const iface = createMainSourceBufferInterface();
    sb.appendWindowStart = 0;
    sb.appendWindowEnd = 5; // new start (8) >= current end (5) → must bump end first
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, {
      codec: "video/mp4",
      appendWindow: [8, 20],
    });
    sb._emit("updateend");
    await promise;
    expect(sb.appendWindowStart).toBe(8);
    expect(sb.appendWindowEnd).toBe(20);
  });

  it("resets appendWindowEnd to Infinity when appendWindow[1] is undefined and was not Infinity", async () => {
    const iface = createMainSourceBufferInterface();
    sb.appendWindowEnd = 30;
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, {
      codec: "video/mp4",
      appendWindow: [undefined, undefined],
    });
    sb._emit("updateend");
    await promise;
    expect(sb.appendWindowEnd).toBe(Infinity);
  });

  it("merges consecutive Push operations with identical parameters", async () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const iface = createMainSourceBufferInterface();
    const data1 = new Uint8Array([1, 2]);
    const data2 = new Uint8Array([3, 4]);

    // First block all appendBuffer operation by implying that the sourcebuffer is updating
    sb.updating = true;
    const p1 = iface.appendBuffer(data1, { codec: "video/mp4", timestampOffset: 0 });
    const p2 = iface.appendBuffer(data2, { codec: "video/mp4", timestampOffset: 0 });

    expect(mockAppendBuffer).not.toHaveBeenCalled();

    // Remove lock
    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;

    // Both segments are merged into a single appendBuffer call
    expect(mockAppendBuffer).toHaveBeenCalledOnce();

    // One updateend resolves both promises
    sb.updating = false;
    sb._emit("updateend");
    const [r1, r2] = await Promise.all([p1, p2]);
    expect(r1).toEqual(r2);
  });

  it("does NOT merge consecutive Push operations with different codecs", async () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const iface = createMainSourceBufferInterface(SourceBufferType.Video, "video/mp4");
    const data1 = new Uint8Array([1]);
    const data2 = new Uint8Array([2]);

    // First block all appendBuffer operation by implying that the sourcebuffer is updating
    sb.updating = true;

    const p1 = iface.appendBuffer(data1, { codec: "video/mp4" });
    const p2 = iface.appendBuffer(data2, { codec: "video/webm" });

    expect(mockAppendBuffer).not.toHaveBeenCalled();

    // Remove lock
    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;

    // First push executed immediately
    expect(mockAppendBuffer).toHaveBeenCalledOnce();

    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;
    await p1;

    // Second push should start now
    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;
    await p2;

    expect(mockAppendBuffer).toHaveBeenCalledTimes(2);
  });

  it("does NOT merge consecutive Push operations with different timestampOffsets", async () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const iface = createMainSourceBufferInterface();
    const data1 = new Uint8Array([1]);
    const data2 = new Uint8Array([2]);

    // First block all appendBuffer operation by implying that the sourcebuffer is updating
    sb.updating = true;

    const p1 = iface.appendBuffer(data1, { codec: "video/mp4", timestampOffset: 0 });
    const p2 = iface.appendBuffer(data2, { codec: "video/mp4", timestampOffset: 5 });

    expect(mockAppendBuffer).not.toHaveBeenCalled();

    // Remove lock
    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;

    expect(mockAppendBuffer).toHaveBeenCalledOnce();

    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;
    await p1;

    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;
    await p2;

    expect(mockAppendBuffer).toHaveBeenCalledTimes(2);
  });

  it("handles ArrayBuffer input in merge path", async () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const iface = createMainSourceBufferInterface();
    const buf1 = new ArrayBuffer(2);
    new Uint8Array(buf1).set([1, 2]);
    const buf2 = new ArrayBuffer(2);
    new Uint8Array(buf2).set([3, 4]);

    // First block all appendBuffer operation by implying that the sourcebuffer is updating
    sb.updating = true;

    const p1 = iface.appendBuffer(buf1, { codec: "video/mp4" });
    const p2 = iface.appendBuffer(buf2, { codec: "video/mp4" });

    expect(mockAppendBuffer).not.toHaveBeenCalled();

    // Remove lock
    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;

    expect(mockAppendBuffer).toHaveBeenCalledOnce();
    sb.updating = false;
    sb._emit("updateend");
    await Promise.all([p1, p2]);
  });

  it("handles DataView input in merge path", async () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const iface = createMainSourceBufferInterface();
    const buf = new ArrayBuffer(4);
    const dv1 = new DataView(buf, 0, 2);
    const dv2 = new DataView(buf, 2, 2);

    // First block all appendBuffer operation by implying that the sourcebuffer is updating
    sb.updating = true;

    const p1 = iface.appendBuffer(dv1, { codec: "video/mp4" });
    const p2 = iface.appendBuffer(dv2, { codec: "video/mp4" });

    // Remove lock
    sb.updating = false;
    sb._emit("updateend");
    sb.updating = true;

    expect(mockAppendBuffer).toHaveBeenCalledOnce();
    sb.updating = false;
    sb._emit("updateend");
    await Promise.all([p1, p2]);
  });

  it("rejects and continues queue when appendBuffer throws synchronously", async () => {
    const iface = createMainSourceBufferInterface();
    vi.spyOn(sb, "appendBuffer").mockImplementationOnce(() => {
      throw new Error("random error");
    });

    const data1 = new Uint8Array([1]);
    const data2 = new Uint8Array([2]);

    const p1 = iface.appendBuffer(data1, { codec: "video/mp4" });
    const p2 = iface.appendBuffer(data2, { codec: "video/mp4" });

    const error = new SourceBufferError("Error", "random error", false);
    await expect(p1).rejects.toMatchObject(error);

    // Queue should move on – second push should now run
    // TODO: For a synchronous error, maybe we shouldn't await an updateend?
    sb._emit("updateend");
    await expect(p2).resolves.toBeDefined();
  });

  it("remove returns a promise that resolves on updateend", async () => {
    const mockRemove = vi.spyOn(MockSourceBuffer.prototype, "remove");
    const iface = createMainSourceBufferInterface();
    const promise = iface.remove(0, 10);
    expect(mockRemove).toHaveBeenCalledWith(0, 10);
    sb._emit("updateend");
    await expect(promise).resolves.toEqual([]);
  });

  it("remove rejects when SourceBuffer.remove throws synchronously", async () => {
    const iface = createMainSourceBufferInterface();
    vi.spyOn(sb, "remove").mockImplementationOnce(() => {
      throw new Error("some remove error");
    });
    const promise = iface.remove(0, 5);
    const error = new SourceBufferError("Error", "some remove error", false);
    await expect(promise).rejects.toMatchObject(error);
  });

  it("queues a remove after a pending push", async () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const mockRemove = vi.spyOn(MockSourceBuffer.prototype, "remove");
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1]);

    const pushPromise = iface.appendBuffer(data, { codec: "video/mp4" });
    const removePromise = iface.remove(0, 5);

    // Only the push should have started
    expect(mockAppendBuffer).toHaveBeenCalledOnce();
    expect(mockRemove).not.toHaveBeenCalled();

    sb._emit("updateend");
    await pushPromise;

    expect(mockRemove).toHaveBeenCalledWith(0, 5);
    sb._emit("updateend");
    await expect(removePromise).resolves.toBeDefined();
  });

  it("getBuffered returns ranges from the underlying SourceBuffer", () => {
    const iface = createMainSourceBufferInterface();
    const result = iface.getBuffered();
    expect(Array.isArray(result)).toBe(true);
  });

  it("getBuffered returns empty array when accessing buffered throws", () => {
    const iface = createMainSourceBufferInterface();
    Object.defineProperty(sb, "buffered", {
      get() {
        throw new Error("buffered unavailable");
      },
      configurable: true,
    });
    const result = iface.getBuffered();
    expect(result).toEqual([]);
  });

  it("abort calls sourceBuffer.abort and rejects pending operations", async () => {
    const mockAbort = vi.spyOn(MockSourceBuffer.prototype, "abort");
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1]);

    const p1 = iface.appendBuffer(data, { codec: "video/mp4" });
    const p2 = iface.appendBuffer(data, { codec: "video/mp4" });

    iface.abort("test-abort");

    await expect(p1).rejects.toBeDefined();
    await expect(p2).rejects.toBeDefined();
    expect(mockAbort).toHaveBeenCalled();
  });

  it("dispose calls sourceBuffer.abort and rejects pending operations", async () => {
    const mockAbort = vi.spyOn(MockSourceBuffer.prototype, "abort");
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1]);
    const p1 = iface.appendBuffer(data, { codec: "video/mp4" });

    iface.dispose("test-dispose");

    await expect(p1).rejects.toBeDefined();
    expect(mockAbort).toHaveBeenCalled();
  });

  it("abort does not throw even if sourceBuffer.abort throws", () => {
    const iface = createMainSourceBufferInterface();
    vi.spyOn(sb, "abort").mockImplementation(() => {
      throw new Error("abort threw");
    });
    expect(() => iface.abort("safe")).not.toThrow();
  });

  it("resolves with empty array when buffered throws InvalidStateError on updateend", async () => {
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/mp4" });

    let callCount = 0;
    Object.defineProperty(sb, "buffered", {
      get() {
        callCount++;
        if (callCount > 1) {
          const e = new Error("InvalidStateError");
          e.name = "InvalidStateError";
          throw e;
        }
        return { length: 0, start: () => 0, end: () => 0 };
      },
      configurable: true,
    });

    sb._emit("updateend");
    await expect(promise).resolves.toEqual([]);
  });

  it("rejects with non-InvalidStateError thrown from buffered on updateend", async () => {
    const iface = createMainSourceBufferInterface();
    const data = new Uint8Array([1]);
    const promise = iface.appendBuffer(data, { codec: "video/mp4" });

    const e = new Error("some buffered error");
    e.name = "SomeOtherError";
    Object.defineProperty(sb, "buffered", {
      get() {
        throw e;
      },
      configurable: true,
    });

    sb._emit("updateend");
    await expect(promise).rejects.toMatchObject(e);
  });

  it("logs error when error event fires with no current operations", () => {
    createMainSourceBufferInterface();
    // No pending operation – just fire error; should not throw
    expect(() => sb._emit("error", new Error("orphan error"))).not.toThrow();
    // TODO: check logs?
  });

  it("does not call appendBuffer again if sourceBuffer.updating is true", () => {
    const mockAppendBuffer = vi.spyOn(MockSourceBuffer.prototype, "appendBuffer");
    const iface = createMainSourceBufferInterface();
    sb.updating = true;
    const data = new Uint8Array([1]);
    iface.appendBuffer(data, { codec: "video/mp4" }).catch(noop);
    // Because updating=true _performNextOperation bails out
    expect(mockAppendBuffer).not.toHaveBeenCalled();
  });
});
