/*
 * Copyright 2017 balena.io
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * TODO:
 * This is convoluted and needlessly complex. It should be simplified and modernized.
 * The environment variable setting and escaping should be greatly simplified by letting {linux|catalina}-sudo handle that.
 * We shouldn't need to write a script to a file and then execute it. We should be able to forwatd the command to the sudo code directly.
 */

import { spawn, exec } from 'child_process';
import { withTmpFile } from 'etcher-sdk/build/tmp';
import { promises as fs } from 'fs';
import { promisify } from 'util';
import * as _ from 'lodash';
import * as os from 'os';
import * as semver from 'semver';

import { sudo as darwinSudo } from './sudo/darwin';
import { sudo as linuxSudo } from './sudo/linux';
import { sudo as winSudo } from './sudo/windows';
import * as errors from './errors';

const execAsync = promisify(exec);

/**
 * @summary The user id of the UNIX "superuser"
 */
const UNIX_SUPERUSER_USER_ID = 0;

export async function isElevated(): Promise<boolean> {
	if (os.platform() === 'win32') {
		// `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10
		// Works even when the "Server" service is disabled
		// See http://stackoverflow.com/a/28268802
		try {
			await execAsync('fltmc');
		} catch (error: any) {
			if (error.code === os.constants.errno.EPERM) {
				return false;
			}
			throw error;
		}
		return true;
	}
	return process.geteuid!() === UNIX_SUPERUSER_USER_ID;
}

/**
 * @summary Check if the current process is running with elevated permissions
 */
export function isElevatedUnixSync(): boolean {
	return process.geteuid!() === UNIX_SUPERUSER_USER_ID;
}

function escapeSh(value: any): string {
	// Make sure it's a string
	// Replace ' -> '\'' (closing quote, escaped quote, opening quote)
	// Surround with quotes
	return `'${String(value).replace(/'/g, "'\\''")}'`;
}

function escapeParamCmd(value: any): string {
	// Make sure it's a string
	// Escape " -> \"
	// Surround with double quotes
	return `"${String(value).replace(/"/g, '\\"')}"`;
}

function setEnvVarSh(value: any, name: string): string {
	return `export ${name}=${escapeSh(value)}`;
}

function setEnvVarCmd(value: any, name: string): string {
	return `set "${name}=${String(value)}"`;
}

// Exported for tests
export function createLaunchScript(
	command: string,
	argv: string[],
	environment: _.Dictionary<string | undefined>,
): string {
	const isWindows = os.platform() === 'win32';
	const lines = [];
	if (isWindows) {
		// Switch to utf8
		lines.push('chcp 65001');
	}
	const [setEnvVarFn, escapeFn] = isWindows
		? [setEnvVarCmd, escapeParamCmd]
		: [setEnvVarSh, escapeSh];
	lines.push(..._.map(environment, setEnvVarFn));
	lines.push([command, ...argv].map(escapeFn).join(' '));
	return lines.join(os.EOL);
}

async function elevateScriptWindows(
	path: string,
	name: string,
	env: any,
): Promise<{ cancelled: false }> {
	// '&' needs to be escaped here (but not when written to a .cmd file)
	const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')].join(' ');
	await winSudo(cmd, name, env);
	return { cancelled: false };
}

async function elevateScriptUnix(
	path: string,
	name: string,
): Promise<{ cancelled: boolean }> {
	const cmd = ['bash', escapeSh(path)].join(' ');
	await linuxSudo(cmd, { name });
	return { cancelled: false };
}

async function elevateScriptCatalina(
	path: string,
): Promise<{ cancelled: boolean }> {
	const cmd = ['bash', escapeSh(path)].join(' ');
	try {
		const { cancelled } = await darwinSudo(cmd);
		return { cancelled };
	} catch (error: any) {
		throw errors.createError({ title: error.stderr });
	}
}

export async function elevateCommand(
	command: string[],
	options: {
		env: _.Dictionary<string | undefined>;
		applicationName: string;
	},
): Promise<{ cancelled: boolean }> {
	if (await isElevated()) {
		spawn(command[0], command.slice(1), {
			env: options.env,
		});
		return { cancelled: false };
	}
	const isWindows = os.platform() === 'win32';
	const launchScript = createLaunchScript(
		command[0],
		command.slice(1),
		options.env,
	);
	return await withTmpFile(
		{
			keepOpen: false,
			prefix: 'balena-etcher-electron-',
			postfix: '.cmd',
		},
		async ({ path }) => {
			await fs.writeFile(path, launchScript);
			if (isWindows) {
				return elevateScriptWindows(path, options.applicationName, options.env);
			}
			if (
				os.platform() === 'darwin' &&
				semver.compare(os.release(), '19.0.0') >= 0
			) {
				// >= macOS Catalina
				return elevateScriptCatalina(path);
			}
			try {
				return elevateScriptUnix(path, options.applicationName);
			} catch (error: any) {
				// We're hardcoding internal error messages declared by `sudo-prompt`.
				// There doesn't seem to be a better way to handle these errors, so
				// for now, we should make sure we double check if the error messages
				// have changed every time we upgrade `sudo-prompt`.
				console.log('error', error);
				if (_.includes(error.message, 'is not in the sudoers file')) {
					throw errors.createUserError({
						title: "Your user doesn't have enough privileges to proceed",
						description:
							'This application requires sudo privileges to be able to write to drives',
					});
				} else if (_.startsWith(error.message, 'Command failed:')) {
					throw errors.createUserError({
						title: 'The elevated process died unexpectedly',
						description: `The process error code was ${error.code}`,
					});
				} else if (error.message === 'User did not grant permission.') {
					return { cancelled: true };
				} else if (error.message === 'No polkit authentication agent found.') {
					throw errors.createUserError({
						title: 'No polkit authentication agent found',
						description:
							'Please install a polkit authentication agent for your desktop environment of choice to continue',
					});
				}
				throw error;
			}
		},
	);
}
