/**
 * @license
 * Copyright 2021 Balena Ltd.
 *
 * 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.
 */

/**
 * This module was inspired by the npm `umount` package:
 *     https://www.npmjs.com/package/umount
 * With some important changes:
 * - Fix "Command Injection" security advisory 1512
 *   https://www.npmjs.com/advisories/1512
 * - Port from CoffeeScript to TypeScript
 * - Convert callbacks to async/await
 */

import * as child_process from 'child_process';
import * as path from 'path';
import { promisify } from 'util';

const execFile = promisify(child_process.execFile);

/**
 * Unmount a device on Linux or macOS. No-op on Windows.
 * @param device Device path, e.g. '/dev/disk2'
 */
export async function umount(device: string): Promise<void> {
	if (process.platform === 'win32') {
		return;
	}
	const { sanitizePath, whichBin } = await import('./which');
	// sanitize user's input (regular expression attacks ?)
	device = sanitizePath(device);
	const cmd: string[] = [];

	if (process.platform === 'darwin') {
		cmd.push('/usr/sbin/diskutil', 'unmountDisk', 'force', device);
	} else {
		// Linux
		const glob = promisify(await import('glob'));
		// '?*' expands a base device path like '/dev/sdb' to an array of paths
		// like '/dev/sdb1', '/dev/sdb2', ..., '/dev/sdb11', ... (partitions)
		// that exist for balenaOS images and are needed as arguments to 'umount'
		// on Linux (otherwise, umount produces an error "/dev/sdb: not mounted")
		const devices = await glob(`${device}?*`, { nodir: true, nonull: true });
		cmd.push(await whichBin('umount'), ...devices);
	}
	if (cmd.length > 1) {
		let stderr = '';
		try {
			const proc = await execFile(cmd[0], cmd.slice(1));
			stderr = proc.stderr;
		} catch (err) {
			const msg = [
				'',
				`Error executing "${cmd.join(' ')}"`,
				stderr || '',
				err.message || '',
			];
			if (process.platform === 'linux') {
				// ignore errors like: "umount: /dev/sdb4: not mounted."
				if (process.env.DEBUG) {
					console.error(msg.join('\n[debug] '));
				}
				return;
			}
			const { ExpectedError } = await import('../errors');
			throw new ExpectedError(msg.join('\n'));
		}
	}
}

/**
 * Check if a device is mounted on Linux or macOS. Always true on Windows.
 * @param device Device path, e.g. '/dev/disk2'
 */
export async function isMounted(device: string): Promise<boolean> {
	if (process.platform === 'win32') {
		return true;
	}
	if (!device) {
		return false;
	}
	const { whichBin } = await import('./which');
	const mountCmd = await whichBin('mount');
	let stdout = '';
	let stderr = '';
	try {
		const proc = await execFile(mountCmd);
		stdout = proc.stdout;
		stderr = proc.stderr;
	} catch (err) {
		const { ExpectedError } = await import('../errors');
		throw new ExpectedError(
			`Error executing "${mountCmd}":\n${stderr}\n${err.message}`,
		);
	}
	const result = (stdout || '')
		.split('\n')
		.some((line) => line.startsWith(device));
	return result;
}

/** Check if `drive` is mounted and, if so, umount it. No-op on Windows. */
export async function safeUmount(drive: string) {
	if (!drive) {
		return;
	}
	if (await isMounted(drive)) {
		await umount(drive);
	}
}

/**
 * Wrapper around the `denymount` package. See:
 * https://github.com/balena-io-modules/denymount
 */
export async function denyMount(
	target: string,
	handler: () => any,
	opts: { autoMountOnSuccess?: boolean; executablePath?: string } = {},
) {
	const denymount = promisify(await import('denymount'));
	if (process.pkg) {
		// when running in a standalone pkg install, the 'denymount'
		// executable is placed on the same folder as process.execPath
		opts.executablePath ||= path.join(
			path.dirname(process.execPath),
			'denymount',
		);
	}
	const dmHandler = async (cb: (err?: Error) => void) => {
		let err: Error | undefined;
		try {
			await handler();
		} catch (e) {
			err = e;
		}
		cb(err);
	};
	await denymount(target, dmHandler, opts);
}
