/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set sts=2 sw=2 et tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/*
 * These modules are loosely based on the subprocess.jsm module created
 * by Jan Gerber and Patrick Brunschwig, though the implementation
 * differs drastically.
 */

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { SubprocessConstants } from "resource://gre/modules/subprocess/subprocess_common.sys.mjs";

const lazy = {};

if (AppConstants.platform == "win") {
  ChromeUtils.defineESModuleGetters(lazy, {
    SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs",
  });
} else {
  ChromeUtils.defineESModuleGetters(lazy, {
    SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs",
  });
}

function encodeEnvVar(name, value) {
  if (typeof name === "string" && typeof value === "string") {
    return `${name}=${value}`;
  }

  let encoder = new TextEncoder();
  function encode(val) {
    return typeof val === "string" ? encoder.encode(val) : val;
  }

  return Uint8Array.of(...encode(name), ...encode("="), ...encode(value), 0);
}

function platformSupportsDisclaimedSpawn() {
  return AppConstants.platform === "macosx";
}

/**
 * Allows for creation of and communication with OS-level sub-processes.
 *
 * @namespace
 */
export var Subprocess = {
  /**
   * Launches a process, and returns a handle to it.
   *
   * @param {object} options
   * An object describing the process to launch.
   *
   * @param {string} options.command
   * The full path of the executable to launch. Relative paths are not
   * accepted, and `$PATH` is not searched.
   *
   * If a path search is necessary, the {@link Subprocess.pathSearch} method may
   * be used to map a bare executable name to a full path.
   *
   * @param {string[]} [options.arguments]
   * A list of strings to pass as arguments to the process.
   *
   * @param {object} [options.environment] An object containing a key
   * and value for each environment variable to pass to the
   * process. Values that are `=== null` are ignored. Only the
   * object's own, enumerable properties are added to the environment.
   *
   * @param {boolean} [options.environmentAppend] If true, append the
   * environment variables passed in `environment` to the existing set
   * of environment variables. Values that are `=== null` are removed
   * from the environment. Otherwise, the values in 'environment'
   * constitute the entire set of environment variables passed to the
   * new process.
   *
   * @param {string} [options.stderr]
   * Defines how the process's stderr output is handled. One of:
   *
   * - `"ignore"`: (default) The process's standard error is not redirected.
   * - `"stdout"`: The process's stderr is merged with its stdout.
   * - `"pipe"`: The process's stderr is redirected to a pipe, which can be read
   *   from via its `stderr` property.
   *
   * @param {string} [options.workdir]
   *        The working directory in which to launch the new process.
   *
   * @param {boolean} [options.disclaim]
   * macOS-specific option for 10.14+ OS versions. If true, enables a
   * macOS-specific process launch option allowing the parent process to
   * disclaim responsibility for the child process with respect to privacy/
   * security permission prompts and decisions. This option is ignored on
   * platforms that do not support it.
   *
   * @returns {Promise<Process>}
   *
   * @throws {Error}
   * May be rejected with an Error object if the process can not be
   * launched. The object will include an `errorCode` property with
   * one of the following values if it was rejected for the
   * corresponding reason:
   *
   * - Subprocess.ERROR_BAD_EXECUTABLE: The given command could not
   *   be found, or the file that it references is not executable.
   *
   * Note that if the process is successfully launched, but exits with
   * a non-zero exit code, the promise will still resolve successfully.
   */
  call(options) {
    options = Object.assign({}, options);

    options.stderr = options.stderr || "ignore";
    options.workdir = options.workdir || null;
    options.disclaim = options.disclaim || false;

    let environment = {};
    if (!options.environment || options.environmentAppend) {
      environment = this.getEnvironment();
    }

    if (options.environment) {
      Object.assign(environment, options.environment);
    }

    options.environment = Object.entries(environment)
      .map(([key, val]) => (val !== null ? encodeEnvVar(key, val) : null))
      .filter(s => s);

    options.arguments = Array.from(options.arguments || []);

    if (options.disclaim && !platformSupportsDisclaimedSpawn()) {
      options.disclaim = false;
    }

    return Promise.resolve(
      lazy.SubprocessImpl.isExecutableFile(options.command)
    ).then(isExecutable => {
      if (!isExecutable) {
        let error = new Error(
          `File at path "${options.command}" does not exist, or is not executable`
        );
        error.errorCode = SubprocessConstants.ERROR_BAD_EXECUTABLE;
        throw error;
      }

      options.arguments.unshift(options.command);

      return lazy.SubprocessImpl.call(options);
    });
  },

  /**
   * Returns an object with a key-value pair for every variable in the process's
   * current environment.
   *
   * @returns {object}
   */
  getEnvironment() {
    let environment = Object.create(null);
    for (let [k, v] of lazy.SubprocessImpl.getEnvironment()) {
      environment[k] = v;
    }
    return environment;
  },

  /**
   * Searches for the given executable file in the system executable
   * file paths as specified by the PATH environment variable.
   *
   * On Windows, if the unadorned filename cannot be found, the
   * extensions in the semicolon-separated list in the PATHSEP
   * environment variable are successively appended to the original
   * name and searched for in turn.
   *
   * @param {string} command
   *        The name of the executable to find.
   * @param {object} [environment]
   *        An object containing a key for each environment variable to be used
   *        in the search. If not provided, full the current process environment
   *        is used.
   * @returns {Promise<string>}
   */
  pathSearch(command, environment = this.getEnvironment()) {
    // Promise.resolve lets us get around returning one of the Promise.jsm
    // pseudo-promises returned by Task.jsm.
    let path = lazy.SubprocessImpl.pathSearch(command, environment);
    return Promise.resolve(path);
  },

  /**
   * Connect to an already-running subprocess
   * given the file descriptors for its stdin, stdout and stderr.
   *
   * @param {number[]} fds
   *        A list of three file descriptors [stdin, stdout, stderr].
   *
   * @returns {Promise<Process>}
   */
  connectRunning(fds) {
    return lazy.SubprocessImpl.connectRunning(fds);
  },
};

Object.assign(Subprocess, SubprocessConstants);
Object.freeze(Subprocess);

export function getSubprocessImplForTest() {
  return lazy.SubprocessImpl;
}
