import process from 'node:process';
import path from 'node:path';
import {taskkill} from 'taskkill';
import {execa} from 'execa';
import {portToPid} from 'pid-port';
import {processExistsMultiple, filterExistingProcesses} from 'process-exists';
import psList from 'ps-list';

// If we check too soon, we're unlikely to see process killed so we essentially wait 3*ALIVE_CHECK_MIN_INTERVAL before the second check while producing unnecessary load.
// Primitive tests show that for a process which just dies on kill on a system without much load, we can usually see the process die in 5 ms.
// Checking once a second creates low enough load to not bother increasing maximum interval further, 1280 as first x to satisfy 2^^x * ALIVE_CHECK_MIN_INTERVAL > 1000.
const ALIVE_CHECK_MIN_INTERVAL = 5;
const ALIVE_CHECK_MAX_INTERVAL = 1280;

const TASKKILL_EXIT_CODE_FOR_PROCESS_FILTERING_SIGTERM = 255;
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC';

const delay = ms => new Promise(resolve => {
	setTimeout(resolve, ms);
});

const missingBinaryError = async (command, arguments_) => {
	try {
		return await execa(command, arguments_);
	} catch (error) {
		if (error.code === 'ENOENT') {
			const newError = new Error(`\`${command}\` doesn't seem to be installed and is required by fkill`);
			newError.sourceError = error;
			throw newError;
		}

		throw error;
	}
};

const windowsKill = async (input, options) => {
	const killOptions = {
		force: options.force,
		tree: options.tree === undefined ? true : options.tree,
	};

	const attemptKill = async target => {
		try {
			return await taskkill(target, killOptions);
		} catch (error) {
			if (error.exitCode === TASKKILL_EXIT_CODE_FOR_PROCESS_FILTERING_SIGTERM && !options.force) {
				return;
			}

			throw error;
		}
	};

	// If it's a PID, proceed normally
	if (typeof input === 'number') {
		return attemptKill(input);
	}

	// Normalize PATHEXT to uppercase for consistent comparison
	// Filter out empty entries and trim whitespace to handle malformed PATHEXT
	const pathext = (process.env.PATHEXT || DEFAULT_PATHEXT)
		.toUpperCase()
		.split(';')
		.map(ext => ext.trim())
		.filter(Boolean);

	const inputExtension = path.extname(input).toUpperCase();

	// If input has a known executable extension, use it directly
	if (inputExtension && pathext.includes(inputExtension)) {
		return attemptKill(input);
	}

	// Guard against empty input
	if (!input) {
		throw new Error('Process name cannot be empty');
	}

	// No executable extension - try to find the actual process name
	try {
		const {stdout} = await execa('tasklist', ['/fo', 'csv', '/nh']);
		const inputLower = input.toLowerCase();

		// Parse CSV output to get process names
		const processes = stdout.trim().split('\n')
			.filter(Boolean)
			.map(line => {
				const match = line.match(/^"([^"]+)"/);
				return match ? {imageName: match[1]} : null;
			})
			.filter(Boolean);

		// Find processes matching: input + dot + extension
		const matches = processes.filter(proc =>
			proc.imageName.toLowerCase().startsWith(inputLower + '.'));

		if (matches.length > 0) {
			// Find the best match by PATHEXT priority
			let bestMatch = matches[0];
			let bestPriority = pathext.indexOf(path.extname(bestMatch.imageName).toUpperCase());

			for (const proc of matches.slice(1)) {
				const priority = pathext.indexOf(path.extname(proc.imageName).toUpperCase());

				// Prefer processes with extensions in PATHEXT, prioritized by order
				if (priority !== -1 && (bestPriority === -1 || priority < bestPriority)) {
					bestMatch = proc;
					bestPriority = priority;
				}
			}

			return attemptKill(bestMatch.imageName);
		}
	} catch {
		// If tasklist fails, fall through to .exe fallback
	}

	// Fallback: try with .exe extension
	return attemptKill(`${input}.exe`);
};

const macosKill = (input, options) => {
	const killByName = typeof input === 'string';
	const command = killByName ? 'pkill' : 'kill';
	const arguments_ = [input];

	if (killByName && options.ignoreCase) {
		arguments_.unshift('-i');
	}

	if (killByName) {
		arguments_.unshift('-x');
	}

	// Must be last.
	if (options.force) {
		if (killByName) {
			arguments_.unshift('-KILL');
		} else {
			arguments_.unshift('-9');
		}
	}

	return missingBinaryError(command, arguments_);
};

const defaultKill = (input, options) => {
	const killByName = typeof input === 'string';
	const command = killByName ? 'killall' : 'kill';
	const arguments_ = [input];

	if (options.force) {
		arguments_.unshift('-9');
	}

	if (killByName && options.ignoreCase) {
		arguments_.unshift('-I');
	}

	return missingBinaryError(command, arguments_);
};

const kill = (() => {
	if (process.platform === 'darwin') {
		return macosKill;
	}

	if (process.platform === 'win32') {
		return windowsKill;
	}

	return defaultKill;
})();

const parseInput = async input => {
	if (typeof input === 'string' && input[0] === ':') {
		return portToPid({port: Number.parseInt(input.slice(1), 10), host: '*'});
	}

	return input;
};

const getCurrentProcessParentsPID = processes => {
	const processMap = new Map(processes.map(ps => [ps.pid, ps.ppid]));
	const pids = [];
	let currentId = process.pid;
	while (currentId) {
		pids.push(currentId);
		currentId = processMap.get(currentId);
	}

	return pids;
};

const waitForProcessExit = async (parsedInputsMap, timeout, silent) => {
	const endTime = Date.now() + timeout;
	let interval = ALIVE_CHECK_MIN_INTERVAL;
	if (interval > timeout) {
		interval = timeout;
	}

	let alive = [...parsedInputsMap.values()];

	do {
		await delay(interval); // eslint-disable-line no-await-in-loop

		alive = await filterExistingProcesses(alive); // eslint-disable-line no-await-in-loop

		interval *= 2;
		if (interval > ALIVE_CHECK_MAX_INTERVAL) {
			interval = ALIVE_CHECK_MAX_INTERVAL;
		}
	} while (Date.now() < endTime && alive.length > 0);

	if (alive.length > 0 && !silent) {
		const waitErrors = [];
		for (const parsedInput of alive) {
			// Find the original input that matches this parsedInput
			const originalInput = [...parsedInputsMap.entries()].find(([, value]) => value === parsedInput)?.[0] ?? parsedInput;
			waitErrors.push(`Process ${originalInput} did not exit within ${timeout}ms`);
		}

		throw new AggregateError(waitErrors, 'Processes did not exit within timeout');
	}
};

const killWithLimits = async (input, options) => {
	input = await parseInput(input);

	if (input === process.pid) {
		return;
	}

	if (input === 'node' || input === 'node.exe') {
		const processes = await psList();
		const pids = getCurrentProcessParentsPID(processes);
		await Promise.all(processes.map(async ps => {
			if ((ps.name === 'node' || ps.name === 'node.exe') && !pids.includes(ps.pid)) {
				await kill(ps.pid, options);
			}
		}));
		return;
	}

	await kill(input, options);
};

export default async function fkill(inputs, options = {}) {
	inputs = [inputs].flat();

	// Parse ports to PIDs upfront for correct existence checking.
	const parsedInputsMap = new Map(await Promise.all(inputs.map(async input => {
		try {
			return [input, await parseInput(input)];
		} catch {
			// If parsing fails (e.g., port has no process), keep original input.
			return [input, input];
		}
	})));

	const exists = await processExistsMultiple([...parsedInputsMap.values()]);

	const errors = [];

	const handleKill = async input => {
		const parsedInput = parsedInputsMap.get(input);

		try {
			await killWithLimits(input, options);
		} catch (error) {
			if (!exists.get(parsedInput)) {
				errors.push(`Killing process ${input} failed: Process doesn't exist`);
				return;
			}

			errors.push(`Killing process ${input} failed: ${error.message.replace(/.*\n/, '').replace(/kill: \d+: /, '').trim()}`);
		}
	};

	await Promise.all(inputs.map(input => handleKill(input)));

	if (errors.length > 0 && !options.silent) {
		throw new AggregateError(errors, 'Failed to kill processes');
	}

	if (options.forceAfterTimeout !== undefined && !options.force) {
		const endTime = Date.now() + options.forceAfterTimeout;
		let interval = ALIVE_CHECK_MIN_INTERVAL;
		if (interval > options.forceAfterTimeout) {
			interval = options.forceAfterTimeout;
		}

		let alive = [...parsedInputsMap.values()];

		do {
			await delay(interval); // eslint-disable-line no-await-in-loop

			alive = await filterExistingProcesses(alive); // eslint-disable-line no-await-in-loop

			interval *= 2;
			if (interval > ALIVE_CHECK_MAX_INTERVAL) {
				interval = ALIVE_CHECK_MAX_INTERVAL;
			}
		} while (Date.now() < endTime && alive.length > 0);

		if (alive.length > 0) {
			await Promise.all(alive.map(async parsedInput => {
				try {
					await killWithLimits(parsedInput, {...options, force: true});
				} catch {
					// It's hard to filter does-not-exist kind of errors, so we ignore all of them here.
					// All meaningful errors should have been thrown before this operation takes place.
				}
			}));
		}
	}

	if (options.waitForExit !== undefined && options.waitForExit > 0) {
		await waitForProcessExit(parsedInputsMap, options.waitForExit, options.silent);
	}
}
