import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { spawn } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';

import { FfmpegProcessError } from '../src';
import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg';

jest.mock('node:child_process', () => ({
    spawn: jest.fn(),
}));

const mockSpawn = jest.mocked(spawn);

type MockChildProcess = {
    stdout: {
        destroy: jest.Mock;
        once: jest.Mock;
        off: jest.Mock;
    };
    stderr: {
        destroy: jest.Mock;
        on: jest.Mock;
        resume: jest.Mock;
    };
    once: jest.Mock;
    off: jest.Mock;
    removeAllListeners: jest.Mock;
    kill: jest.Mock;
    killed: boolean;
    exitCode: number | null;
    signalCode: NodeJS.Signals | null;
};

function createMockChildProcess(): MockChildProcess {
    return {
        stdout: {
            destroy: jest.fn(),
            once: jest.fn(),
            off: jest.fn(),
        },
        stderr: {
            destroy: jest.fn(),
            on: jest.fn(),
            resume: jest.fn(),
        },
        once: jest.fn(),
        off: jest.fn(),
        removeAllListeners: jest.fn(),
        kill: jest.fn(),
        killed: false,
        exitCode: null,
        signalCode: null,
    };
}

function mockSpawnReturn(childProcess: MockChildProcess): void {
    mockSpawn.mockReturnValue(childProcess as unknown as ChildProcess);
}

function getProcessHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
    return childProcess.once.mock.calls.find(([event]) => event === eventName)?.[1] as (...args: unknown[]) => void;
}

function getStdoutHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
    return childProcess.stdout.once.mock.calls.find(([event]) => event === eventName)?.[1] as (
        ...args: unknown[]
    ) => void;
}

function getStderrHandler(childProcess: MockChildProcess, eventName: string): (...args: unknown[]) => void {
    return childProcess.stderr.on.mock.calls.find(([event]) => event === eventName)?.[1] as (
        ...args: unknown[]
    ) => void;
}

describe('ffmpeg helpers', () => {
    beforeEach(() => {
        jest.clearAllMocks();
    });

    afterEach(() => {
        jest.useRealTimers();
    });

    it('prefers an explicit executable path', () => {
        expect(resolveFfmpegExecutable({ executablePath: '/custom/ffmpeg' })).toBe('/custom/ffmpeg');
    });

    it('uses the native ffmpeg binary by default', () => {
        expect(resolveFfmpegExecutable()).toBe('ffmpeg');
        expect(resolveFfmpegExecutable({ mode: 'native' })).toBe('ffmpeg');
    });

    it('spawns ffmpeg with the default argument set', () => {
        const childProcess = createMockChildProcess();
        mockSpawnReturn(childProcess);

        startFfmpeg('https://synradiode.stream.laut.fm/synradiode');

        expect(mockSpawn).toHaveBeenCalledWith(
            'ffmpeg',
            [
                '-hide_banner',
                '-loglevel',
                'error',
                '-nostdin',
                '-i',
                'https://synradiode.stream.laut.fm/synradiode',
                '-vn',
                '-f',
                's16le',
                '-ar',
                '48000',
                '-ac',
                '2',
                'pipe:1',
            ],
            { stdio: ['ignore', 'pipe', 'pipe'] },
        );
        expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function));
        expect(childProcess.once).toHaveBeenCalledWith('exit', expect.any(Function));
        expect(childProcess.stdout.once).toHaveBeenCalledWith('readable', expect.any(Function));
        expect(childProcess.stderr.on).toHaveBeenCalledWith('data', expect.any(Function));
        expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1);
    });

    it('rejects readiness when ffmpeg startup emits an error', async () => {
        const childProcess = createMockChildProcess();
        mockSpawnReturn(childProcess);

        const handle = startFfmpeg('tests/audio.mp3');
        const errorHandler = getProcessHandler(childProcess, 'error');

        errorHandler(new Error('spawn ENOENT'));

        await expect(handle.ready).rejects.toThrow(FfmpegProcessError);
        await expect(handle.ready).rejects.toThrow('spawn ENOENT');
    });

    it('includes stderr when ffmpeg exits before producing audio', async () => {
        const childProcess = createMockChildProcess();
        mockSpawnReturn(childProcess);

        const handle = startFfmpeg('tests/audio.mp3');
        const stderrHandler = getStderrHandler(childProcess, 'data');
        const exitHandler = getProcessHandler(childProcess, 'exit');

        stderrHandler('invalid input');
        exitHandler(1, null);

        await expect(handle.ready).rejects.toThrow('invalid input');
    });

    it('keeps the useful tail of long ffmpeg stderr output', async () => {
        const childProcess = createMockChildProcess();
        mockSpawnReturn(childProcess);

        const handle = startFfmpeg('tests/audio.mp3');
        const stderrHandler = getStderrHandler(childProcess, 'data');
        const exitHandler = getProcessHandler(childProcess, 'exit');

        stderrHandler(`${'x'.repeat(5_000)}final diagnostic`);
        exitHandler(1, null);

        await expect(handle.ready).rejects.toThrow('final diagnostic');
    });

    it('keeps readiness resolved when ffmpeg exits after producing audio', async () => {
        const childProcess = createMockChildProcess();
        mockSpawnReturn(childProcess);

        const handle = startFfmpeg('tests/audio.mp3');
        const readableHandler = getStdoutHandler(childProcess, 'readable');
        const exitHandler = getProcessHandler(childProcess, 'exit');

        readableHandler();
        exitHandler(1, null);

        await expect(handle.ready).resolves.toBeUndefined();
    });

    it('uses custom executable and argument overrides when provided', () => {
        const childProcess = createMockChildProcess();
        mockSpawnReturn(childProcess);

        startFfmpeg('tests/audio.mp3', {
            executablePath: '/custom/ffmpeg',
            inputArgs: ['-re'],
            outputArgs: ['-f', 'wav', 'pipe:1'],
        });

        expect(mockSpawn).toHaveBeenCalledWith(
            '/custom/ffmpeg',
            ['-re', '-i', 'tests/audio.mp3', '-f', 'wav', 'pipe:1'],
            { stdio: ['ignore', 'pipe', 'pipe'] },
        );
    });

    it('stops a running child process and schedules a force kill fallback', () => {
        jest.useFakeTimers();
        const childProcess = createMockChildProcess();
        mockSpawnReturn(childProcess);

        const handle = startFfmpeg('tests/audio.mp3');
        handle.stop();

        expect(childProcess.stdout.destroy).toHaveBeenCalledTimes(1);
        expect(childProcess.stderr.destroy).toHaveBeenCalledTimes(1);
        expect(childProcess.removeAllListeners).toHaveBeenCalledTimes(1);
        expect(childProcess.kill).toHaveBeenCalledWith('SIGTERM');

        jest.advanceTimersByTime(2_000);

        expect(childProcess.kill).toHaveBeenNthCalledWith(2, 'SIGKILL');
    });

    it('does not signal a process that already exited', () => {
        const childProcess = createMockChildProcess();
        childProcess.exitCode = 0;
        mockSpawnReturn(childProcess);

        const handle = startFfmpeg('tests/audio.mp3');
        handle.stop();

        expect(childProcess.kill).not.toHaveBeenCalled();
    });
});
