// Copyright 2016-2023, 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 * as fs from "fs";
import * as yaml from "js-yaml";
import * as os from "os";
import * as semver from "semver";
import * as upath from "upath";

import { CommandResult, PulumiCommand } from "./cmd";
import { ConfigMap, ConfigValue } from "./config";
import { minimumVersion } from "./minimumVersion";
import { ProjectSettings } from "./projectSettings";
import { RemoteGitProgramArgs } from "./remoteWorkspace";
import { OutputMap, Stack } from "./stack";
import { StackSettings, stackSettingsSerDeKeys } from "./stackSettings";
import { TagMap } from "./tag";
import { Deployment, PluginInfo, PulumiFn, StackSummary, WhoAmIResult, Workspace } from "./workspace";

const SKIP_VERSION_CHECK_VAR = "PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK";

/**
 * {@link LocalWorkspace} is a default implementation of the {@link Workspace} interface.
 *
 * A {@link Workspace} is the execution context containing a single Pulumi
 * project, a program, and multiple stacks. Workspaces are used to manage the
 * execution environment, providing various utilities such as plugin
 * installation, environment configuration (`$PULUMI_HOME`), and creation,
 * deletion, and listing of Stacks.
 *
 * {@link LocalWorkspace} relies on `Pulumi.yaml` and `Pulumi.<stack>.yaml` as
 * the intermediate format for Project and Stack settings. Modifying the
 * workspace's {@link ProjectSettings} will alter the workspace's `Pulumi.yaml`
 * file, and setting config on a Stack will modify the relevant
 * `Pulumi.<stack>.yaml` file. This is identical to the behavior of Pulumi CLI
 * driven workspaces.
 *
 * @alpha
 */
export class LocalWorkspace implements Workspace {
    /**
     * The working directory to run Pulumi CLI commands in.
     */
    readonly workDir: string;

    /**
     * The directory override for CLI metadata if set. This customizes the
     * location of `$PULUMI_HOME` where metadata is stored and plugins are
     * installed.
     */
    readonly pulumiHome?: string;

    /**
     * The secrets provider to use for encryption and decryption of stack secrets.
     * See: https://www.pulumi.com/docs/intro/concepts/secrets/#available-encryption-providers
     */
    readonly secretsProvider?: string;

    private _pulumiCommand?: PulumiCommand;

    /**
     * The underlying Pulumi CLI.
     */
    public get pulumiCommand(): PulumiCommand {
        if (this._pulumiCommand === undefined) {
            throw new Error("Failed to get Pulumi CLI");
        }
        return this._pulumiCommand;
    }

    /**
     * The inline program {@link PulumiFn} to be used for preview/update
     * operations if any. If none is specified, the stack will refer to
     * {@link ProjectSettings} for this information.
     */
    program?: PulumiFn;

    /**
     * Environment values scoped to the current workspace. These will be supplied to every Pulumi command.
     */
    envVars: { [key: string]: string };

    private _pulumiVersion?: semver.SemVer;

    /**
     * The version of the underlying Pulumi CLI/engine.
     *
     * @returns A string representation of the version, if available. `null` otherwise.
     */
    public get pulumiVersion(): string {
        if (this._pulumiVersion === undefined) {
            throw new Error(`Failed to get Pulumi version`);
        }
        return this._pulumiVersion.toString();
    }

    private ready: Promise<any[]>;

    /**
     * True if the workspace is a remote workspace.
     */
    private remote?: boolean;

    /**
     * Remote Git source info.
     */
    private remoteGitProgramArgs?: RemoteGitProgramArgs;

    /**
     * An optional list of arbitrary commands to run before the remote Pulumi operation is invoked.
     */
    private remotePreRunCommands?: string[];

    /**
     * The environment variables to pass along when running remote Pulumi operations.
     */
    private remoteEnvVars?: { [key: string]: string | { secret: string } };

    /**
     * Whether to skip the default dependency installation step.
     */
    private remoteSkipInstallDependencies?: boolean;

    /**
     * Whether to inherit the deployment settings set on the stack.
     */
    private remoteInheritSettings?: boolean;

    /**
     * Creates a workspace using the specified options. Used for maximal control and customization
     * of the underlying environment before any stacks are created or selected.
     *
     * @param opts Options used to configure the Workspace
     */
    static async create(opts: LocalWorkspaceOptions): Promise<LocalWorkspace> {
        const ws = new LocalWorkspace(opts);
        await ws.ready;
        return ws;
    }

    /**
     * Creates a {@link Stack} with a {@link LocalWorkspace} utilizing the local
     * Pulumi CLI program from the specified working directory. This is a way to
     * create drivers on top of pre-existing Pulumi programs. This workspace
     * will pick up any available settings files (`Pulumi.yaml`,
     * `Pulumi.<stack>.yaml`).
     *
     * @param args
     *  A set of arguments to initialize a stack with a pre-configured Pulumi
     *  CLI program that already exists on disk.
     *
     * @param opts
     *  Additional customizations to be applied to the Workspace.
     */
    static async createStack(args: LocalProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack>;

    /**
     * Creates a {@link Stack} with a {@link LocalWorkspace} utilizing the
     * specified inline (in process) Pulumi program. This program is fully
     * debuggable and runs in process. If no Project option is specified,
     * default project settings will be created on behalf of the user.
     * Similarly, unless a `workDir` option is specified, the working directory
     * will default to a new temporary directory provided by the OS.
     *
     * @param args
     *  A set of arguments to initialize a stack with and an inline
     *  {@link PulumiFn} program that runs in process.
     * @param opts
     *  Additional customizations to be applied to the Workspace.
     */
    static async createStack(args: InlineProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack>;
    static async createStack(args: InlineProgramArgs | LocalProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack> {
        if (isInlineProgramArgs(args)) {
            return await this.inlineSourceStackHelper(args, Stack.create, opts);
        } else if (isLocalProgramArgs(args)) {
            return await this.localSourceStackHelper(args, Stack.create, opts);
        }
        throw new Error(`unexpected args: ${args}`);
    }

    /**
     * Selects a {@link Stack} with a {@link LocalWorkspace} utilizing the local
     * Pulumi CLI program from the specified working directory. This is a way to
     * create drivers on top of pre-existing Pulumi programs. This Workspace
     * will pick up any available Settings files (`Pulumi.yaml`,
     * `Pulumi.<stack>.yaml`).
     *
     * @param args
     *  A set of arguments to initialize a stack with a pre-configured Pulumi
     *  CLI program that already exists on disk.
     * @param opts
     *  Additional customizations to be applied to the Workspace.
     */
    static async selectStack(args: LocalProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack>;

    /**
     * Selects an existing {@link Stack} with a {@link LocalWorkspace} utilizing
     * the specified inline (in process) Pulumi program. This program is fully
     * debuggable and runs in process. If no Project option is specified,
     * default project settings will be created on behalf of the user.
     * Similarly, unless a `workDir` option is specified, the working directory
     * will default to a new temporary directory provided by the OS.
     *
     * @param args
     *  A set of arguments to initialize a Stack with and inline `PulumiFn`
     *  program that runs in process.
     * @param opts
     *  Additional customizations to be applied to the Workspace.
     */
    static async selectStack(args: InlineProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack>;
    static async selectStack(args: InlineProgramArgs | LocalProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack> {
        if (isInlineProgramArgs(args)) {
            return await this.inlineSourceStackHelper(args, Stack.select, opts);
        } else if (isLocalProgramArgs(args)) {
            return await this.localSourceStackHelper(args, Stack.select, opts);
        }
        throw new Error(`unexpected args: ${args}`);
    }

    /**
     * Creates or selects an existing {@link Stack} with a {@link LocalWorkspace}
     * utilizing the specified inline (in process) Pulumi CLI program. This
     * program is fully debuggable and runs in process. If no project is
     * specified, default project settings will be created on behalf of the
     * user. Similarly, unless a `workDir` option is specified, the working
     * directory will default to a new temporary directory provided by the OS.
     *
     * @param args
     *  A set of arguments to initialize a Stack with a pre-configured Pulumi
     *  CLI program that already exists on disk.
     * @param opts
     *  Additional customizations to be applied to the Workspace.
     */
    static async createOrSelectStack(args: LocalProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack>;

    /**
     * Creates or selects an existing {@link Stack} with a {@link
     * LocalWorkspace} utilizing the specified inline Pulumi CLI program. This
     * program is fully debuggable and runs in process. If no Project option is
     * specified, default project settings will be created on behalf of the
     * user. Similarly, unless a `workDir` option is specified, the working
     * directory will default to a new temporary directory provided by the OS.
     *
     * @param args
     *  A set of arguments to initialize a Stack with and inline `PulumiFn`
     *  program that runs in process.
     * @param opts
     *  Additional customizations to be applied to the Workspace.
     */
    static async createOrSelectStack(args: InlineProgramArgs, opts?: LocalWorkspaceOptions): Promise<Stack>;
    static async createOrSelectStack(
        args: InlineProgramArgs | LocalProgramArgs,
        opts?: LocalWorkspaceOptions,
    ): Promise<Stack> {
        if (isInlineProgramArgs(args)) {
            return await this.inlineSourceStackHelper(args, Stack.createOrSelect, opts);
        } else if (isLocalProgramArgs(args)) {
            return await this.localSourceStackHelper(args, Stack.createOrSelect, opts);
        }
        throw new Error(`unexpected args: ${args}`);
    }

    private static async localSourceStackHelper(
        args: LocalProgramArgs,
        initFn: StackInitializer,
        opts?: LocalWorkspaceOptions,
    ): Promise<Stack> {
        let wsOpts = { workDir: args.workDir };
        if (opts) {
            wsOpts = { ...opts, workDir: args.workDir };
        }

        const ws = new LocalWorkspace(wsOpts);
        await ws.ready;

        return await initFn(args.stackName, ws);
    }

    private static async inlineSourceStackHelper(
        args: InlineProgramArgs,
        initFn: StackInitializer,
        opts?: LocalWorkspaceOptions,
    ): Promise<Stack> {
        let wsOpts: LocalWorkspaceOptions = { program: args.program };
        if (opts) {
            wsOpts = { ...opts, program: args.program };
        }

        if (!wsOpts.projectSettings) {
            if (wsOpts.workDir) {
                try {
                    // Try to load the project settings.
                    loadProjectSettings(wsOpts.workDir);
                } catch (e) {
                    // If it failed to find the project settings file, set a default project.
                    if (e.toString().includes("failed to find project settings")) {
                        wsOpts.projectSettings = defaultProject(args.projectName);
                    } else {
                        throw e;
                    }
                }
            } else {
                wsOpts.projectSettings = defaultProject(args.projectName);
            }
        }

        const ws = new LocalWorkspace(wsOpts);
        await ws.ready;

        return await initFn(args.stackName, ws);
    }

    /**
     * Constructs a new {@link LocalWorkspace}.
     */
    private constructor(opts?: LocalWorkspaceOptions) {
        let dir = "";
        let envs = {};

        if (opts) {
            const {
                workDir,
                pulumiHome,
                program,
                envVars,
                secretsProvider,
                remote,
                remoteGitProgramArgs,
                remotePreRunCommands,
                remoteEnvVars,
                remoteSkipInstallDependencies,
                remoteInheritSettings,
            } = opts;
            if (workDir) {
                // Verify that the workdir exists.
                if (!fs.existsSync(workDir)) {
                    throw new Error(`Invalid workDir passed to local workspace: '${workDir}' does not exist`);
                }
                dir = workDir;
            }
            this.pulumiHome = pulumiHome;
            this.program = program;
            this.secretsProvider = secretsProvider;
            this.remote = remote;
            this.remoteGitProgramArgs = remoteGitProgramArgs;
            this.remotePreRunCommands = remotePreRunCommands;
            this.remoteEnvVars = { ...remoteEnvVars };
            this.remoteSkipInstallDependencies = remoteSkipInstallDependencies;
            this.remoteInheritSettings = remoteInheritSettings;
            envs = { ...envVars };
        }

        if (!dir) {
            dir = fs.mkdtempSync(upath.joinSafe(os.tmpdir(), "automation-"));
        }
        this.workDir = dir;
        this.envVars = envs;

        const skipVersionCheck = !!this.envVars[SKIP_VERSION_CHECK_VAR] || !!process.env[SKIP_VERSION_CHECK_VAR];
        const pulumiCommand = opts?.pulumiCommand
            ? Promise.resolve(opts.pulumiCommand)
            : PulumiCommand.get({ skipVersionCheck });

        const readinessPromises: Promise<any>[] = [
            pulumiCommand.then((p) => {
                this._pulumiCommand = p;
                if (p.version) {
                    this._pulumiVersion = p.version;
                }
                return this.checkRemoteSupport();
            }),
        ];

        if (opts?.projectSettings) {
            readinessPromises.push(this.saveProjectSettings(opts.projectSettings));
        }
        if (opts?.stackSettings) {
            for (const [name, value] of Object.entries(opts.stackSettings)) {
                readinessPromises.push(this.saveStackSettings(name, value));
            }
        }

        this.ready = Promise.all(readinessPromises);
    }

    /**
     * Returns the settings object for the current project if any
     * {@link LocalWorkspace} reads settings from the `Pulumi.yaml`
     * in the workspace. A workspace can contain only a single project at a
     * time.
     */
    async projectSettings(): Promise<ProjectSettings> {
        return loadProjectSettings(this.workDir);
    }

    /**
     * Overwrites the settings object in the current project. There can only be
     * a single project per workspace. Fails if new project name does not match
     * old. {@link LocalWorkspace} writes this value to a `Pulumi.yaml` file in
     * `Workspace.WorkDir()`.
     *
     * @param settings
     *  The settings object to save to the Workspace.
     */
    async saveProjectSettings(settings: ProjectSettings): Promise<void> {
        let foundExt = ".yaml";
        for (const ext of settingsExtensions) {
            const testPath = upath.joinSafe(this.workDir, `Pulumi${ext}`);
            if (fs.existsSync(testPath)) {
                foundExt = ext;
                break;
            }
        }
        const path = upath.joinSafe(this.workDir, `Pulumi${foundExt}`);
        let contents;
        if (foundExt === ".json") {
            contents = JSON.stringify(settings, null, 4);
        } else {
            contents = yaml.safeDump(settings, { skipInvalid: true });
        }
        return fs.writeFileSync(path, contents);
    }

    /**
     * Returns the settings object for the stack matching the specified stack
     * name if any. {@link LocalWorkspace} reads this from a
     * `Pulumi.<stack>.yaml` file in `Workspace.WorkDir()`.
     *
     * @param stackName
     *  The stack to retrieve settings from.
     */
    async stackSettings(stackName: string): Promise<StackSettings> {
        const stackSettingsName = getStackSettingsName(stackName);
        for (const ext of settingsExtensions) {
            const isJSON = ext === ".json";
            const path = upath.joinSafe(this.workDir, `Pulumi.${stackSettingsName}${ext}`);
            if (!fs.existsSync(path)) {
                continue;
            }
            const contents = fs.readFileSync(path).toString();
            let stackSettings: any;
            if (isJSON) {
                stackSettings = JSON.parse(contents);
            }
            stackSettings = yaml.safeLoad(contents) as StackSettings;

            // Transform the serialized representation back to what we expect.
            for (const key of stackSettingsSerDeKeys) {
                if (stackSettings.hasOwnProperty(key[0])) {
                    stackSettings[key[1]] = stackSettings[key[0]];
                    delete stackSettings[key[0]];
                }
            }
            return stackSettings as StackSettings;
        }
        throw new Error(`failed to find stack settings file in workdir: ${this.workDir}`);
    }

    /**
     * Overwrites the settings object for the stack matching the specified stack
     * name. {@link LocalWorkspace} writes this value to a `Pulumi.<stack>.yaml`
     * file in `Workspace.WorkDir()`
     *
     * @param stackName
     *  The stack to operate on.
     * @param settings
     *  The settings object to save.
     */
    async saveStackSettings(stackName: string, settings: StackSettings): Promise<void> {
        const stackSettingsName = getStackSettingsName(stackName);
        let foundExt = ".yaml";
        for (const ext of settingsExtensions) {
            const testPath = upath.joinSafe(this.workDir, `Pulumi.${stackSettingsName}${ext}`);
            if (fs.existsSync(testPath)) {
                foundExt = ext;
                break;
            }
        }
        const path = upath.joinSafe(this.workDir, `Pulumi.${stackSettingsName}${foundExt}`);
        const serializeSettings = { ...settings } as any;
        let contents;

        // Transform the keys to the serialized representation that we expect.
        for (const key of stackSettingsSerDeKeys) {
            if (serializeSettings.hasOwnProperty(key[1])) {
                serializeSettings[key[0]] = serializeSettings[key[1]];
                delete serializeSettings[key[1]];
            }
        }

        if (foundExt === ".json") {
            contents = JSON.stringify(serializeSettings, null, 4);
        } else {
            contents = yaml.safeDump(serializeSettings, { skipInvalid: true });
        }
        return fs.writeFileSync(path, contents);
    }

    /**
     * Creates and sets a new stack with the stack name, failing if one already
     * exists.
     *
     * @param stackName The stack to create.
     */
    async createStack(stackName: string): Promise<void> {
        const args = ["stack", "init", stackName];
        if (this.secretsProvider) {
            args.push("--secrets-provider", this.secretsProvider);
        }
        if (this.isRemote) {
            args.push("--no-select");
        }
        await this.runPulumiCmd(args);
    }

    /**
     * Selects and sets an existing stack matching the stack name, failing if
     * none exists.
     *
     * @param stackName The stack to select.
     */
    async selectStack(stackName: string): Promise<void> {
        // If this is a remote workspace, we don't want to actually select the stack (which would modify global state);
        // but we will ensure the stack exists by calling `pulumi stack`.
        const args = ["stack"];
        if (!this.isRemote) {
            args.push("select");
        }
        args.push("--stack", stackName);

        await this.runPulumiCmd(args);
    }

    /**
     * Deletes the stack and all associated configuration and history.
     *
     * @param stackName The stack to remove
     */
    async removeStack(stackName: string, opts?: RemoveOptions): Promise<void> {
        const args = ["stack", "rm", "--yes"];

        if (opts?.force) {
            args.push("--force");
        }

        if (opts?.preserveConfig) {
            args.push("--preserve-config");
        }

        args.push(stackName);

        await this.runPulumiCmd(args);
    }

    /**
     * Adds environments to the end of a stack's import list. Imported
     * environments are merged in order per the ESC merge rules. The list of
     * environments behaves as if it were the import list in an anonymous
     * environment.
     *
     * @param stackName
     *  The stack to operate on
     * @param environments
     *  The names of the environments to add to the stack's configuration
     */
    async addEnvironments(stackName: string, ...environments: string[]): Promise<void> {
        let ver = this._pulumiVersion;
        if (ver === undefined) {
            // Assume an old version. Doesn't really matter what this is as long as it's pre-3.95.
            ver = semver.parse("3.0.0")!;
        }

        // 3.95 added this command (https://github.com/pulumi/pulumi/releases/tag/v3.95.0)
        if (ver.compare("3.95.0") < 0) {
            throw new Error(`addEnvironments requires Pulumi version >= 3.95.0`);
        }

        await this.runPulumiCmd(["config", "env", "add", ...environments, "--stack", stackName, "--yes"]);
    }

    /**
     * Returns the list of environments associated with the specified stack name.
     *
     * @param stackName The stack to operate on
     */
    async listEnvironments(stackName: string): Promise<string[]> {
        let ver = this._pulumiVersion;
        if (ver === undefined) {
            // Assume an old version. Doesn't really matter what this is as long as it's pre-3.99.
            ver = semver.parse("3.0.0")!;
        }

        // 3.99 added this command (https://github.com/pulumi/pulumi/releases/tag/v3.99.0)
        if (ver.compare("3.99.0") < 0) {
            throw new Error(`listEnvironments requires Pulumi version >= 3.99.0`);
        }

        const result = await this.runPulumiCmd(["config", "env", "ls", "--stack", stackName, "--json"]);
        return JSON.parse(result.stdout);
    }

    /**
     * Removes an environment from a stack's import list.
     *
     * @param stackName
     *  The stack to operate on
     * @param environment
     *  The name of the environment to remove from the stack's configuration
     */
    async removeEnvironment(stackName: string, environment: string): Promise<void> {
        let ver = this._pulumiVersion;
        if (ver === undefined) {
            // Assume an old version. Doesn't really matter what this is as long as it's pre-3.95.
            ver = semver.parse("3.0.0")!;
        }

        // 3.95 added this command (https://github.com/pulumi/pulumi/releases/tag/v3.95.0)
        if (ver.compare("3.95.0") < 0) {
            throw new Error(`removeEnvironments requires Pulumi version >= 3.95.0`);
        }

        await this.runPulumiCmd(["config", "env", "rm", environment, "--stack", stackName, "--yes"]);
    }

    /**
     * Returns the value associated with the specified stack name and key,
     * scoped to the current workspace. {@link LocalWorkspace} reads this config
     * from the matching `Pulumi.stack.yaml` file.
     *
     * @param stackName
     *  The stack to read config from
     * @param key
     *  The key to use for the config lookup
     * @param path
     *  The key contains a path to a property in a map or list to get
     */
    async getConfig(stackName: string, key: string, path?: boolean): Promise<ConfigValue> {
        const args = ["config", "get"];
        if (path) {
            args.push("--path");
        }
        args.push(key, "--json", "--stack", stackName);
        const result = await this.runPulumiCmd(args);
        return JSON.parse(result.stdout);
    }

    /**
     * Returns the config map for the specified stack name, scoped to the
     * current workspace. {@link LocalWorkspace} reads this config from the
     * matching `Pulumi.stack.yaml` file.
     *
     * @param stackName
     *  The stack to read config from
     */
    async getAllConfig(stackName: string): Promise<ConfigMap> {
        const result = await this.runPulumiCmd(["config", "--show-secrets", "--json", "--stack", stackName]);
        return JSON.parse(result.stdout);
    }

    /**
     * Sets the specified key-value pair on the provided stack name. {@link
     * LocalWorkspace} writes this value to the matching `Pulumi.<stack>.yaml`
     * file in `Workspace.WorkDir()`.
     *
     * @param stackName
     *  The stack to operate on
     * @param key
     *  The config key to set
     * @param value
     *  The value to set
     * @param path
     *  The key contains a path to a property in a map or list to set
     */
    async setConfig(stackName: string, key: string, value: ConfigValue, path?: boolean): Promise<void> {
        const args = ["config", "set"];
        if (path) {
            args.push("--path");
        }
        const secretArg = value.secret ? "--secret" : "--plaintext";
        args.push(key, "--stack", stackName, secretArg, "--non-interactive", "--", value.value);
        await this.runPulumiCmd(args);
    }

    /**
     * Sets all values in the provided config map for the specified stack name.
     * {@link LocalWorkspace} writes the config to the matching
     * `Pulumi.<stack>.yaml` file in `Workspace.WorkDir()`.
     *
     * @param stackName
     *  The stack to operate on
     * @param config
     *  The {@link ConfigMap} to upsert against the existing config
     * @param path
     *  The keys contain a path to a property in a map or list to set
     */
    async setAllConfig(stackName: string, config: ConfigMap, path?: boolean): Promise<void> {
        const args = ["config", "set-all", "--stack", stackName];
        if (path) {
            args.push("--path");
        }
        for (const [key, value] of Object.entries(config)) {
            const secretArg = value.secret ? "--secret" : "--plaintext";
            args.push(secretArg, `${key}=${value.value}`);
        }

        await this.runPulumiCmd(args);
    }

    /**
     * Removes the specified key-value pair on the provided stack name. Will
     * remove any matching values in the `Pulumi.<stack>.yaml` file in
     * `Workspace.WorkDir()`.
     *
     * @param stackName
     *  The stack to operate on
     * @param key
     *  The config key to remove
     * @param path
     *  The key contains a path to a property in a map or list to remove
     */
    async removeConfig(stackName: string, key: string, path?: boolean): Promise<void> {
        const args = ["config", "rm", key, "--stack", stackName];
        if (path) {
            args.push("--path");
        }
        await this.runPulumiCmd(args);
    }

    /**
     * Removes all values in the provided key list for the specified stack name
     * Will remove any matching values in the `Pulumi.<stack>.yaml` file in
     * `Workspace.WorkDir()`.
     *
     * @param stackName
     *  The stack to operate on
     * @param keys
     *  The list of keys to remove from the underlying config
     * @param path
     *  The keys contain a path to a property in a map or list to remove
     */
    async removeAllConfig(stackName: string, keys: string[], path?: boolean): Promise<void> {
        const args = ["config", "rm-all", "--stack", stackName];
        if (path) {
            args.push("--path");
        }
        args.push(...keys);
        await this.runPulumiCmd(args);
    }

    /**
     * Gets and sets the config map used with the last update for the stack
     * matching the given name. This will overwrite all configuration in the
     * `Pulumi.<stack>.yaml` file in `Workspace.WorkDir()`.
     *
     * @param stackName
     *  The stack to refresh
     */
    async refreshConfig(stackName: string): Promise<ConfigMap> {
        await this.runPulumiCmd(["config", "refresh", "--force", "--stack", stackName]);
        return this.getAllConfig(stackName);
    }

    /**
     * Returns the value associated with the specified stack name and key,
     * scoped to the {@link LocalWorkspace.}
     *
     * @param stackName
     *  The stack to read tag metadata from.
     * @param key
     *  The key to use for the tag lookup.
     */
    async getTag(stackName: string, key: string): Promise<string> {
        const result = await this.runPulumiCmd(["stack", "tag", "get", key, "--stack", stackName]);
        return result.stdout.trim();
    }

    /**
     * Sets the specified key-value pair on the stack with the given name.
     *
     * @param stackName
     *  The stack to operate on.
     * @param key
     *  The tag key to set.
     * @param value
     *  The tag value to set.
     */
    async setTag(stackName: string, key: string, value: string): Promise<void> {
        await this.runPulumiCmd(["stack", "tag", "set", key, value, "--stack", stackName]);
    }

    /**
     * Removes the specified key-value pair on the stack with the given name.
     *
     * @param stackName
     *  The stack to operate on.
     * @param key
     *  The tag key to remove.
     */
    async removeTag(stackName: string, key: string): Promise<void> {
        await this.runPulumiCmd(["stack", "tag", "rm", key, "--stack", stackName]);
    }

    /**
     * Returns the tag map for the specified stack, scoped to the current
     * {@link LocalWorkspace.}
     *
     * @param stackName
     *  The stack to read tag metadata from.
     */
    async listTags(stackName: string): Promise<TagMap> {
        const result = await this.runPulumiCmd(["stack", "tag", "ls", "--json", "--stack", stackName]);
        return JSON.parse(result.stdout);
    }

    /**
     * Returns information about the currently authenticated user.
     */
    async whoAmI(): Promise<WhoAmIResult> {
        let ver = this._pulumiVersion;
        if (ver === undefined) {
            // Assume an old version. Doesn't really matter what this is as long as it's pre-3.58.
            ver = semver.parse("3.0.0")!;
        }

        // 3.58 added the --json flag (https://github.com/pulumi/pulumi/releases/tag/v3.58.0)
        if (ver.compare("3.58.0") >= 0) {
            const result = await this.runPulumiCmd(["whoami", "--json"]);
            return JSON.parse(result.stdout);
        } else {
            const result = await this.runPulumiCmd(["whoami"]);
            return { user: result.stdout.trim() };
        }
    }

    /**
     * Returns a summary of the currently selected stack, if any.
     */
    async stack(): Promise<StackSummary | undefined> {
        const stacks = await this.listStacks();
        for (const stack of stacks) {
            if (stack.current) {
                return stack;
            }
        }
        return undefined;
    }

    /**
     * Returns all stacks from the underlying backend based on the provided
     * options. This queries the underlying backend and may return stacks not
     * present in the workspace as `Pulumi.<stack>.yaml` files.
     *
     * @param opts
     *  Options to customize the behavior of the list.
     */
    async listStacks(opts?: ListOptions): Promise<StackSummary[]> {
        const args = ["stack", "ls", "--json"];
        if (opts) {
            if (opts.all) {
                args.push("--all");
            }
        }
        const result = await this.runPulumiCmd(args);
        return JSON.parse(result.stdout);
    }

    /**
     * Installs a plugin in the workspace, for example to use cloud providers
     * like AWS or GCP.
     *
     * @param name
     *  The name of the plugin.
     * @param version
     *  The version of the plugin e.g. "v1.0.0".
     * @param kind
     *  The kind of plugin, defaults to "resource"
     */
    async installPlugin(name: string, version: string, kind = "resource"): Promise<void> {
        await this.runPulumiCmd(["plugin", "install", kind, name, version]);
    }

    /**
     * Installs a plugin in the workspace from a third party server.
     *
     * @param name
     *  The name of the plugin.
     * @param version
     *  The version of the plugin e.g. "v1.0.0".
     * @param server
     *  The server to install the plugin from
     */
    async installPluginFromServer(name: string, version: string, server: string): Promise<void> {
        await this.runPulumiCmd(["plugin", "install", "resource", name, version, "--server", server]);
    }

    /**
     * Removes a plugin from the workspace matching the specified name and version.
     *
     * @param name
     *  The optional name of the plugin.
     * @param versionRange
     *  An optional semver range to check when removing plugins matching the
     *  given name e.g. "1.0.0", ">1.0.0".
     * @param kind
     *  The kind of plugin, defaults to "resource".
     */
    async removePlugin(name?: string, versionRange?: string, kind = "resource"): Promise<void> {
        const args = ["plugin", "rm", kind];
        if (name) {
            args.push(name);
        }
        if (versionRange) {
            args.push(versionRange);
        }
        args.push("--yes");
        await this.runPulumiCmd(args);
    }

    /**
     * Returns a list of all plugins installed in the workspace.
     */
    async listPlugins(): Promise<PluginInfo[]> {
        const result = await this.runPulumiCmd(["plugin", "ls", "--json"]);
        return JSON.parse(result.stdout, (key, value) => {
            if (key === "installTime" || key === "lastUsedTime") {
                return new Date(value);
            }
            return value;
        });
    }

    /**
     * Exports the deployment state of the stack. This can be combined with
     * {@link importStack} to edit a stack's state (such as recovery from failed
     * deployments).
     *
     * @param stackName
     *  The name of the stack.
     */
    async exportStack(stackName: string): Promise<Deployment> {
        const result = await this.runPulumiCmd(["stack", "export", "--show-secrets", "--stack", stackName]);
        return JSON.parse(result.stdout);
    }

    /**
     * Imports the given deployment state into a pre-existing stack. This can be
     * combined with {@link exportStack} to edit a stack's state (such as
     * recovery from failed deployments).
     *
     * @param stackName
     *  The name of the stack.
     * @param state
     *  The stack state to import.
     */
    async importStack(stackName: string, state: Deployment): Promise<void> {
        const randomSuffix = Math.floor(100000 + Math.random() * 900000);
        const filepath = upath.joinSafe(os.tmpdir(), `automation-${randomSuffix}`);
        const contents = JSON.stringify(state, null, 4);
        fs.writeFileSync(filepath, contents);
        await this.runPulumiCmd(["stack", "import", "--file", filepath, "--stack", stackName]);
        fs.unlinkSync(filepath);
    }

    /**
     * Gets the current set of Stack outputs from the last {@link Stack.up}.
     *
     * @param stackName The name of the stack.
     */
    async stackOutputs(stackName: string): Promise<OutputMap> {
        // TODO: do this in parallel after this is fixed https://github.com/pulumi/pulumi/issues/6050
        const maskedResult = await this.runPulumiCmd(["stack", "output", "--json", "--stack", stackName]);
        const plaintextResult = await this.runPulumiCmd([
            "stack",
            "output",
            "--json",
            "--show-secrets",
            "--stack",
            stackName,
        ]);
        const maskedOuts = JSON.parse(maskedResult.stdout);
        const plaintextOuts = JSON.parse(plaintextResult.stdout);
        const outputs: OutputMap = {};

        for (const [key, value] of Object.entries(plaintextOuts)) {
            const secret = maskedOuts[key] === "[secret]";
            outputs[key] = { value, secret };
        }

        return outputs;
    }

    /**
     * A hook to provide additional args to every CLI commands before they are
     * executed. Provided with stack name, this function returns a list of
     * arguments to append to an invoked command (e.g. `["--config=...", ...]`)
     * Presently, {@link LocalWorkspace} does not utilize this extensibility
     * point.
     */
    async serializeArgsForOp(_: string): Promise<string[]> {
        return [];
    }

    /**
     * A hook executed after every command. Called with the stack name. An
     * extensibility point to perform workspace cleanup (CLI operations may
     * create/modify a `Pulumi.stack.yaml`) {@link LocalWorkspace} does not
     * utilize this extensibility point.
     */
    async postCommandCallback(_: string): Promise<void> {
        return;
    }

    private async checkRemoteSupport() {
        const optOut = !!this.envVars[SKIP_VERSION_CHECK_VAR] || !!process.env[SKIP_VERSION_CHECK_VAR];
        // If remote was specified, ensure the CLI supports it.
        if (!optOut && this.isRemote) {
            // See if `--remote` is present in `pulumi preview --help`'s output.
            const previewResult = await this.runPulumiCmd(["preview", "--help"]);
            const previewOutput = previewResult.stdout.trim();
            if (!previewOutput.includes("--remote")) {
                throw new Error("The Pulumi CLI does not support remote operations. Please upgrade.");
            }
        }
    }

    private async runPulumiCmd(args: string[]): Promise<CommandResult> {
        let envs: { [key: string]: string } = {};
        if (this.pulumiHome) {
            envs["PULUMI_HOME"] = this.pulumiHome;
        }
        if (this.isRemote) {
            envs["PULUMI_EXPERIMENTAL"] = "true";
        }
        envs = { ...envs, ...this.envVars };
        return this.pulumiCommand.run(args, this.workDir, envs);
    }

    /**
     * @internal
     */
    get isRemote(): boolean {
        return !!this.remote;
    }

    /**
     * @internal
     */
    remoteArgs(): string[] {
        const args: string[] = [];
        if (!this.isRemote) {
            return args;
        }

        args.push("--remote");
        if (this.remoteGitProgramArgs) {
            const { url, projectPath, branch, commitHash, auth } = this.remoteGitProgramArgs;
            if (url) {
                args.push(url);
            }
            if (projectPath) {
                args.push("--remote-git-repo-dir", projectPath);
            }
            if (branch) {
                args.push("--remote-git-branch", branch);
            }
            if (commitHash) {
                args.push("--remote-git-commit", commitHash);
            }
            if (auth) {
                const { personalAccessToken, sshPrivateKey, sshPrivateKeyPath, password, username } = auth;
                if (personalAccessToken) {
                    args.push("--remote-git-auth-access-token", personalAccessToken);
                }
                if (sshPrivateKey) {
                    args.push("--remote-git-auth-ssh-private-key", sshPrivateKey);
                }
                if (sshPrivateKeyPath) {
                    args.push("--remote-git-auth-ssh-private-key-path", sshPrivateKeyPath);
                }
                if (password) {
                    args.push("--remote-git-auth-password", password);
                }
                if (username) {
                    args.push("--remote-git-auth-username", username);
                }
            }
        }

        for (const key of Object.keys(this.remoteEnvVars ?? {})) {
            const val = this.remoteEnvVars![key];
            if (typeof val === "string") {
                args.push("--remote-env", `${key}=${val}`);
            } else if ("secret" in val) {
                args.push("--remote-env-secret", `${key}=${val.secret}`);
            } else {
                throw new Error(`unexpected env value '${val}' for key '${key}'`);
            }
        }

        for (const command of this.remotePreRunCommands ?? []) {
            args.push("--remote-pre-run-command", command);
        }

        if (this.remoteSkipInstallDependencies) {
            args.push("--remote-skip-install-dependencies");
        }

        if (this.remoteInheritSettings) {
            args.push("--remote-inherit-settings");
        }

        return args;
    }
}

/**
 * Description of a stack backed by an inline (in process) Pulumi program.
 */
export interface InlineProgramArgs {
    /**
     * The associated stack name.
     */
    stackName: string;

    /**
     * The associated project name.
     */
    projectName: string;

    /**
     * The inline (in-process) Pulumi program to use with update and preview operations.
     */
    program: PulumiFn;
}

/**
 * Description of a stack backed by pre-existing local Pulumi CLI program.
 */
export interface LocalProgramArgs {
    /**
     * The associated stack name.
     */
    stackName: string;

    /**
     * The working directory of the program.
     */
    workDir: string;
}

/**
 * Extensibility options to configure a {@link LocalWorkspace;} e.g: settings to
 * seed and environment variables to pass through to every command.
 */
export interface LocalWorkspaceOptions {
    /**
     * The directory to run Pulumi commands and read settings (`Pulumi.yaml` and
     * `Pulumi.<stack>.yaml`).
     */
    workDir?: string;

    /**
     * The directory to override for CLI metadata
     */
    pulumiHome?: string;

    /**
     * The underlying Pulumi CLI.
     */
    pulumiCommand?: PulumiCommand;

    /**
     * The inline program {@link PulumiFn} to be used for preview/update
     * operations, if any. If none is specified, the stack will refer to
     * {@link ProjectSettings} for this information.
     */
    program?: PulumiFn;

    /**
     * Environment values scoped to the current workspace. These will be supplied to every Pulumi command.
     */
    envVars?: { [key: string]: string };

    /**
     * The secrets provider to use for encryption and decryption of stack secrets.
     * See: https://www.pulumi.com/docs/intro/concepts/secrets/#available-encryption-providers
     */
    secretsProvider?: string;

    /**
     * The settings object for the current project.
     */
    projectSettings?: ProjectSettings;

    /**
     * A map of stack names and corresponding settings objects.
     */
    stackSettings?: { [key: string]: StackSettings };

    /**
     * True if workspace is a remote workspace.
     *
     * @internal
     */
    remote?: boolean;

    /**
     * The remote Git source info.
     *
     * @internal
     */
    remoteGitProgramArgs?: RemoteGitProgramArgs;

    /**
     * An optional list of arbitrary commands to run before a remote Pulumi operation is invoked.
     *
     * @internal
     */
    remotePreRunCommands?: string[];

    /**
     * The environment variables to pass along when running remote Pulumi operations.
     *
     * @internal
     */
    remoteEnvVars?: { [key: string]: string | { secret: string } };

    /**
     * Whether to skip the default dependency installation step.
     *
     * @internal
     */
    remoteSkipInstallDependencies?: boolean;

    /**
     * Whether to inherit deployment settings from the stack.
     *
     * @internal
     */
    remoteInheritSettings?: boolean;
}

/**
 * Returns true if the provided arguments satisfy the {@link LocalProgramArgs} interface.
 *
 * @param args
 *  The args object to evaluate
 */
function isLocalProgramArgs(args: LocalProgramArgs | InlineProgramArgs): args is LocalProgramArgs {
    return (args as LocalProgramArgs).workDir !== undefined;
}

/**
 * Returns true if the provided arguments satisfy the {@link InlineProgramArgs} interface.
 *
 * @param args
 *  The args object to evaluate
 */
function isInlineProgramArgs(args: LocalProgramArgs | InlineProgramArgs): args is InlineProgramArgs {
    return (args as InlineProgramArgs).projectName !== undefined && (args as InlineProgramArgs).program !== undefined;
}

const settingsExtensions = [".yaml", ".yml", ".json"];

function getStackSettingsName(name: string): string {
    const parts = name.split("/");
    if (parts.length < 1) {
        return name;
    }
    return parts[parts.length - 1];
}

type StackInitializer = (name: string, workspace: Workspace) => Promise<Stack>;

function defaultProject(projectName: string) {
    const settings: ProjectSettings = { name: projectName, runtime: "nodejs", main: process.cwd() };
    return settings;
}

function loadProjectSettings(workDir: string) {
    for (const ext of settingsExtensions) {
        const isJSON = ext === ".json";
        const path = upath.joinSafe(workDir, `Pulumi${ext}`);
        if (!fs.existsSync(path)) {
            continue;
        }
        const contents = fs.readFileSync(path).toString();
        if (isJSON) {
            return JSON.parse(contents);
        }
        return yaml.safeLoad(contents) as ProjectSettings;
    }
    throw new Error(`failed to find project settings file in workdir: ${workDir}`);
}

export interface ListOptions {
    /**
     * List all stacks instead of just stacks for the current project
     */
    all?: boolean;
}

export interface RemoveOptions {
    /**
     * Forces deletion of the stack, leaving behind any resources managed by the stack
     */
    force?: boolean;

    /**
     * Do not delete the corresponding Pulumi.<stack-name>.yaml configuration file for the stack
     */
    preserveConfig?: boolean;
}