import { afterEach, describe, expect, it, vi } from 'vitest';

import {
  BrowserMetadataError,
  browserMediaFileInfo,
  inferBrowserMediaKind,
  inspectBrowserMediaMetadata,
} from '../../../src/media/browser-metadata';

afterEach(() => {
  vi.useRealTimers();
  vi.restoreAllMocks();
});

describe('browser media metadata', () => {
  it('returns file information synchronously and infers useful media kinds', () => {
    const file = new File(['media'], 'lecture.MP4', {
      type: 'application/octet-stream',
      lastModified: 123,
    });

    expect(browserMediaFileInfo(file)).toEqual({
      name: 'lecture.MP4',
      sizeBytes: 5,
      declaredMimeType: 'application/octet-stream',
      inferredMimeType: 'video/mp4',
      lastModified: 123,
    });
    expect(inferBrowserMediaKind(file)).toBe('video');
    expect(
      inferBrowserMediaKind(
        new File([], 'recording.bin', { type: 'audio/ogg; codecs=opus' })
      )
    ).toBe('audio');
    expect(inferBrowserMediaKind(new File([], 'notes.txt'))).toBe('unknown');
  });

  it('loads duration and dimensions through a native video element', async () => {
    const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
    const video = document.createElement('video');
    const load = vi.spyOn(video, 'load').mockImplementation(() => undefined);
    defineNumber(video, 'duration', 2_622.48);
    defineNumber(video, 'videoWidth', 640);
    defineNumber(video, 'videoHeight', 480);
    Object.defineProperty(video, 'audioTracks', {
      configurable: true,
      value: { length: 1 },
    });

    const resultPromise = inspectBrowserMediaMetadata(file, 'blob:lecture', {
      createMediaElement: () => video,
    });
    video.dispatchEvent(new Event('loadedmetadata'));

    await expect(resultPromise).resolves.toEqual({
      source: 'browser',
      file: {
        name: 'lecture.mp4',
        sizeBytes: 5,
        declaredMimeType: 'video/mp4',
        inferredMimeType: 'video/mp4',
        lastModified: file.lastModified,
      },
      kind: 'video',
      durationSeconds: 2_622.48,
      width: 640,
      height: 480,
      hasAudio: true,
      hasVideo: true,
    });
    expect(video.preload).toBe('metadata');
    expect(video).not.toHaveAttribute('src');
    expect(load).toHaveBeenCalledTimes(2);
  });

  it('uses an audio element and leaves unavailable details unknown', async () => {
    const file = new File(['audio'], 'speech.flac', { type: 'audio/flac' });
    const audio = document.createElement('audio');
    vi.spyOn(audio, 'load').mockImplementation(() => undefined);
    defineNumber(audio, 'duration', 12.5);

    const resultPromise = inspectBrowserMediaMetadata(file, 'blob:speech', {
      createMediaElement: (kind) => {
        expect(kind).toBe('audio');
        return audio;
      },
    });
    audio.dispatchEvent(new Event('loadedmetadata'));

    await expect(resultPromise).resolves.toMatchObject({
      source: 'browser',
      kind: 'audio',
      durationSeconds: 12.5,
      hasAudio: true,
      hasVideo: false,
    });
  });

  it('keeps a video-container player viable when dimensions and audio hints are absent', async () => {
    const file = new File(['audio-only'], 'audio-only.mp4', {
      type: 'video/mp4',
    });
    const video = document.createElement('video');
    vi.spyOn(video, 'load').mockImplementation(() => undefined);
    defineNumber(video, 'duration', 8);
    defineNumber(video, 'videoWidth', 0);
    defineNumber(video, 'videoHeight', 0);
    Object.defineProperty(video, 'audioTracks', {
      configurable: true,
      value: undefined,
    });

    const resultPromise = inspectBrowserMediaMetadata(file, 'blob:audio-only', {
      createMediaElement: () => video,
    });
    video.dispatchEvent(new Event('loadedmetadata'));

    await expect(resultPromise).resolves.toMatchObject({
      kind: 'video',
      durationSeconds: 8,
      hasVideo: undefined,
      hasAudio: undefined,
    });
  });

  it('reports load errors without revoking the caller-owned object URL', async () => {
    const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL');
    const video = document.createElement('video');
    vi.spyOn(video, 'load').mockImplementation(() => undefined);
    const resultPromise = inspectBrowserMediaMetadata(
      new File([], 'broken.mp4', { type: 'video/mp4' }),
      'blob:broken',
      { createMediaElement: () => video }
    );

    video.dispatchEvent(new Event('error'));

    await expect(resultPromise).rejects.toMatchObject({
      name: 'BrowserMetadataError',
      code: 'load-error',
    });
    expect(revokeObjectUrl).not.toHaveBeenCalled();
  });

  it('supports cancellation and a bounded metadata timeout', async () => {
    const file = new File([], 'long.mp4', { type: 'video/mp4' });
    const controller = new AbortController();
    const firstVideo = document.createElement('video');
    vi.spyOn(firstVideo, 'load').mockImplementation(() => undefined);
    const cancelled = inspectBrowserMediaMetadata(file, 'blob:cancel', {
      createMediaElement: () => firstVideo,
      signal: controller.signal,
    });
    controller.abort();

    await expect(cancelled).rejects.toMatchObject({
      code: 'aborted',
    });

    vi.useFakeTimers();
    const secondVideo = document.createElement('video');
    vi.spyOn(secondVideo, 'load').mockImplementation(() => undefined);
    const timedOut = inspectBrowserMediaMetadata(file, 'blob:timeout', {
      createMediaElement: () => secondVideo,
      timeoutMilliseconds: 25,
    });
    const timeoutExpectation = expect(timedOut).rejects.toEqual(
      new BrowserMetadataError(
        'timeout',
        'Browser metadata loading exceeded 1 seconds.'
      )
    );
    await vi.advanceTimersByTimeAsync(25);
    await timeoutExpectation;
  });
});

function defineNumber(
  target: HTMLMediaElement,
  property: 'duration' | 'videoHeight' | 'videoWidth',
  value: number
): void {
  Object.defineProperty(target, property, {
    configurable: true,
    value,
  });
}
