/* eslint-disable @typescript-eslint/unbound-method */
/* eslint-disable @typescript-eslint/dot-notation */

import { Buffer } from 'node:buffer';
import { once } from 'node:events';
import process from 'node:process';
import { Readable } from 'node:stream';
import { describe, test, expect, vitest, type MockedFunction, beforeEach, afterEach } from 'vitest';
import { addAudioPlayer, deleteAudioPlayer } from '../src/DataStore';
import { VoiceConnection, VoiceConnectionStatus } from '../src/VoiceConnection';
import type { AudioPlayer } from '../src/audio/AudioPlayer';
import { createAudioPlayer, AudioPlayerStatus, SILENCE_FRAME } from '../src/audio/AudioPlayer';
import { AudioPlayerError } from '../src/audio/AudioPlayerError';
import { AudioResource } from '../src/audio/AudioResource';
import { NoSubscriberBehavior } from '../src/index';

vitest.mock('../src/DataStore', () => ({
	addAudioPlayer: vitest.fn(),
	deleteAudioPlayer: vitest.fn(),
}));

vitest.mock('../src/VoiceConnection', async (importOriginal) => {
	// eslint-disable-next-line @typescript-eslint/consistent-type-imports
	const actual = await importOriginal<typeof import('../src/VoiceConnection')>();
	const VoiceConnection = vitest.fn();
	VoiceConnection.prototype.setSpeaking = vitest.fn();
	VoiceConnection.prototype.dispatchAudio = vitest.fn();
	VoiceConnection.prototype.prepareAudioPacket = vitest.fn();
	return {
		...actual,
		VoiceConnection,
	};
});

vitest.mock('../src/audio/AudioPlayerError', () => ({
	AudioPlayerError: vitest.fn(),
}));

function* silence() {
	while (true) {
		yield Buffer.from([0xf8, 0xff, 0xfe]);
	}
}

function createVoiceConnectionMock() {
	const connection = new VoiceConnection({} as any, {} as any);
	connection.state = {
		status: VoiceConnectionStatus.Signalling,
		adapter: {
			sendPayload: vitest.fn(),
			destroy: vitest.fn(),
		},
	};
	connection.subscribe = vitest.fn((player) => player['subscribe'](connection));
	return connection;
}

async function wait() {
	// eslint-disable-next-line no-promise-executor-return
	return new Promise((resolve) => process.nextTick(resolve));
}

async function started(resource: AudioResource) {
	while (!resource.started) {
		await wait();
	}

	return resource;
}

let player: AudioPlayer | undefined;

beforeEach(() => {
	vitest.resetAllMocks();
});

afterEach(() => {
	player?.stop(true);
});

describe('State transitions', () => {
	test('Starts in Idle state', () => {
		player = createAudioPlayer();
		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
		expect(addAudioPlayer).toBeCalledTimes(0);
		expect(deleteAudioPlayer).toBeCalledTimes(0);
	});

	test('Playing resource with pausing and resuming', async () => {
		// Call AudioResource constructor directly to avoid analyzing pipeline for stream
		const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
		player = createAudioPlayer();
		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);

		// Pause and unpause should not affect the status of an Idle player
		expect(player.pause()).toEqual(false);
		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
		expect(player.unpause()).toEqual(false);
		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
		expect(addAudioPlayer).toBeCalledTimes(0);

		player.play(resource);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		expect(addAudioPlayer).toBeCalledTimes(1);

		// Expect pause() to return true and transition to paused state
		expect(player.pause()).toEqual(true);
		expect(player.state.status).toEqual(AudioPlayerStatus.Paused);

		// further calls to pause() should be unsuccessful
		expect(player.pause()).toEqual(false);
		expect(player.state.status).toEqual(AudioPlayerStatus.Paused);

		// unpause() should transition back to Playing
		expect(player.unpause()).toEqual(true);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);

		// further calls to unpause() should be unsuccessful
		expect(player.unpause()).toEqual(false);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);

		// The audio player should not have been deleted throughout these changes
		expect(deleteAudioPlayer).toBeCalledTimes(0);
	});

	test('Playing to Stopping', async () => {
		const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
		player = createAudioPlayer();

		// stop() shouldn't do anything in Idle state
		expect(player.stop(true)).toEqual(false);
		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);

		player.play(resource);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		expect(addAudioPlayer).toBeCalledTimes(1);
		expect(deleteAudioPlayer).toBeCalledTimes(0);

		expect(player.stop()).toEqual(true);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		expect(addAudioPlayer).toBeCalledTimes(1);
		expect(deleteAudioPlayer).toBeCalledTimes(0);
		expect(resource.silenceRemaining).toEqual(5);
	});

	test('Buffering to Playing', async () => {
		const resource = new AudioResource([], [Readable.from(silence())], null, 5);
		player = createAudioPlayer();

		player.play(resource);
		expect(player.state.status).toEqual(AudioPlayerStatus.Buffering);

		await started(resource);

		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		expect(addAudioPlayer).toHaveBeenCalled();
		expect(deleteAudioPlayer).not.toHaveBeenCalled();
	});

	describe('NoSubscriberBehavior transitions', () => {
		test('NoSubscriberBehavior.Pause', async () => {
			const connection = createVoiceConnectionMock();
			if (connection.state.status !== VoiceConnectionStatus.Signalling) {
				throw new Error('Voice connection should have been Signalling');
			}

			const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
			player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Pause } });
			connection.subscribe(player);

			player.play(resource);
			expect(player.checkPlayable()).toEqual(true);
			player['_stepPrepare']();
			expect(player.state.status).toEqual(AudioPlayerStatus.AutoPaused);

			connection.state = {
				...connection.state,
				status: VoiceConnectionStatus.Ready,
				networking: null as any,
			};

			expect(player.checkPlayable()).toEqual(true);
			player['_stepPrepare']();
			expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		});

		test('NoSubscriberBehavior.Play', async () => {
			const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
			player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } });

			player.play(resource);
			expect(player.checkPlayable()).toEqual(true);
			player['_stepPrepare']();
			expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		});

		test('NoSubscriberBehavior.Stop', async () => {
			const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
			player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Stop } });

			player.play(resource);
			expect(addAudioPlayer).toBeCalledTimes(1);
			expect(player.checkPlayable()).toEqual(true);
			player['_stepPrepare']();
			expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
			expect(deleteAudioPlayer).toBeCalledTimes(1);
		});
	});

	test('Normal playing state', async () => {
		const connection = createVoiceConnectionMock();
		if (connection.state.status !== VoiceConnectionStatus.Signalling) {
			throw new Error('Voice connection should have been Signalling');
		}

		connection.state = {
			...connection.state,
			status: VoiceConnectionStatus.Ready,
			networking: null as any,
		};

		const buffer = Buffer.from([1, 2, 4, 8]);
		const resource = await started(
			new AudioResource([], [Readable.from([buffer, buffer, buffer, buffer, buffer])], null, 5),
		);
		player = createAudioPlayer();
		connection.subscribe(player);

		player.play(resource);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		expect(addAudioPlayer).toBeCalledTimes(1);
		expect(player.checkPlayable()).toEqual(true);

		// Run through a few packet cycles
		for (let index = 1; index <= 5; index++) {
			player['_stepDispatch']();
			expect(connection.dispatchAudio).toHaveBeenCalledTimes(index);

			await wait(); // Wait for the stream

			player['_stepPrepare']();
			expect(connection.prepareAudioPacket).toHaveBeenCalledTimes(index);
			expect(connection.prepareAudioPacket).toHaveBeenLastCalledWith(buffer);
			expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
			if (player.state.status === AudioPlayerStatus.Playing) {
				expect(player.state.playbackDuration).toStrictEqual(index * 20);
			}
		}

		// Expect silence to be played
		player['_stepDispatch']();
		expect(connection.dispatchAudio).toHaveBeenCalledTimes(6);
		await wait();
		player['_stepPrepare']();
		const prepareAudioPacket = connection.prepareAudioPacket as unknown as MockedFunction<
			typeof connection.prepareAudioPacket
		>;
		expect(prepareAudioPacket).toHaveBeenCalledTimes(6);
		expect(prepareAudioPacket.mock.calls[5]![0]).toEqual(silence().next().value);

		player.stop(true);
		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
		expect(connection.setSpeaking).toBeCalledTimes(1);
		expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
		expect(deleteAudioPlayer).toHaveBeenCalledTimes(1);
	});

	test('stop() causes resource to use silence padding frames', async () => {
		const connection = createVoiceConnectionMock();
		if (connection.state.status !== VoiceConnectionStatus.Signalling) {
			throw new Error('Voice connection should have been Signalling');
		}

		connection.state = {
			...connection.state,
			status: VoiceConnectionStatus.Ready,
			networking: null as any,
		};

		const buffer = Buffer.from([1, 2, 4, 8]);
		const resource = await started(
			new AudioResource([], [Readable.from([buffer, buffer, buffer, buffer, buffer])], null, 5),
		);
		player = createAudioPlayer();
		connection.subscribe(player);

		player.play(resource);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		expect(addAudioPlayer).toBeCalledTimes(1);
		expect(player.checkPlayable()).toEqual(true);

		player.stop();

		// Run through a few packet cycles
		for (let index = 1; index <= 5; index++) {
			player['_stepDispatch']();
			expect(connection.dispatchAudio).toHaveBeenCalledTimes(index);

			await wait(); // Wait for the stream

			player['_stepPrepare']();
			expect(connection.prepareAudioPacket).toHaveBeenCalledTimes(index);
			expect(connection.prepareAudioPacket).toHaveBeenLastCalledWith(SILENCE_FRAME);
			expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
			if (player.state.status === AudioPlayerStatus.Playing) {
				expect(player.state.playbackDuration).toStrictEqual(index * 20);
			}
		}

		await wait();
		expect(player.checkPlayable()).toEqual(false);
		const prepareAudioPacket = connection.prepareAudioPacket as unknown as MockedFunction<
			typeof connection.prepareAudioPacket
		>;
		expect(prepareAudioPacket).toHaveBeenCalledTimes(5);

		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
		expect(connection.setSpeaking).toBeCalledTimes(1);
		expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
		expect(deleteAudioPlayer).toHaveBeenCalledTimes(1);
	});

	test('Plays silence 5 times for unreadable stream before quitting', async () => {
		const connection = createVoiceConnectionMock();
		if (connection.state.status !== VoiceConnectionStatus.Signalling) {
			throw new Error('Voice connection should have been Signalling');
		}

		connection.state = {
			...connection.state,
			status: VoiceConnectionStatus.Ready,
			networking: null as any,
		};

		const resource = await started(new AudioResource([], [Readable.from([1])], null, 0));
		resource.playStream.read();
		player = createAudioPlayer({ behaviors: { maxMissedFrames: 5 } });
		connection.subscribe(player);

		player.play(resource);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		expect(addAudioPlayer).toBeCalledTimes(1);
		expect(player.checkPlayable()).toEqual(true);

		const prepareAudioPacket = connection.prepareAudioPacket as unknown as MockedFunction<
			typeof connection.prepareAudioPacket
		>;

		// Run through a few packet cycles
		for (let index = 1; index <= 5; index++) {
			expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
			if (player.state.status !== AudioPlayerStatus.Playing) throw new Error('Error');
			expect(player.state.playbackDuration).toStrictEqual((index - 1) * 20);
			expect(player.state.missedFrames).toEqual(index - 1);
			player['_stepDispatch']();
			expect(connection.dispatchAudio).toHaveBeenCalledTimes(index);
			player['_stepPrepare']();
			expect(prepareAudioPacket).toHaveBeenCalledTimes(index);
			expect(prepareAudioPacket.mock.calls[index - 1]![0]).toEqual(silence().next().value);
		}

		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
		expect(connection.setSpeaking).toBeCalledTimes(1);
		expect(connection.setSpeaking).toHaveBeenLastCalledWith(false);
		expect(deleteAudioPlayer).toHaveBeenCalledTimes(1);
	});

	test('checkPlayable() transitions to Idle for unreadable stream', async () => {
		const resource = await started(new AudioResource([], [Readable.from([1])], null, 0));
		player = createAudioPlayer();
		player.play(resource);
		expect(player.checkPlayable()).toEqual(true);
		expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
		for (let index = 0; index < 3; index++) {
			resource.playStream.read();
			await wait();
		}

		expect(resource.playStream.readableEnded).toEqual(true);
		expect(player.checkPlayable()).toEqual(false);
		expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
	});
});

test('play() throws when playing a resource that has already ended', async () => {
	const resource = await started(new AudioResource([], [Readable.from([1])], null, 5));
	player = createAudioPlayer();
	player.play(resource);
	expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
	for (let index = 0; index < 3; index++) {
		resource.playStream.read();
		await wait();
	}

	expect(resource.playStream.readableEnded).toEqual(true);
	player.stop(true);
	expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
	expect(() => player?.play(resource)).toThrow();
});

test('Propagates errors from streams', async () => {
	const resource = await started(new AudioResource([], [Readable.from(silence())], null, 5));
	player = createAudioPlayer();
	player.play(resource);
	expect(player.state.status).toEqual(AudioPlayerStatus.Playing);
	const error = new Error('AudioPlayer test error');
	process.nextTick(() => resource.playStream.emit('error', error));
	const res = await once(player, 'error');
	const playerError = res[0] as AudioPlayerError;
	expect(playerError).toBeInstanceOf(AudioPlayerError);
	expect(AudioPlayerError).toHaveBeenCalledWith(error, resource);
	expect(player.state.status).toEqual(AudioPlayerStatus.Idle);
});
