/* 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/. */

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Subprocess: "resource://gre/modules/Subprocess.sys.mjs",
});

XPCOMUtils.defineLazyServiceGetters(lazy, {
  XreDirProvider: [
    "@mozilla.org/xre/directory-provider;1",
    Ci.nsIXREDirProvider,
  ],
});

ChromeUtils.defineLazyGetter(lazy, "log", () => {
  let { ConsoleAPI } = ChromeUtils.importESModule(
    "resource://gre/modules/Console.sys.mjs"
  );
  let consoleOptions = {
    // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
    // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
    maxLogLevel: "error",
    maxLogLevelPref: "toolkit.components.taskscheduler.loglevel",
    prefix: "TaskScheduler",
  };
  return new ConsoleAPI(consoleOptions);
});

/**
 * Task generation and management for macOS, using `launchd` via `launchctl`.
 *
 * Implements the API exposed in TaskScheduler.sys.mjs
 * Not intended for external use, this is in a separate module to ship the code only
 * on macOS, and to expose for testing.
 */
export var MacOSImpl = {
  async registerTask(id, command, intervalSeconds, options) {
    lazy.log.info(
      `registerTask(${id}, ${command}, ${intervalSeconds}, ${JSON.stringify(
        options
      )})`
    );

    let uid = await this._uid();
    lazy.log.debug(`registerTask: uid=${uid}`);

    let label = this._formatLabelForThisApp(id, options);

    // We ignore `options.disabled`, which is test only.
    //
    // The `Disabled` key prevents `launchd` from registering the task, with
    // exit code 133 and error message "Service is disabled".  If we really want
    // this flow in the future, there is `launchctl disable ...`, but it's
    // fraught with peril: the disabled status is stored outside of any plist,
    // and it persists even after the task is deleted.  Monkeying with the
    // disabled status will likely prevent users from disabling these tasks
    // forcibly, should it come to that.  All told, fraught.
    //
    // For the future: there is the `RunAtLoad` key, should we want to run the
    // task once immediately.
    let plist = {};
    plist.Label = label;
    plist.ProgramArguments = [command];
    if (options.args) {
      plist.ProgramArguments.push(...options.args);
    }
    plist.StartInterval = intervalSeconds;
    if (options.workingDirectory) {
      plist.WorkingDirectory = options.workingDirectory;
    }

    let str = this._formatLaunchdPlist(plist);
    let path = this._formatPlistPath(label);

    await IOUtils.write(path, new TextEncoder().encode(str));
    lazy.log.debug(`registerTask: wrote ${path}`);

    try {
      let bootout = await lazy.Subprocess.call({
        command: "/bin/launchctl",
        arguments: ["bootout", `gui/${uid}/${label}`],
        stderr: "stdout",
      });

      lazy.log.debug(
        "registerTask: bootout stdout",
        await bootout.stdout.readString()
      );

      let { exitCode } = await bootout.wait();
      lazy.log.debug(`registerTask: bootout returned ${exitCode}`);

      let bootstrap = await lazy.Subprocess.call({
        command: "/bin/launchctl",
        arguments: ["bootstrap", `gui/${uid}`, path],
        stderr: "stdout",
      });

      lazy.log.debug(
        "registerTask: bootstrap stdout",
        await bootstrap.stdout.readString()
      );

      ({ exitCode } = await bootstrap.wait());
      lazy.log.debug(`registerTask: bootstrap returned ${exitCode}`);

      if (exitCode != 0) {
        throw new Components.Exception(
          `Failed to run launchctl bootstrap: ${exitCode}`,
          Cr.NS_ERROR_UNEXPECTED
        );
      }
    } catch (e) {
      // Try to clean up.
      await IOUtils.remove(path, { ignoreAbsent: true });
      throw e;
    }

    return true;
  },

  async deleteTask(id, options) {
    lazy.log.info(`deleteTask(${id})`);

    let label = this._formatLabelForThisApp(id, options);
    return this._deleteTaskByLabel(label);
  },

  async _deleteTaskByLabel(label) {
    let path = this._formatPlistPath(label);
    lazy.log.debug(`_deleteTaskByLabel: removing ${path}`);
    await IOUtils.remove(path, { ignoreAbsent: true });

    let uid = await this._uid();
    lazy.log.debug(`_deleteTaskByLabel: uid=${uid}`);

    let bootout = await lazy.Subprocess.call({
      command: "/bin/launchctl",
      arguments: ["bootout", `gui/${uid}/${label}`],
      stderr: "stdout",
    });

    let { exitCode } = await bootout.wait();
    lazy.log.debug(`_deleteTaskByLabel: bootout returned ${exitCode}`);
    lazy.log.debug(
      `_deleteTaskByLabel: bootout stdout`,
      await bootout.stdout.readString()
    );

    return !exitCode;
  },

  // For internal and testing use only.
  async _listAllLabelsForThisApp() {
    let proc = await lazy.Subprocess.call({
      command: "/bin/launchctl",
      arguments: ["list"],
      stderr: "stdout",
    });

    let { exitCode } = await proc.wait();
    if (exitCode != 0) {
      throw new Components.Exception(
        `Failed to run /bin/launchctl list: ${exitCode}`,
        Cr.NS_ERROR_UNEXPECTED
      );
    }

    let stdout = await proc.stdout.readString();

    let lines = stdout.split(/\r\n|\n|\r/);
    let labels = lines
      .map(line => line.split("\t").pop()) // Lines are like "-\t0\tlabel".
      .filter(this._labelMatchesThisApp);

    lazy.log.debug(`_listAllLabelsForThisApp`, labels);
    return labels;
  },

  async deleteAllTasks() {
    lazy.log.info(`deleteAllTasks()`);

    let labelsToDelete = await this._listAllLabelsForThisApp();

    let deleted = 0;
    let failed = 0;
    for (const label of labelsToDelete) {
      try {
        if (await this._deleteTaskByLabel(label)) {
          deleted += 1;
        } else {
          failed += 1;
        }
      } catch (e) {
        failed += 1;
      }
    }

    let result = { deleted, failed };
    lazy.log.debug(`deleteAllTasks: returning ${JSON.stringify(result)}`);
  },

  async taskExists(id, options) {
    const label = this._formatLabelForThisApp(id, options);
    const path = this._formatPlistPath(label);
    return IOUtils.exists(path);
  },

  /**
   * Turn an object into a macOS plist.
   *
   * Properties of type array-of-string, dict-of-string, string,
   * number, and boolean are supported.
   *
   * @param   options object to turn into macOS plist.
   * @returns plist as an XML DOM object.
   */
  _toLaunchdPlist(options) {
    const doc = new DOMParser().parseFromString("<plist></plist>", "text/xml");
    const root = doc.documentElement;
    root.setAttribute("version", "1.0");

    let dict = doc.createElement("dict");
    root.appendChild(dict);

    for (let [k, v] of Object.entries(options)) {
      let key = doc.createElement("key");
      key.textContent = k;
      dict.appendChild(key);

      if (Array.isArray(v)) {
        let array = doc.createElement("array");
        dict.appendChild(array);

        for (let vv of v) {
          let string = doc.createElement("string");
          string.textContent = vv;
          array.appendChild(string);
        }
      } else if (typeof v === "object") {
        let d = doc.createElement("dict");
        dict.appendChild(d);

        for (let [kk, vv] of Object.entries(v)) {
          key = doc.createElement("key");
          key.textContent = kk;
          d.appendChild(key);

          let string = doc.createElement("string");
          string.textContent = vv;
          d.appendChild(string);
        }
      } else if (typeof v === "number") {
        let number = doc.createElement(
          Number.isInteger(v) ? "integer" : "real"
        );
        number.textContent = v;
        dict.appendChild(number);
      } else if (typeof v === "string") {
        let string = doc.createElement("string");
        string.textContent = v;
        dict.appendChild(string);
      } else if (typeof v === "boolean") {
        let bool = doc.createElement(v ? "true" : "false");
        dict.appendChild(bool);
      }
    }

    return doc;
  },

  /**
   * Turn an object into a macOS plist encoded as a string.
   *
   * Properties of type array-of-string, dict-of-string, string,
   * number, and boolean are supported.
   *
   * @param   options object to turn into macOS plist.
   * @returns plist as a string.
   */
  _formatLaunchdPlist(options) {
    let doc = this._toLaunchdPlist(options);

    let serializer = new XMLSerializer();
    return serializer.serializeToString(doc);
  },

  _formatLabelForThisApp(id) {
    let installHash = lazy.XreDirProvider.getInstallHash();
    return `${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.${id}`;
  },

  _labelMatchesThisApp(label) {
    let installHash = lazy.XreDirProvider.getInstallHash();
    return (
      label &&
      label.startsWith(`${AppConstants.MOZ_MACBUNDLE_ID}.${installHash}.`)
    );
  },

  _formatPlistPath(label) {
    let file = Services.dirsvc.get("Home", Ci.nsIFile);
    file.append("Library");
    file.append("LaunchAgents");
    file.append(`${label}.plist`);
    return file.path;
  },

  _cachedUid: -1,

  async _uid() {
    if (this._cachedUid >= 0) {
      return this._cachedUid;
    }

    // There are standard APIs for determining our current UID, but this
    // is easy and parallel to the general tactics used by this module.
    let proc = await lazy.Subprocess.call({
      command: "/usr/bin/id",
      arguments: ["-u"],
      stderr: "stdout",
    });

    let stdout = await proc.stdout.readString();

    let { exitCode } = await proc.wait();
    if (exitCode != 0) {
      throw new Components.Exception(
        `Failed to run /usr/bin/id: ${exitCode}`,
        Cr.NS_ERROR_UNEXPECTED
      );
    }

    try {
      this._cachedUid = Number.parseInt(stdout);
      return this._cachedUid;
    } catch (e) {
      throw new Components.Exception(
        `Failed to parse /usr/bin/id output as integer: ${stdout}`,
        Cr.NS_ERROR_UNEXPECTED
      );
    }
  },
};
