import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import { EventEmitter } from 'events';
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { MediaProbeService } from './media-probe.service';

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

type FakeChild = EventEmitter & {
  stdout: EventEmitter;
  stderr: EventEmitter;
  kill: jest.Mock;
};

const fakeChild = (): FakeChild => {
  const child = new EventEmitter() as FakeChild;
  child.stdout = new EventEmitter();
  child.stderr = new EventEmitter();
  child.kill = jest.fn();
  return child;
};

type ProbeInternals = {
  runCommand(command: string, args: string[], timeout: number): Promise<string>;
};

const internals = (service: MediaProbeService): ProbeInternals =>
  service as unknown as ProbeInternals;

describe('MediaProbeService', () => {
  const values: Record<string, unknown> = {};
  const config = { get: jest.fn((key: string) => values[key]) } as unknown as ConfigService;

  beforeEach(() => {
    jest.clearAllMocks();
    Object.keys(values).forEach((key) => delete values[key]);
    (mkdtemp as jest.Mock).mockResolvedValue('/tmp/media-probe');
    (writeFile as jest.Mock).mockResolvedValue(undefined);
    (rm as jest.Mock).mockResolvedValue(undefined);
  });

  it('derives ffprobe beside configured ffmpeg on Windows and Unix', () => {
    values['videoProcessing.ffmpegPath'] = ' C:\\ffmpeg\\ffmpeg.exe ';
    expect(new MediaProbeService(config).getFfprobePath()).toBe('C:\\ffmpeg\\ffprobe.exe');
    values['videoProcessing.ffmpegPath'] = '/usr/local/bin/ffmpeg';
    expect(new MediaProbeService(config).getFfprobePath()).toBe('/usr/local/bin/ffprobe');
    values['videoProcessing.ffprobePath'] = ' custom-probe ';
    expect(new MediaProbeService(config).getFfprobePath()).toBe('custom-probe');
    values['videoProcessing.ffprobePath'] = '';
    values['videoProcessing.ffmpegPath'] = 'wrapper';
    expect(new MediaProbeService(config).getFfprobePath()).toBe('ffprobe');
  });

  it('caches successful and failed command probes', async () => {
    const success = new MediaProbeService(config);
    const run = jest.spyOn(internals(success), 'runCommand').mockResolvedValue('ffmpeg version 7\nother');
    await expect(success.checkFfmpeg()).resolves.toEqual({ path: 'ffmpeg', available: true, version: 'ffmpeg version 7' });
    await success.checkFfmpeg();
    expect(run).toHaveBeenCalledTimes(1);

    const failure = new MediaProbeService(config);
    const fail = jest.spyOn(internals(failure), 'runCommand').mockRejectedValue('not installed');
    await expect(failure.checkFfprobe()).resolves.toEqual({ path: 'ffprobe', available: false, version: '', error: 'unknown command error' });
    await failure.checkFfprobe();
    expect(fail).toHaveBeenCalledTimes(1);
  });

  it('extracts rounded positive durations and rejects unusable output', async () => {
    const service = new MediaProbeService(config);
    jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: true, version: '1' });
    const run = jest.spyOn(internals(service), 'runCommand');
    run.mockResolvedValueOnce('12.6\n').mockResolvedValueOnce('0').mockResolvedValueOnce('NaN').mockRejectedValueOnce(new Error('bad media'));
    await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBe(13);
    await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
    await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
    await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
    jest.spyOn(service, 'checkFfprobe').mockResolvedValue({ path: 'probe', available: false, version: '' });
    await expect(service.extractDurationSeconds('/a.mp4')).resolves.toBeNull();
  });

  it.each([
    ['clip.mp4', undefined, '.mp4'], [undefined, 'video/quicktime', '.mov'],
    [undefined, 'video/webm', '.webm'], [undefined, 'audio/mpeg', '.mp3'],
    [undefined, 'audio/x-m4a', '.m4a'], [undefined, 'audio/x-wav', '.wav'],
    [undefined, 'audio/aac', '.aac'], [undefined, 'audio/ogg', '.ogg'],
    [undefined, 'other', '.media'],
  ])('uses safe buffer extension for %p/%p', async (originalname, mimetype, expected) => {
    const service = new MediaProbeService(config);
    jest.spyOn(service, 'extractDurationSeconds').mockResolvedValue(8);
    await expect(service.extractDurationSecondsFromBuffer(Buffer.from('content'), { originalname, mimetype })).resolves.toBe(8);
    expect(writeFile).toHaveBeenCalledWith(expect.stringContaining(expected), Buffer.from('content'));
    expect(rm).toHaveBeenCalledWith('/tmp/media-probe', { recursive: true, force: true });
  });

  it('handles empty buffers and temporary file failures', async () => {
    const service = new MediaProbeService(config);
    await expect(service.extractDurationSecondsFromBuffer(Buffer.alloc(0))).resolves.toBeNull();
    expect(mkdtemp).not.toHaveBeenCalled();
    (writeFile as jest.Mock).mockRejectedValue(new Error('disk full'));
    await expect(service.extractDurationSecondsFromBuffer(Buffer.from('x'), { originalname: 'bad.mp4' })).resolves.toBeNull();
    expect(rm).toHaveBeenCalled();
  });

  it('runs commands and handles output, process errors, and nonzero exits', async () => {
    const service = new MediaProbeService(config);
    const invoke = internals(service).runCommand.bind(service);

    const success = fakeChild();
    (spawn as jest.Mock).mockReturnValueOnce(success);
    const successPromise = invoke('probe', ['-version'], 100);
    success.stdout.emit('data', Buffer.from('version'));
    success.stderr.emit('data', Buffer.from('warning'));
    success.emit('close', 0);
    await expect(successPromise).resolves.toBe('version');

    const failed = fakeChild();
    (spawn as jest.Mock).mockReturnValueOnce(failed);
    const failedPromise = invoke('probe', [], 100);
    failed.stderr.emit('data', Buffer.from('invalid'));
    failed.emit('close', 2);
    await expect(failedPromise).rejects.toThrow('invalid');

    const errored = fakeChild();
    (spawn as jest.Mock).mockReturnValueOnce(errored);
    const errorPromise = invoke('probe', [], 100);
    errored.emit('error', new Error('spawn error'));
    await expect(errorPromise).rejects.toThrow('spawn error');
  });

  it('kills commands that exceed their timeout', async () => {
    jest.useFakeTimers();
    const service = new MediaProbeService(config);
    const child = fakeChild();
    (spawn as jest.Mock).mockReturnValue(child);
    const promise = internals(service).runCommand('slow', [], 10);
    jest.advanceTimersByTime(11);
    await expect(promise).rejects.toThrow('timed out after 10ms');
    expect(child.kill).toHaveBeenCalledWith('SIGKILL');
    jest.useRealTimers();
  });
});
