/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

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

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

/**
 * Get the location of the user's macOS quarantine database.
 * @return {String} path.
 */
function getQuarantineDatabasePath() {
  let file = Services.dirsvc.get("Home", Ci.nsIFile);
  file.append("Library");
  file.append("Preferences");
  file.append("com.apple.LaunchServices.QuarantineEventsV2");
  return file.path;
}

/**
 * Query given path for quarantine extended attributes.
 * @param {String} path of the file to query.
 * @return {[String, String]} pair of the quarantine data GUID and remaining
 *                            quarantine data (usually, Gatekeeper flags).
 * @throws NS_ERROR_NOT_AVAILABLE if there is no quarantine GUID for the given path.
 * @throws NS_ERROR_UNEXPECTED if there is a quarantine GUID, but it is malformed.
 */
async function getQuarantineAttributes(path) {
  let bytes = await IOUtils.getMacXAttr(path, "com.apple.quarantine");
  if (!bytes) {
    throw new Components.Exception(
      `No macOS quarantine xattrs found for ${path}`,
      Cr.NS_ERROR_NOT_AVAILABLE
    );
  }

  let string = new TextDecoder("utf-8").decode(bytes);
  let parts = string.split(";");
  if (!parts.length) {
    throw new Components.Exception(
      `macOS quarantine data is not ; separated`,
      Cr.NS_ERROR_UNEXPECTED
    );
  }
  let guid = parts[parts.length - 1];
  if (guid.length != 36) {
    // Like "12345678-90AB-CDEF-1234-567890ABCDEF".
    throw new Components.Exception(
      `macOS quarantine data guid is not length 36: ${guid.length}`,
      Cr.NS_ERROR_UNEXPECTED
    );
  }

  return { guid, parts };
}

/**
 * Invoke system SQLite binary to extract the referrer URL corresponding to
 * the given GUID from the given macOS quarantine database.
 * @param {String} path of the user's macOS quarantine database.
 * @param {String} guid to query.
 * @return {String} referrer URL.
 */
async function queryQuarantineDatabase(
  guid,
  path = getQuarantineDatabasePath()
) {
  let query = `SELECT COUNT(*), LSQuarantineOriginURLString
       FROM LSQuarantineEvent
       WHERE LSQuarantineEventIdentifier = '${guid}'
       ORDER BY LSQuarantineTimeStamp DESC LIMIT 1`;

  let proc = await lazy.Subprocess.call({
    command: "/usr/bin/sqlite3",
    arguments: [path, query],
    environment: {},
    stderr: "stdout",
  });

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

  let { exitCode } = await proc.wait();
  if (exitCode != 0) {
    throw new Components.Exception(
      "Failed to run sqlite3",
      Cr.NS_ERROR_UNEXPECTED
    );
  }

  // Output is like "integer|url".
  let parts = stdout.split("|", 2);
  if (parts.length != 2) {
    throw new Components.Exception(
      "Failed to parse sqlite3 output",
      Cr.NS_ERROR_UNEXPECTED
    );
  }

  if (parts[0].trim() == "0") {
    throw new Components.Exception(
      `Quarantine database does not contain URL for guid ${guid}`,
      Cr.NS_ERROR_UNEXPECTED
    );
  }

  return parts[1].trim();
}

export var MacAttribution = {
  /**
   * The file path to the `.app` directory.
   */
  get applicationPath() {
    // On macOS, `GreD` is like "App.app/Contents/macOS".  Return "App.app".
    return Services.dirsvc.get("GreD", Ci.nsIFile).parent.parent.path;
  },

  /**
   * Used by the Attributions system to get the download referrer.
   *
   * @param {String} path to get the quarantine data from.
   *             Usually this is a `.app` directory but can be any
   *             (existing) file or directory.  Default: `this.applicationPath`.
   * @return {String} referrer URL.
   * @throws NS_ERROR_NOT_AVAILABLE if there is no quarantine GUID for the given path.
   * @throws NS_ERROR_UNEXPECTED if there is a quarantine GUID, but no corresponding referrer URL is known.
   */
  async getReferrerUrl(path = this.applicationPath) {
    lazy.log.debug(`getReferrerUrl(${JSON.stringify(path)})`);

    // First, determine the quarantine GUID assigned by macOS to the given path.
    let guid;
    try {
      guid = (await getQuarantineAttributes(path)).guid;
    } catch (ex) {
      throw new Components.Exception(
        `No macOS quarantine GUID found for ${path}`,
        Cr.NS_ERROR_NOT_AVAILABLE
      );
    }
    lazy.log.debug(`getReferrerUrl: guid: ${guid}`);

    // Second, fish the relevant record from the quarantine database.
    let url = "";
    try {
      url = await queryQuarantineDatabase(guid);
      lazy.log.debug(`getReferrerUrl: url: ${url}`);
    } catch (ex) {
      // This path is known to macOS but we failed to extract a referrer -- be noisy.
      throw new Components.Exception(
        `No macOS quarantine referrer URL found for ${path} with GUID ${guid}`,
        Cr.NS_ERROR_UNEXPECTED
      );
    }

    return url;
  },
};
