// *****************************************************************************
// Copyright (C) 2020 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

/**
 * This test suite assumes that we run in a NodeJS environment!
 */

import { spawn, execSync, SpawnOptions, ChildProcess, spawnSync } from 'child_process';
import { Readable } from 'stream';
import { join } from 'path';
import { ShellCommandBuilder, CommandLineOptions, ProcessInfo } from './shell-command-builder';
import * as chalk from 'chalk'; // tslint:disable-line:no-implicit-dependencies

export interface TestProcessInfo extends ProcessInfo {
    shell: ChildProcess
}

const isWindows = process.platform === 'win32';
/**
 * Extra debugging info (very verbose).
 */
const _debug: boolean = Boolean(process.env['THEIA_PROCESS_TEST_DEBUG']);
/**
 * On Windows, some shells simply mess up the terminal's output.
 * Enable if you still want to test those.
 */
const _runWeirdShell: true | undefined = Boolean(process.env['THEIA_PROCESS_TEST_WEIRD_SHELL']) || undefined;
/**
 * You might only have issues with a specific shell (`cmd.exe` I am looking at you).
 */
const _onlyTestShell: string | undefined = process.env['THEIA_PROCESS_TEST_ONLY'] || undefined;
/**
 * Only log if environment variable is set.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function debug(...parts: any[]): void {
    if (_debug) {
        console.debug(...parts);
    }
}

const testResources = join(__dirname, '../../src/common/tests');
const spawnOptions: SpawnOptions = {
    // We do our own quoting, don't rely on the one done by NodeJS:
    windowsVerbatimArguments: true,
    stdio: ['pipe', 'pipe', 'pipe'],
};

// Formatting options, used with `scanLines` for debugging.
const stdoutFormat = (prefix: string) => (data: string) =>
    `${chalk.bold(chalk.yellow(`${prefix} STDOUT:`))} ${chalk.bgYellow(chalk.black(data))}`;
const stderrFormat = (prefix: string) => (data: string) =>
    `${chalk.bold(chalk.red(`${prefix} STDERR:`))} ${chalk.bgRed(chalk.white(data))}`;

// Default error scanner
const errorScanner = (handle: ScanLineHandle<void>) => {
    if (
        /^\s*\w+Error:/.test(handle.line) ||
        /^\s*Cannot find /.test(handle.line)
    ) {
        throw new Error(handle.text);
    }
};

// Yarn mangles the PATH and creates some proxy script around node(.exe),
// which messes up our environment, failing the tests.
const hostNodePath =
    process.env['npm_node_execpath'] ||
    process.env['NODE'];
if (!hostNodePath) {
    throw new Error('Could not determine the real node path.');
}

const shellCommandBuilder = new ShellCommandBuilder();
const shellConfigs = [{
    name: 'bash',
    path: isWindows
        ? _runWeirdShell && execShellCommand('where bash.exe')
        : execShellCommand('command -v bash'),
    nodePath:
        isWindows && 'node' // Good enough
}, {
    name: 'wsl',
    path: isWindows
        ? _runWeirdShell && execShellCommand('where wsl.exe')
        : undefined,
    nodePath:
        isWindows && 'node' // Good enough
}, {
    name: 'cmd',
    path: isWindows
        ? execShellCommand('where cmd.exe')
        : undefined,
}, {
    name: 'powershell',
    path: execShellCommand(isWindows
        ? 'where powershell'
        : 'command -v pwsh'),
}];

/* eslint-disable max-len */

// 18d/12m/19y - Ubuntu 16.04:
// Powershell sometimes fails when running as part of an npm lifecycle script.
// See following error:
//
//
//  FailFast:
//  The type initializer for 'Microsoft.PowerShell.ApplicationInsightsTelemetry' threw an exception.
//
//     at System.Environment.FailFast(System.String, System.Exception)
//     at System.Environment.FailFast(System.String, System.Exception)
//     at Microsoft.PowerShell.UnmanagedPSEntry.Start(System.String, System.String[], Int32)
//     at Microsoft.PowerShell.ManagedPSEntry.Main(System.String[])
//
//  Exception details:
//  System.TypeInitializationException: The type initializer for 'Microsoft.PowerShell.ApplicationInsightsTelemetry' threw an exception. ---> System.ArgumentException: Item has already been added. Key in dictionary: 'SPAWN_WRAP_SHIM_ROOT'  Key being added: 'SPAWN_WRAP_SHIM_ROOT'
//     at System.Collections.Hashtable.Insert(Object key, Object nvalue, Boolean add)
//     at System.Environment.ToHashtable(IEnumerable`1 pairs)
//     at System.Environment.GetEnvironmentVariables()
//     at Microsoft.ApplicationInsights.Extensibility.Implementation.Platform.PlatformImplementation..ctor()
//     at Microsoft.ApplicationInsights.Extensibility.Implementation.Platform.PlatformSingleton.get_Current()
//     at Microsoft.ApplicationInsights.Extensibility.Implementation.TelemetryConfigurationFactory.Initialize(TelemetryConfiguration configuration, TelemetryModules modules)
//     at Microsoft.ApplicationInsights.Extensibility.TelemetryConfiguration.get_Active()
//     at Microsoft.PowerShell.ApplicationInsightsTelemetry..cctor()
//     --- End of inner exception stack trace ---
//     at Microsoft.PowerShell.ApplicationInsightsTelemetry.SendPSCoreStartupTelemetry()
//     at Microsoft.PowerShell.ConsoleHost.Start(String bannerText, String helpText, String[] args)
//     at Microsoft.PowerShell.ConsoleShell.Start(String bannerText, String helpText, String[] args)
//     at Microsoft.PowerShell.UnmanagedPSEntry.Start(String consoleFilePath, String[] args, Int32 argc)

/* eslint-enable max-len */

let id = 0;
for (const shellConfig of shellConfigs) {

    let skipMessage: string | undefined;

    if (typeof _onlyTestShell === 'string' && shellConfig.name !== _onlyTestShell) {
        skipMessage = `only testing ${_onlyTestShell}`;

    } else if (!shellConfig.path) {
        // For each shell, skip if we could not find the executable path.
        skipMessage = 'cannot find shell';

    } else {
        // Run a test in the shell to catch runtime issues.
        // CI seems to have issues with some shells depending on the environment...
        try {
            const debugName = `${shellConfig.name}/test`;
            const shellTest = spawnSync(shellConfig.path, {
                input: 'echo abcdefghijkl\n\n',
                timeout: 5_000,
            });
            debug(stdoutFormat(debugName)(shellTest.stdout.toString()));
            debug(stderrFormat(debugName)(shellTest.stderr.toString()));
            if (!/abcdefghijkl/m.test(shellTest.output.toString())) {
                skipMessage = 'wrong test output';
            }
        } catch (error) {
            console.error(error);
            skipMessage = 'error occurred';
        }
    }

    /**
     * If skipMessage is set, we should skip the test and explain why.
     */
    const describeOrSkip = (callback: (this: Mocha.Suite) => void) => {
        const describeMessage = `test ${shellConfig.name} commands`;
        if (typeof skipMessage === 'undefined') {
            describe(describeMessage, callback);
        } else {
            describe.skip(`${describeMessage} - skip: ${skipMessage}`, callback);
        }
    };

    describeOrSkip(function (): void {
        this.timeout(10_000);

        let nodePath: string;
        let cwd: string;
        let submit: string | undefined;
        let processInfo: TestProcessInfo;
        let context: TestCaseContext;

        beforeEach(() => {
            // In WSL, the node path is different than the host one (Windows vs Linux).
            nodePath = shellConfig.nodePath || hostNodePath;

            // On windows, when running bash we need to convert paths from Windows
            // to their mounting point, assuming bash is running within WSL.
            if (isWindows && /bash|wsl/.test(shellConfig.name)) {
                cwd = convertWindowsPath(testResources);
            } else {
                cwd = testResources;
            }

            // When running powershell, it seems like good measure to send `\n` twice...
            if (shellConfig.name === 'powershell') {
                submit = '\n\n';
            }

            // TestContext holds all state for a given test.
            const testContextName = `${shellConfig.name}/${++id}`;
            context = new TestCaseContext(testContextName, submit);
            processInfo = createShell(context, shellConfig.path!);
        });

        afterEach(() => {
            processInfo.shell.kill();
            context.finalize();
        });

        it('use simple environment variables', async () => {
            const envName = 'SIMPLE_NAME';
            const envValue = 'SIMPLE_VALUE';
            await testCommandLine(
                context, processInfo,
                {
                    cwd, args: [nodePath, '-p', `\`[\${process.env['${envName}']}]\``],
                    env: {
                        [envName]: envValue,
                    }
                }, [
                // stderr
                scanLines<void>(context, processInfo.shell.stderr!, errorScanner, stderrFormat(context.name)),
                // stdout
                scanLines<void>(context, processInfo.shell.stdout!, handle => {
                    errorScanner(handle);
                    if (handle.line.includes(`[${envValue}]`)) {
                        handle.resolve();
                    }
                }, stdoutFormat(context.name)),
            ]);
        });

        it('use problematic environment variables', async () => {
            const envName = 'A?B_C | D $PATH';
            const envValue = 'SUCCESS';
            await testCommandLine(
                context, processInfo,
                {
                    cwd, args: [nodePath, '-p', `\`[\${process.env['${envName}']}]\``],
                    env: {
                        [envName]: envValue,
                    }
                }, [
                // stderr
                scanLines<void>(context, processInfo.shell.stderr!, errorScanner, stderrFormat(context.name)),
                // stdout
                scanLines<void>(context, processInfo.shell.stdout!, handle => {
                    errorScanner(handle);
                    if (handle.line.includes(`[${envValue}]`)) {
                        handle.resolve();
                    }
                    if (handle.line.includes('[undefined]')) {
                        handle.reject(new Error(handle.text));
                    }
                }, stdoutFormat(context.name)),
            ]);
        });

        it('command with complex arguments', async () => {
            const left = 'ABC';
            const right = 'DEF';
            await testCommandLine(
                context, processInfo,
                {
                    cwd, args: [nodePath, '-e', `{
                        const left = '${left}';
                        const right = '${right}';
                        console.log(\`[\${left}|\${right}]\`);
                    }`],
                }, [
                // stderr
                scanLines<void>(context, processInfo.shell.stderr!, errorScanner, stderrFormat(context.name)),
                // stdout
                scanLines<void>(context, processInfo.shell.stdout!, handle => {
                    errorScanner(handle);
                    if (handle.line.includes(`[${left}|${right}]`)) {
                        handle.resolve();
                    }
                }, stdoutFormat(context.name)),
            ]);
        });

    });

}

/**
 * Allow `command` to fail and return undefined instead.
 */
function execShellCommand(command: string): string | undefined {
    try {
        // If trimmed output is an empty string, return `undefined` instead:
        return execSync(command).toString().trim() || undefined;
    } catch (error) {
        console.error(command, error);
        return undefined;
    }
}

/**
 * When executing `bash.exe` on Windows, the `C:`, `D:`, etc drives are mounted under `/mnt/<drive>/...`
 */
function convertWindowsPath(windowsPath: string): string {
    return windowsPath
        // Convert back-slashes to forward-slashes
        .replace(/\\/g, '/')
        // Convert drive-letter to usual mounting point in WSL
        .replace(/^[A-Za-z]:\//, s => `/mnt/${s[0].toLowerCase()}/`);
}

/**
 * Display trailing whitespace in a string, such as \r and \n.
 */
function displayWhitespaces(line: string): string {
    return line
        .replace(/\r?\n/, s => s.length === 2 ? '<\\r\\n>\r\n' : '<\\n>\n');
}

/**
 * Actually run `prepareCommandLine`.
 */
async function testCommandLine(
    context: TestCaseContext,
    processInfo: TestProcessInfo,
    options: CommandLineOptions,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    firstOf: Array<Promise<any>>,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any> {
    const commandLine = shellCommandBuilder.buildCommand(processInfo, options);
    debug(`${chalk.bold(chalk.white(`${context.name} STDIN:`))} ${chalk.bgWhite(chalk.black(displayWhitespaces(commandLine)))}`);
    processInfo.shell.stdin!.write(commandLine + context.submit);
    return Promise.race(firstOf);
}

/**
 * Creates a `(Test)ProcessInfo` object by spawning the specified shell.
 */
function createShell(
    context: TestCaseContext,
    shellExecutable: string,
    shellArguments: string[] = []
): TestProcessInfo {
    const shell = spawn(shellExecutable, shellArguments, spawnOptions);
    debug(chalk.magenta(`${chalk.bold(`${context.name} SPAWN:`)} ${shellExecutable} ${shellArguments.join(' ')}`));
    shell.on('close', (code, signal) => debug(chalk.magenta(
        `${chalk.bold(`${context.name} CLOSE:`)} ${shellExecutable} code(${code}) signal(${signal})`
    )));
    return {
        executable: shellExecutable,
        arguments: [],
        shell,
    };
}

/**
 * Fire `callback` once per new detected line.
 */
async function scanLines<T = void>(
    context: TestCaseContext,
    stream: Readable,
    callback: (handle: ScanLineHandle<T>) => void,
    debugFormat = (s: string) => s,
): Promise<T> {
    return new Promise((resolve, reject) => {
        let line = '';
        let text = '';
        stream.on('close', () => {
            debug(debugFormat('<CLOSED>'));
        });
        // The `data` listener will be collected on 'close', which will happen
        // once we kill the process.
        stream.on('data', data => {
            if (context.resolved) {
                return;
            }
            const split = data.toString().split('\n');
            while (!context.resolved && split.length > 1) {
                line += split.shift()! + '\n';
                text += line;
                debug(debugFormat(displayWhitespaces(line)));
                try {
                    callback({
                        resolve: (value: T) => {
                            if (!context.resolved) {
                                context.resolve();
                                resolve(value);
                                debug(chalk.bold(chalk.green(`${context.name} SCANLINES RESOLVED`)));
                            }
                        },
                        reject: (reason?: Error) => {
                            if (!context.resolved) {
                                context.resolve();
                                reject(reason);
                                debug(chalk.bold(chalk.red(`${context.name} SCANLINES REJECTED`)));
                            }
                        },
                        line,
                        text,
                    });
                } catch (error) {
                    debug(chalk.bold(chalk.red(`${context.name} SCANLINES THROWED`)));
                    context.resolve();
                    reject(error);
                    break;
                }
                line = '';
            }
            line += split[0];
        });
    });

}
interface ScanLineHandle<T> {

    /**
     * Finish listening to new events with a return value.
     */
    resolve: (value: T) => void
    /**
     * Finish listening to new events with an error.
     */
    reject: (reason?: Error) => void
    /**
     * Currently parsed line.
     */
    line: string
    /**
     * The whole output buffer, containing all lines.
     */
    text: string

}
/**
 * We need a test case context to help with catching listeners that timed-out,
 * and synchronize multiple listeners so that when one resolves the test case,
 * the others can be put in "sleep mode" until destruction.
 */
class TestCaseContext {

    constructor(
        /**
         * A name associated with this context, to help with debugging.
         */
        readonly name: string,
        /**
         * The characters to send in order to submit a command (mostly
         * powershell is causing issues).
         */
        public submit = '\n',
        /**
         * @internal Current state of the test case, if it is finished or not.
         */
        public resolved = false
    ) { }

    resolve(): void {
        this.resolved = true;
    }

    finalize(): void {
        if (!this.resolved) {
            this.resolve();
            debug(chalk.red(`${chalk.bold(`${this.name} CONTEXT:`)} context wasn't resolved when finalizing, resolving!`));
        }
    }

}
