/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import * as cp from 'child_process';
import { memoize } from '../../../base/common/decorators.js';
import { FileAccess } from '../../../base/common/network.js';
import * as path from '../../../base/common/path.js';
import * as env from '../../../base/common/platform.js';
import { sanitizeProcessEnvironment } from '../../../base/common/processes.js';
import * as pfs from '../../../base/node/pfs.js';
import * as processes from '../../../base/node/processes.js';
import * as nls from '../../../nls.js';
import { DEFAULT_TERMINAL_OSX, IExternalTerminalService, IExternalTerminalSettings, ITerminalForPlatform } from '../common/externalTerminal.js';
import { ITerminalEnvironment } from '../../terminal/common/terminal.js';

const TERMINAL_TITLE = nls.localize('console.title', "VS Code Console");

abstract class ExternalTerminalService {
	public _serviceBrand: undefined;

	async getDefaultTerminalForPlatforms(): Promise<ITerminalForPlatform> {
		return {
			windows: WindowsExternalTerminalService.getDefaultTerminalWindows(),
			linux: await LinuxExternalTerminalService.getDefaultTerminalLinuxReady(),
			osx: DEFAULT_TERMINAL_OSX
		};
	}
}

export class WindowsExternalTerminalService extends ExternalTerminalService implements IExternalTerminalService {
	private static readonly CMD = 'cmd.exe';
	private static _DEFAULT_TERMINAL_WINDOWS: string;

	public openTerminal(configuration: IExternalTerminalSettings, cwd?: string): Promise<void> {
		return this.spawnTerminal(cp, configuration, processes.getWindowsShell(), cwd);
	}

	public spawnTerminal(spawner: typeof cp, configuration: IExternalTerminalSettings, command: string, cwd?: string): Promise<void> {
		const exec = configuration.windowsExec || WindowsExternalTerminalService.getDefaultTerminalWindows();

		// Make the drive letter uppercase on Windows (see #9448)
		if (cwd && cwd[1] === ':') {
			cwd = cwd[0].toUpperCase() + cwd.substr(1);
		}

		// cmder ignores the environment cwd and instead opts to always open in %USERPROFILE%
		// unless otherwise specified
		const basename = path.basename(exec, '.exe').toLowerCase();
		if (basename === 'cmder') {
			spawner.spawn(exec, cwd ? [cwd] : undefined);
			return Promise.resolve(undefined);
		}

		const cmdArgs = ['/c', 'start', '/wait'];
		if (exec.indexOf(' ') >= 0) {
			// The "" argument is the window title. Without this, exec doesn't work when the path
			// contains spaces. #6590
			// Title is Execution Path. #220129
			cmdArgs.push(exec);
		}
		cmdArgs.push(exec);
		// Add starting directory parameter for Windows Terminal (see #90734)
		if (basename === 'wt') {
			cmdArgs.push('-d .');
		}

		return new Promise<void>((c, e) => {
			const env = getSanitizedEnvironment(process);
			const child = spawner.spawn(command, cmdArgs, { cwd, env, detached: true });
			child.on('error', e);
			child.on('exit', () => c());
		});
	}

	public async runInTerminal(title: string, dir: string, args: string[], envVars: ITerminalEnvironment, settings: IExternalTerminalSettings): Promise<number | undefined> {
		const exec = settings.windowsExec || WindowsExternalTerminalService.getDefaultTerminalWindows();
		const wt = await WindowsExternalTerminalService.getWtExePath();

		return new Promise<number | undefined>((resolve, reject) => {

			const title = `"${dir} - ${TERMINAL_TITLE}"`;
			const command = `"${args.join('" "')}" & pause`; // use '|' to only pause on non-zero exit code

			// merge environment variables into a copy of the process.env
			const env = Object.assign({}, getSanitizedEnvironment(process), envVars);

			// delete environment variables that have a null value
			Object.keys(env).filter(v => env[v] === null).forEach(key => delete env[key]);

			const options = {
				cwd: dir,
				env: env,
				windowsVerbatimArguments: true
			};

			let spawnExec: string;
			let cmdArgs: string[];

			if (path.basename(exec, '.exe') === 'wt') {
				// Handle Windows Terminal specially; -d to set the cwd and run a cmd.exe instance
				// inside it
				spawnExec = exec;
				cmdArgs = ['-d', '.', WindowsExternalTerminalService.CMD, '/c', command];
			} else if (wt) {
				// prefer to use the window terminal to spawn if it's available instead
				// of start, since that allows ctrl+c handling (#81322)
				spawnExec = wt;
				cmdArgs = ['-d', '.', exec, '/c', command];
			} else {
				spawnExec = WindowsExternalTerminalService.CMD;
				cmdArgs = ['/c', 'start', title, '/wait', exec, '/c', `"${command}"`];
			}

			const cmd = cp.spawn(spawnExec, cmdArgs, options);

			cmd.on('error', err => {
				reject(improveError(err));
			});

			resolve(undefined);
		});
	}

	public static getDefaultTerminalWindows(): string {
		if (!WindowsExternalTerminalService._DEFAULT_TERMINAL_WINDOWS) {
			const isWoW64 = !!process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432');
			WindowsExternalTerminalService._DEFAULT_TERMINAL_WINDOWS = `${process.env.windir ? process.env.windir : 'C:\\Windows'}\\${isWoW64 ? 'Sysnative' : 'System32'}\\cmd.exe`;
		}
		return WindowsExternalTerminalService._DEFAULT_TERMINAL_WINDOWS;
	}

	@memoize
	private static async getWtExePath() {
		try {
			return await processes.findExecutable('wt');
		} catch {
			return undefined;
		}
	}
}

export class MacExternalTerminalService extends ExternalTerminalService implements IExternalTerminalService {
	private static readonly OSASCRIPT = '/usr/bin/osascript';	// osascript is the AppleScript interpreter on OS X

	public openTerminal(configuration: IExternalTerminalSettings, cwd?: string): Promise<void> {
		return this.spawnTerminal(cp, configuration, cwd);
	}

	public runInTerminal(title: string, dir: string, args: string[], envVars: ITerminalEnvironment, settings: IExternalTerminalSettings): Promise<number | undefined> {

		const terminalApp = settings.osxExec || DEFAULT_TERMINAL_OSX;

		return new Promise<number | undefined>((resolve, reject) => {

			if (terminalApp === DEFAULT_TERMINAL_OSX || terminalApp === 'iTerm.app') {

				// On OS X we launch an AppleScript that creates (or reuses) a Terminal window
				// and then launches the program inside that window.

				const script = terminalApp === DEFAULT_TERMINAL_OSX ? 'TerminalHelper' : 'iTermHelper';
				const scriptpath = FileAccess.asFileUri(`vs/workbench/contrib/externalTerminal/node/${script}.scpt`).fsPath;

				const osaArgs = [
					scriptpath,
					'-t', title || TERMINAL_TITLE,
					'-w', dir,
				];

				for (const a of args) {
					osaArgs.push('-a');
					osaArgs.push(a);
				}

				if (envVars) {
					// merge environment variables into a copy of the process.env
					const env = Object.assign({}, getSanitizedEnvironment(process), envVars);

					for (const key in env) {
						const value = env[key];
						if (value === null) {
							osaArgs.push('-u');
							osaArgs.push(key);
						} else {
							osaArgs.push('-e');
							osaArgs.push(`${key}=${value}`);
						}
					}
				}

				const osa = cp.spawn(MacExternalTerminalService.OSASCRIPT, osaArgs);
				setupSpawnErrorHandling(osa, resolve, reject, terminalApp);
			} else if (terminalApp === 'Ghostty.app') {
				// Ghostty uses CLI flags directly instead of AppleScript like Mac Terminal and iTerm
				// Note: -na is required (not just -a) because we need to spawn a new instance that
				// receives our --args. With just -a, if Ghostty is already running, open will
				// activate the existing instance and ignore --args entirely.
				const env = Object.assign({}, getSanitizedEnvironment(process), envVars);
				const openArgs = ['-na', 'Ghostty.app', '--args'];
				openArgs.push('--working-directory=' + dir);
				openArgs.push('--wait-after-command=true');
				openArgs.push('-e', ...args);

				const cmd = cp.spawn('/usr/bin/open', openArgs, { env });
				setupSpawnErrorHandling(cmd, resolve, reject, terminalApp);
			} else {
				reject(new Error(nls.localize('mac.terminal.type.not.supported', "'{0}' not supported", terminalApp)));
			}
		});
	}

	spawnTerminal(spawner: typeof cp, configuration: IExternalTerminalSettings, cwd?: string): Promise<void> {
		const terminalApp = configuration.osxExec || DEFAULT_TERMINAL_OSX;

		return new Promise<void>((c, e) => {
			const args = ['-a', terminalApp];
			if (cwd) {
				args.push(cwd);
			}
			const env = getSanitizedEnvironment(process);
			const child = spawner.spawn('/usr/bin/open', args, { cwd, env });
			child.on('error', e);
			child.on('exit', () => c());
		});
	}
}

export class LinuxExternalTerminalService extends ExternalTerminalService implements IExternalTerminalService {

	private static readonly WAIT_MESSAGE = nls.localize('press.any.key', "Press any key to continue...");

	public openTerminal(configuration: IExternalTerminalSettings, cwd?: string): Promise<void> {
		return this.spawnTerminal(cp, configuration, cwd);
	}

	public runInTerminal(title: string, dir: string, args: string[], envVars: ITerminalEnvironment, settings: IExternalTerminalSettings): Promise<number | undefined> {

		const execPromise = settings.linuxExec ? Promise.resolve(settings.linuxExec) : LinuxExternalTerminalService.getDefaultTerminalLinuxReady();

		return new Promise<number | undefined>((resolve, reject) => {
			execPromise.then(exec => {
				const basename = path.basename(exec).toLowerCase();
				if (basename === 'ghostty') {
					const ghosttyArgs: string[] = [];
					if (dir) {
						ghosttyArgs.push(`--working-directory=${dir}`);
					}
					ghosttyArgs.push('--wait-after-command=true');
					if (args.length) {
						ghosttyArgs.push('-e', ...args);
					}
					LinuxExternalTerminalService.spawnTerminalWithEnv(exec, ghosttyArgs, dir, envVars, resolve, reject);
					return;
				}

				const termArgs: string[] = [];
				//termArgs.push('--title');
				//termArgs.push(`"${TERMINAL_TITLE}"`);
				if (exec.indexOf('gnome-terminal') >= 0) {
					termArgs.push('-x');
				} else {
					termArgs.push('-e');
				}
				termArgs.push('bash');
				termArgs.push('-c');

				const bashCommand = `${quote(args)}; echo; read -p "${LinuxExternalTerminalService.WAIT_MESSAGE}" -n1;`;
				termArgs.push(`''${bashCommand}''`);	// wrapping argument in two sets of ' because node is so "friendly" that it removes one set...


				LinuxExternalTerminalService.spawnTerminalWithEnv(exec, termArgs, dir, envVars, resolve, reject);
			});
		});
	}

	private static spawnTerminalWithEnv(
		exec: string,
		args: string[],
		dir: string,
		envVars: ITerminalEnvironment,
		resolve: (value: number | PromiseLike<number | undefined> | undefined) => void,
		reject: (reason?: unknown) => void
	): void {
		const env = Object.assign({}, getSanitizedEnvironment(process), envVars);

		// delete environment variables that have a null value
		Object.keys(env).filter(v => env[v] === null).forEach(key => delete env[key]);

		const cmd = cp.spawn(exec, args, { cwd: dir, env });
		setupSpawnErrorHandling(cmd, resolve, reject, exec);
	}

	private static _DEFAULT_TERMINAL_LINUX_READY: Promise<string>;

	public static async getDefaultTerminalLinuxReady(): Promise<string> {
		if (!LinuxExternalTerminalService._DEFAULT_TERMINAL_LINUX_READY) {
			if (!env.isLinux) {
				LinuxExternalTerminalService._DEFAULT_TERMINAL_LINUX_READY = Promise.resolve('xterm');
			} else {
				const isDebian = await pfs.Promises.exists('/etc/debian_version');
				LinuxExternalTerminalService._DEFAULT_TERMINAL_LINUX_READY = new Promise<string>(r => {
					if (isDebian) {
						r('x-terminal-emulator');
					} else if (process.env.DESKTOP_SESSION === 'gnome' || process.env.DESKTOP_SESSION === 'gnome-classic') {
						r('gnome-terminal');
					} else if (process.env.DESKTOP_SESSION === 'kde-plasma') {
						r('konsole');
					} else if (process.env.COLORTERM) {
						r(process.env.COLORTERM);
					} else if (process.env.TERM) {
						r(process.env.TERM);
					} else {
						r('xterm');
					}
				});
			}
		}
		return LinuxExternalTerminalService._DEFAULT_TERMINAL_LINUX_READY;
	}

	spawnTerminal(spawner: typeof cp, configuration: IExternalTerminalSettings, cwd?: string): Promise<void> {
		const execPromise = configuration.linuxExec ? Promise.resolve(configuration.linuxExec) : LinuxExternalTerminalService.getDefaultTerminalLinuxReady();

		return new Promise<void>((c, e) => {
			execPromise.then(exec => {
				const env = getSanitizedEnvironment(process);
				const basename = path.basename(exec).toLowerCase();
				const args = basename === 'ghostty' && cwd ? [`--working-directory=${cwd}`] : [];
				const child = spawner.spawn(exec, args, { cwd, env });
				child.on('error', e);
				child.on('exit', () => c());
			});
		});
	}
}

function getSanitizedEnvironment(process: NodeJS.Process) {
	const env = { ...process.env };
	sanitizeProcessEnvironment(env);
	return env;
}

/**
 * tries to turn OS errors into more meaningful error messages
 */
function improveError(err: Error & { errno?: string; path?: string }): Error {
	if (err.errno === 'ENOENT' && err.path) {
		return new Error(nls.localize('ext.term.app.not.found', "can't find terminal application '{0}'", err.path));
	}
	return err;
}

/**
 * Attaches error handling to a spawned child process for terminal launching.
 */
function setupSpawnErrorHandling(
	cmd: cp.ChildProcess,
	resolve: (value: number | PromiseLike<number | undefined> | undefined) => void,
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	reject: (reason?: any) => void,
	terminalApp: string
): void {
	let stderr = '';
	cmd.on('error', err => {
		reject(improveError(err));
	});
	cmd.stderr?.on('data', (data) => {
		stderr += data.toString();
	});
	cmd.on('exit', (code: number) => {
		if (code === 0) {
			resolve(undefined);
		} else {
			if (stderr) {
				const lines = stderr.split('\n', 1);
				reject(new Error(lines[0]));
			} else {
				reject(new Error(nls.localize('terminal.launch.failed', "Launching '{0}' failed with exit code {1}", terminalApp, code)));
			}
		}
	});
}

/**
 * Quote args if necessary and combine into a space separated string.
 */
function quote(args: string[]): string {
	let r = '';
	for (const a of args) {
		if (a.indexOf(' ') >= 0) {
			r += '"' + a + '"';
		} else {
			r += a;
		}
		r += ' ';
	}
	return r;
}
