import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises';
import { VideoProcessingService } from './video-processing.service';

jest.mock('child_process', () => ({ spawn: jest.fn() }));
jest.mock('fs/promises', () => ({
  mkdir: jest.fn(), mkdtemp: jest.fn(), readFile: jest.fn(), readdir: jest.fn(), rm: jest.fn(), writeFile: jest.fn(),
}));

type Internals = {
  ensureFfmpegAvailable(): Promise<boolean>;
  runFfmpeg(args: string[]): Promise<void>;
  runCommand(command: string, args: string[]): Promise<string>;
  generateHlsPackage(input: string, output: string): Promise<any>;
  generateSingleRenditionHlsPackage(input: string, output: string): Promise<any>;
  buildAdaptiveHlsFilterComplex(renditions: Array<{ width: number }>): string;
  buildHlsRenditions(width: number): Array<Record<string, number>>;
  resolveHlsRendition(width: number): Record<string, number>;
  probeVideo(path: string): Promise<{ hasAudio: boolean; width: number }>;
  readFilesRecursively(base: string, current?: string): Promise<any[]>;
  resolveStreamingContentType(name: string): string;
  resolveInputExtension(file: any): string;
  buildOptimizedFileName(name?: string): string;
  buildVideoFilter(): string;
  buildThumbnailFilter(): string;
  getFfprobePath(): string;
};

const internals = (service: VideoProcessingService): Internals => service as unknown as Internals;

type FakeChild = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter };
const fakeChild = (): FakeChild => {
  const child = new EventEmitter() as FakeChild;
  child.stdout = new EventEmitter();
  child.stderr = new EventEmitter();
  return child;
};

describe('VideoProcessingService', () => {
  const values: Record<string, unknown> = {};
  const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;
  const file = { originalname: 'concert.mov', mimetype: 'video/quicktime', size: 5, buffer: Buffer.from('video') };

  beforeEach(() => {
    jest.restoreAllMocks();
    jest.clearAllMocks();
    Object.keys(values).forEach((key) => delete values[key]);
    (mkdtemp as jest.Mock).mockResolvedValue('/tmp/video-work');
    (mkdir as jest.Mock).mockResolvedValue(undefined);
    (writeFile as jest.Mock).mockResolvedValue(undefined);
    (readFile as jest.Mock).mockImplementation(async (path: string) => Buffer.from(path.includes('thumbnail') ? 'thumb' : 'optimized'));
    (rm as jest.Mock).mockResolvedValue(undefined);
  });

  it('returns uploads untouched when processing is disabled or ffmpeg unavailable', async () => {
    const disabled = new VideoProcessingService(config);
    await expect(disabled.optimizeForPlayback(file)).resolves.toEqual({ file });
    expect(mkdtemp).not.toHaveBeenCalled();

    values['videoProcessing.enabled'] = true;
    const unavailable = new VideoProcessingService(config);
    jest.spyOn(internals(unavailable), 'ensureFfmpegAvailable').mockResolvedValue(false);
    await expect(unavailable.optimizeForPlayback(file)).resolves.toEqual({ file });
  });

  it('optimizes video and returns optional thumbnail and HLS assets', async () => {
    Object.assign(values, {
      'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true,
      'videoProcessing.generateHls': true, 'videoProcessing.maxWidth': 1080,
      'videoProcessing.thumbnailWidth': 600, 'videoProcessing.maxFps': 24,
      'videoProcessing.crf': 25, 'videoProcessing.preset': ' fast ',
      'videoProcessing.audioBitrateKbps': 96,
    });
    const service = new VideoProcessingService(config);
    jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
    const ffmpeg = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
    const hls = { playlistRelativePath: 'master.m3u8', files: [{ relativePath: 'master.m3u8', buffer: Buffer.from('hls'), contentType: 'application/vnd.apple.mpegurl' }] };
    jest.spyOn(internals(service), 'generateHlsPackage').mockResolvedValue(hls);

    const result = await service.optimizeForPlayback(file);
    expect(result.file).toEqual(expect.objectContaining({ mimetype: 'video/mp4', originalname: 'concert-optimized.mp4', size: 9 }));
    expect(result.generatedThumbnail).toEqual({ buffer: Buffer.from('thumb'), extension: '.jpg', contentType: 'image/jpeg' });
    expect(result.generatedHls).toEqual(hls);
    expect(ffmpeg).toHaveBeenCalledTimes(2);
    expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining(['-crf', '25', '-preset', 'fast', '-b:a', '96k']));
    expect(ffmpeg.mock.calls[0][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(1080,iw)')]));
    expect(ffmpeg.mock.calls[1][0]).toEqual(expect.arrayContaining([expect.stringContaining('min(600,iw)')]));
    expect(rm).toHaveBeenCalledWith('/tmp/video-work', { recursive: true, force: true });
  });

  it('keeps optimized output when thumbnail and HLS generation fail', async () => {
    Object.assign(values, { 'videoProcessing.enabled': true, 'videoProcessing.generateThumbnails': true, 'videoProcessing.generateHls': true });
    const service = new VideoProcessingService(config);
    jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
    jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValueOnce(undefined).mockRejectedValueOnce('thumbnail failed');
    jest.spyOn(internals(service), 'generateHlsPackage').mockRejectedValue('HLS failed');
    const result = await service.optimizeForPlayback(file);
    expect(result.file.mimetype).toBe('video/mp4');
    expect(result.generatedThumbnail).toBeUndefined();
    expect(result.generatedHls).toBeUndefined();
  });

  it('falls back to original and cleans up after optimization errors', async () => {
    values['videoProcessing.enabled'] = true;
    const service = new VideoProcessingService(config);
    jest.spyOn(internals(service), 'ensureFfmpegAvailable').mockResolvedValue(true);
    jest.spyOn(internals(service), 'runFfmpeg').mockRejectedValue(new Error('codec failed'));
    await expect(service.optimizeForPlayback(file)).resolves.toEqual({ file });
    expect(rm).toHaveBeenCalled();
  });

  it('supports configured and inferred executable paths and upload extensions', () => {
    const service = new VideoProcessingService(config);
    values['videoProcessing.ffmpegPath'] = 'C:\\bin\\ffmpeg.exe';
    expect(internals(service).getFfprobePath()).toBe('C:\\bin\\ffprobe.exe');
    values['videoProcessing.ffprobePath'] = ' custom-ffprobe ';
    expect(internals(service).getFfprobePath()).toBe('custom-ffprobe');
    values['videoProcessing.ffprobePath'] = '';
    values['videoProcessing.ffmpegPath'] = 'wrapper';
    expect(internals(service).getFfprobePath()).toBe('ffprobe');
    expect(internals(service).buildOptimizedFileName()).toBe('video-optimized.mp4');
    expect(internals(service).buildOptimizedFileName('a.b.webm')).toBe('a.b-optimized.mp4');
    expect(internals(service).resolveInputExtension({ originalname: 'A.MOV' })).toBe('.mov');
    expect(internals(service).resolveInputExtension({ mimetype: 'video/webm' })).toBe('.webm');
    expect(internals(service).resolveInputExtension({ mimetype: 'video/x-matroska' })).toBe('.mkv');
    expect(internals(service).resolveInputExtension({ mimetype: 'video/x-msvideo' })).toBe('.avi');
    expect(internals(service).resolveInputExtension({ mimetype: 'unknown' })).toBe('.mp4');
  });

  it('builds ordered adaptive rendition profiles and filters', () => {
    values['videoProcessing.maxWidth'] = 1280;
    values['videoProcessing.maxFps'] = 30;
    const service = new VideoProcessingService(config);
    expect(internals(service).buildHlsRenditions(200).map((item) => item.width)).toEqual([240]);
    expect(internals(service).buildHlsRenditions(720).map((item) => item.width)).toEqual([480, 720]);
    expect(internals(service).buildHlsRenditions(1920).map((item) => item.width)).toEqual([480, 720, 1280]);
    expect(internals(service).resolveHlsRendition(480)).toEqual(expect.objectContaining({ videoBitrateKbps: 800, audioBitrateKbps: 96 }));
    expect(internals(service).resolveHlsRendition(720)).toEqual(expect.objectContaining({ videoBitrateKbps: 1600 }));
    expect(internals(service).resolveHlsRendition(1080)).toEqual(expect.objectContaining({ videoBitrateKbps: 2800 }));
    expect(internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }])).toContain('[v0out]');
    const adaptive = internals(service).buildAdaptiveHlsFilterComplex([{ width: 480 }, { width: 720 }]);
    expect(adaptive).toContain('split=2[v0][v1]');
    expect(adaptive).toContain('fps=30');
    expect(internals(service).buildVideoFilter()).toContain('format=yuv420p');
    expect(internals(service).buildThumbnailFilter()).toContain('force_original_aspect_ratio=decrease');
  });

  it('generates adaptive HLS with audio and gathers generated files', async () => {
    Object.assign(values, { 'videoProcessing.maxWidth': 1280, 'videoProcessing.maxFps': 25, 'videoProcessing.hlsSegmentDurationSeconds': 6 });
    const service = new VideoProcessingService(config);
    jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 1280, hasAudio: true });
    const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
    const generated = [{ relativePath: 'master.m3u8', buffer: Buffer.from('x'), contentType: 'application/vnd.apple.mpegurl' }];
    jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue(generated);
    await expect(internals(service).generateHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'master.m3u8', files: generated });
    const args = run.mock.calls[0][0];
    expect(args).toEqual(expect.arrayContaining(['-filter_complex', expect.stringContaining('split=3'), '-var_stream_map', 'v:0,a:0 v:1,a:1 v:2,a:2']));
    expect(args.filter((arg) => arg === '0:a:0')).toHaveLength(3);
  });

  it('generates adaptive HLS without audio mappings', async () => {
    const service = new VideoProcessingService(config);
    jest.spyOn(internals(service), 'probeVideo').mockResolvedValue({ width: 720, hasAudio: false });
    const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
    jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]);
    await internals(service).generateHlsPackage('/in.mp4', '/hls');
    expect(run.mock.calls[0][0]).toEqual(expect.arrayContaining(['-var_stream_map', 'v:0 v:1']));
    expect(run.mock.calls[0][0]).not.toContain('0:a:0');
  });

  it('falls back to a single HLS stream when probing fails or only one rendition fits', async () => {
    const failedProbe = new VideoProcessingService(config);
    jest.spyOn(internals(failedProbe), 'probeVideo').mockRejectedValue(new Error('probe failed'));
    const single = jest.spyOn(internals(failedProbe), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] });
    await internals(failedProbe).generateHlsPackage('/in.mp4', '/hls');
    expect(single).toHaveBeenCalled();

    const narrow = new VideoProcessingService(config);
    jest.spyOn(internals(narrow), 'probeVideo').mockResolvedValue({ width: 200, hasAudio: false });
    const narrowSingle = jest.spyOn(internals(narrow), 'generateSingleRenditionHlsPackage').mockResolvedValue({ playlistRelativePath: 'playlist.m3u8', files: [] });
    await internals(narrow).generateHlsPackage('/in.mp4', '/hls');
    expect(narrowSingle).toHaveBeenCalled();
  });

  it('generates a single fMP4 rendition with default settings', async () => {
    const service = new VideoProcessingService(config);
    const run = jest.spyOn(internals(service), 'runFfmpeg').mockResolvedValue(undefined);
    jest.spyOn(internals(service), 'readFilesRecursively').mockResolvedValue([]);
    await expect(internals(service).generateSingleRenditionHlsPackage('/in.mp4', '/hls')).resolves.toEqual({ playlistRelativePath: 'playlist.m3u8', files: [] });
    expect(run).toHaveBeenCalledWith(expect.arrayContaining(['-hls_time', '4', '-hls_segment_type', 'fmp4']));
  });

  it('parses video probe output and rejects missing widths', async () => {
    const service = new VideoProcessingService(config);
    const run = jest.spyOn(internals(service), 'runCommand');
    run.mockResolvedValueOnce(JSON.stringify({ streams: [{ codec_type: 'video', width: 1920 }, { codec_type: 'audio' }] }));
    await expect(internals(service).probeVideo('/in.mp4')).resolves.toEqual({ width: 1920, hasAudio: true });
    run.mockResolvedValueOnce('{}');
    await expect(internals(service).probeVideo('/bad.mp4')).rejects.toThrow('video width');
  });

  it('reads nested HLS output in stable order and assigns content types', async () => {
    const service = new VideoProcessingService(config);
    (readdir as jest.Mock).mockImplementation(async (path: string) =>
      path === '/hls'
        ? [
            { name: 'stream_0', isDirectory: () => true },
            { name: 'master.m3u8', isDirectory: () => false },
          ]
        : [
            { name: 'segment.ts', isDirectory: () => false },
            { name: 'init.mp4', isDirectory: () => false },
            { name: 'segment.m4s', isDirectory: () => false },
            { name: 'unknown.bin', isDirectory: () => false },
          ],
    );
    const files = await internals(service).readFilesRecursively('/hls');
    expect(files.map((item) => [item.relativePath, item.contentType])).toEqual([
      ['master.m3u8', 'application/vnd.apple.mpegurl'],
      ['stream_0/init.mp4', 'video/mp4'],
      ['stream_0/segment.m4s', 'video/iso.segment'],
      ['stream_0/segment.ts', 'video/mp2t'],
      ['stream_0/unknown.bin', 'application/octet-stream'],
    ]);
  });

  it('probes ffmpeg only once and caches success or failure', async () => {
    const success = new VideoProcessingService(config);
    const run = jest.spyOn(internals(success), 'runFfmpeg').mockResolvedValue(undefined);
    await expect(internals(success).ensureFfmpegAvailable()).resolves.toBe(true);
    await internals(success).ensureFfmpegAvailable();
    expect(run).toHaveBeenCalledTimes(1);

    const failure = new VideoProcessingService(config);
    const fail = jest.spyOn(internals(failure), 'runFfmpeg').mockRejectedValue('missing');
    await expect(internals(failure).ensureFfmpegAvailable()).resolves.toBe(false);
    await internals(failure).ensureFfmpegAvailable();
    expect(fail).toHaveBeenCalledTimes(1);
  });

  it('runs child commands and propagates stderr, generic exit, and spawn errors', async () => {
    const service = new VideoProcessingService(config);
    const ok = fakeChild();
    (spawn as jest.Mock).mockReturnValueOnce(ok);
    const okPromise = internals(service).runCommand('ffmpeg', ['-version']);
    ok.stdout.emit('data', Buffer.from('version'));
    ok.emit('close', 0);
    await expect(okPromise).resolves.toBe('version');

    const failed = fakeChild();
    (spawn as jest.Mock).mockReturnValueOnce(failed);
    const failedPromise = internals(service).runCommand('ffmpeg', []);
    failed.stderr.emit('data', Buffer.from('invalid codec'));
    failed.emit('close', 1);
    await expect(failedPromise).rejects.toThrow('invalid codec');

    const generic = fakeChild();
    (spawn as jest.Mock).mockReturnValueOnce(generic);
    const genericPromise = internals(service).runCommand('ffmpeg', []);
    generic.emit('close', null);
    await expect(genericPromise).rejects.toThrow('exited with code unknown');

    const errored = fakeChild();
    (spawn as jest.Mock).mockReturnValueOnce(errored);
    const errorPromise = internals(service).runCommand('ffmpeg', []);
    errored.emit('error', new Error('spawn failed'));
    await expect(errorPromise).rejects.toThrow('spawn failed');
  });
});
