// Copyright 2016-2020, Pulumi Corporation.
//
// 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.

import execa from "execa";
import * as fs from "fs";
import got from "got";
import * as os from "os";
import * as path from "path";
import * as semver from "semver";
import * as tmp from "tmp";
import { version as DEFAULT_VERSION } from "../version";
import { minimumVersion } from "./minimumVersion";
import { createCommandError } from "./errors";

const SKIP_VERSION_CHECK_VAR = "PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK";

/** @internal */
export class CommandResult {
    stdout: string;
    stderr: string;
    code: number;
    err?: Error;
    constructor(stdout: string, stderr: string, code: number, err?: Error) {
        this.stdout = stdout;
        this.stderr = stderr;
        this.code = code;
        this.err = err;
    }
    toString(): string {
        let errStr = "";
        if (this.err) {
            errStr = this.err.toString();
        }
        return `code: ${this.code}\n stdout: ${this.stdout}\n stderr: ${this.stderr}\n err?: ${errStr}\n`;
    }
}

export interface PulumiCommandOptions {
    /**
     * The version of the CLI to use. Defaults to the CLI version matching the SDK version.
     */
    version?: semver.SemVer;
    /**
     * The directory to install the CLI in or where to look for an existing
     * installation. Defaults to $HOME/.pulumi/versions/$VERSION.
     */
    root?: string;
    /**
     * Skips the minimum CLI version check, see `PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK`.
     */
    skipVersionCheck?: boolean;
}

export class PulumiCommand {
    private constructor(readonly command: string, readonly version: semver.SemVer | null) {}

    /**
     * Get a new Pulumi instance that uses the installation in `opts.root`.
     * Defaults to using the pulumi binary found in $PATH if no installation
     * root is specified.  If `opts.version` is specified, it validates that
     * the CLI is compatible with the requested version and throws an error if
     * not. This validation can be skipped by setting the environment variable
     * `PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK` or setting
     * `opts.skipVersionCheck` to `true`. Note that the environment variable
     * always takes precedence. If it is set it is not possible to re-enable
     * the validation with `opts.skipVersionCheck`.
     */
    static async get(opts?: PulumiCommandOptions): Promise<PulumiCommand> {
        const command = opts?.root ? path.resolve(path.join(opts.root, "bin/pulumi")) : "pulumi";
        const { stdout } = await exec(command, ["version"]);
        const skipVersionCheck = !!opts?.skipVersionCheck || !!process.env[SKIP_VERSION_CHECK_VAR];
        let min = minimumVersion;
        if (opts?.version && semver.gt(opts.version, minimumVersion)) {
            min = opts.version;
        }
        const version = parseAndValidatePulumiVersion(min, stdout.trim(), skipVersionCheck);
        return new PulumiCommand(command, version);
    }

    /**
     * Installs the Pulumi CLI.
     */
    static async install(opts?: PulumiCommandOptions): Promise<PulumiCommand> {
        const optsWithDefaults = withDefaults(opts);
        try {
            return await PulumiCommand.get(optsWithDefaults);
        } catch (err) {
            // ignore
        }

        if (process.platform === "win32") {
            await PulumiCommand.installWindows(optsWithDefaults);
        } else {
            await PulumiCommand.installPosix(optsWithDefaults);
        }

        return await PulumiCommand.get(optsWithDefaults);
    }

    private static async installWindows(opts: Required<PulumiCommandOptions>): Promise<void> {
        const response = await got("https://get.pulumi.com/install.ps1");
        const script = await writeTempFile(response.body, { extension: ".ps1" });

        try {
            const command = process.env.SystemRoot
                ? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
                : "powershell.exe";

            const args = [
                "-NoProfile",
                "-InputFormat",
                "None",
                "-ExecutionPolicy",
                "Bypass",
                "-File",
                script.path,
                "-NoEditPath",
                "-InstallRoot",
                opts.root,
                "-Version",
                `${opts.version}`,
            ];

            await exec(command, args);
        } finally {
            script.cleanup();
        }
    }

    private static async installPosix(opts: Required<PulumiCommandOptions>): Promise<void> {
        const response = await got("https://get.pulumi.com/install.sh");
        const script = await writeTempFile(response.body);

        try {
            const args = [script.path, "--no-edit-path", "--install-root", opts.root, "--version", `${opts.version}`];

            await exec("/bin/sh", args);
        } finally {
            script.cleanup();
        }
    }

    /** @internal */
    public run(
        args: string[],
        cwd: string,
        additionalEnv: { [key: string]: string },
        onOutput?: (data: string) => void,
    ): Promise<CommandResult> {
        // all commands should be run in non-interactive mode.
        // this causes commands to fail rather than prompting for input (and thus hanging indefinitely)
        if (!args.includes("--non-interactive")) {
            args.push("--non-interactive");
        }

        // Prepend the folder where the CLI is installed to the path to ensure
        // we pickup the matching bundled plugins.
        if (path.isAbsolute(this.command)) {
            const pulumiBin = path.dirname(this.command);
            const sep = os.platform() === "win32" ? ";" : ":";
            const envPath = pulumiBin + sep + (additionalEnv["PATH"] || process.env.PATH);
            additionalEnv["PATH"] = envPath;
        }

        return exec(this.command, args, cwd, additionalEnv, onOutput);
    }
}

async function exec(
    command: string,
    args: string[],
    cwd?: string,
    additionalEnv?: { [key: string]: string },
    onOutput?: (data: string) => void,
): Promise<CommandResult> {
    const unknownErrCode = -2;

    const env = additionalEnv ? { ...additionalEnv } : undefined;

    try {
        const proc = execa(command, args, { env, cwd });

        if (onOutput && proc.stdout) {
            proc.stdout!.on("data", (data: any) => {
                if (data?.toString) {
                    data = data.toString();
                }
                onOutput(data);
            });
        }

        const { stdout, stderr, exitCode } = await proc;
        const commandResult = new CommandResult(stdout, stderr, exitCode);
        if (exitCode !== 0) {
            throw createCommandError(commandResult);
        }

        return commandResult;
    } catch (err) {
        const error = err as Error;
        throw createCommandError(new CommandResult("", error.message, unknownErrCode, error));
    }
}

function withDefaults(opts?: PulumiCommandOptions): Required<PulumiCommandOptions> {
    let version = opts?.version;
    if (!version) {
        version = new semver.SemVer(DEFAULT_VERSION);
    }
    let root = opts?.root;
    if (!root) {
        root = path.join(os.homedir(), ".pulumi", "versions", `${version}`);
    }
    const skipVersionCheck = opts?.skipVersionCheck !== undefined ? opts.skipVersionCheck : false;
    return { version, root, skipVersionCheck };
}

function writeTempFile(
    contents: string,
    options?: { extension?: string },
): Promise<{ path: string; cleanup: () => void }> {
    return new Promise<{ path: string; cleanup: () => void }>((resolve, reject) => {
        tmp.file(
            {
                // Powershell requires a `.ps1` extension.
                postfix: options?.extension,
                // Powershell won't execute the script if the file descriptor is open.
                discardDescriptor: true,
            },
            (tmpErr, tmpPath, _fd, cleanup) => {
                if (tmpErr) {
                    reject(tmpErr);
                } else {
                    fs.writeFile(tmpPath, contents, (writeErr) => {
                        if (writeErr) {
                            cleanup();
                            reject(writeErr);
                        } else {
                            resolve({ path: tmpPath, cleanup });
                        }
                    });
                }
            },
        );
    });
}

/**
 * @internal
 * Throws an error if the Pulumi CLI version is not valid.
 *
 * @param minVersion The minimum acceptable version of the Pulumi CLI.
 * @param currentVersion The currently known version. `null` indicates that the current version is unknown.
 * @param optOut If the user has opted out of the version check.
 */
export function parseAndValidatePulumiVersion(
    minVersion: semver.SemVer,
    currentVersion: string,
    optOut: boolean,
): semver.SemVer | null {
    const version = semver.parse(currentVersion);
    if (optOut) {
        return version;
    }
    if (version == null) {
        throw new Error(
            `Failed to parse Pulumi CLI version. This is probably an internal error. You can override this by setting "${SKIP_VERSION_CHECK_VAR}" to "true".`,
        );
    }
    if (minVersion.major < version.major) {
        throw new Error(
            `Major version mismatch. You are using Pulumi CLI version ${currentVersion} with Automation SDK v${minVersion.major}. Please update the SDK.`,
        );
    }
    if (minVersion.compare(version) === 1) {
        throw new Error(
            `Minimum version requirement failed. The minimum CLI version requirement is ${minVersion.toString()}, your current CLI version is ${currentVersion}. Please update the Pulumi CLI.`,
        );
    }
    return version;
}