/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
 * 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 = {};

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.backgroundtasks.loglevel",
    prefix: "BackgroundTasksUtils",
  };
  return new ConsoleAPI(consoleOptions);
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "ProfileService",
  "@mozilla.org/toolkit/profile-service;1",
  "nsIToolkitProfileService"
);

ChromeUtils.defineESModuleGetters(lazy, {
  ASRouter:
    // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
    "resource:///modules/asrouter/ASRouter.sys.mjs",
  ASRouterDefaultConfig:
    // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
    "resource:///modules/asrouter/ASRouterDefaultConfig.sys.mjs",

  ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",

  RemoteSettingsExperimentLoader:
    "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs",
});

class CannotLockProfileError extends Error {
  constructor(message) {
    super(message);
    this.name = "CannotLockProfileError";
  }
}

export var BackgroundTasksUtils = {
  // Manage our own default profile that can be overridden for testing.  It's
  // easier to do this here rather than using the profile service itself.
  _defaultProfileInitialized: false,
  _defaultProfile: null,

  getDefaultProfile() {
    if (!this._defaultProfileInitialized) {
      this._defaultProfileInitialized = true;
      // This is all test-only.
      let defaultProfilePath = Services.env.get(
        "MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH"
      );
      let noDefaultProfile = Services.env.get(
        "MOZ_BACKGROUNDTASKS_NO_DEFAULT_PROFILE"
      );
      if (defaultProfilePath) {
        lazy.log.info(
          `getDefaultProfile: using default profile path ${defaultProfilePath}`
        );
        var tmpd = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
        tmpd.initWithPath(defaultProfilePath);
        // Sadly this writes to `profiles.ini`, but there's little to be done.
        this._defaultProfile = lazy.ProfileService.createProfile(
          tmpd,
          `MOZ_BACKGROUNDTASKS_DEFAULT_PROFILE_PATH-${Date.now()}`
        );
      } else if (noDefaultProfile) {
        lazy.log.info(`getDefaultProfile: setting default profile to null`);
        this._defaultProfile = null;
      } else {
        try {
          lazy.log.info(
            `getDefaultProfile: using ProfileService.defaultProfile`
          );
          this._defaultProfile = lazy.ProfileService.defaultProfile;
        } catch (e) {}
      }
    }
    return this._defaultProfile;
  },

  hasDefaultProfile() {
    return this.getDefaultProfile() != null;
  },

  currentProfileIsDefaultProfile() {
    let defaultProfile = this.getDefaultProfile();
    let currentProfile = lazy.ProfileService.currentProfile;
    // This comparison needs to accommodate null on both sides.
    let isDefaultProfile = defaultProfile && currentProfile == defaultProfile;
    return isDefaultProfile;
  },

  _throwIfNotLocked(lock) {
    if (!(lock instanceof Ci.nsIProfileLock)) {
      throw new Error("Passed lock was not an instance of nsIProfileLock");
    }

    try {
      // In release builds, `.directory` throws NS_ERROR_NOT_INITIALIZED when
      // unlocked.  In debug builds, `.directory` when the profile is not locked
      // will crash via `NS_ERROR`.
      if (lock.directory) {
        return;
      }
    } catch (e) {
      if (
        !(
          e instanceof Ci.nsIException &&
          e.result == Cr.NS_ERROR_NOT_INITIALIZED
        )
      ) {
        throw e;
      }
    }
    throw new Error("Profile is not locked");
  },

  /**
   * Locks the given profile and provides the path to it to the callback.
   * The callback should return a promise and once settled the profile is
   * unlocked and then the promise returned back to the caller of this function.
   *
   * @template T
   * @param {(lock: nsIProfileLock) => Promise<T>} callback
   * @param {nsIToolkitProfile} [profile] defaults to default profile
   * @return {Promise<T>}
   */
  async withProfileLock(callback, profile = this.getDefaultProfile()) {
    if (!profile) {
      throw new Error("No default profile exists");
    }

    let lock;
    try {
      lock = profile.lock({});
      lazy.log.info(
        `withProfileLock: locked profile at ${lock.directory.path}`
      );
    } catch (e) {
      throw new CannotLockProfileError(`Cannot lock profile: ${e}`);
    }

    try {
      // We must await to ensure any logging is displayed after the callback resolves.
      return await callback(lock);
    } finally {
      try {
        lazy.log.info(
          `withProfileLock: unlocking profile at ${lock.directory.path}`
        );
        lock.unlock();
        lazy.log.info(`withProfileLock: unlocked profile`);
      } catch (e) {
        lazy.log.warn(`withProfileLock: error unlocking profile`, e);
      }
    }
  },

  /**
   * Reads the preferences from "prefs.js" out of a profile, optionally
   * returning only names satisfying a given predicate.
   *
   * If no `lock` is given, the default profile is locked and the preferences
   * read from it.  If `lock` is given, read from the given lock's directory.
   *
   * @param {(name: string) => boolean} [predicate] a predicate to filter
   * preferences by; if not given, all preferences are accepted.
   * @param {nsIProfileLock} [lock] optional lock to use
   * @returns {object} with keys that are string preference names and values
   * that are string|number|boolean preference values.
   */
  async readPreferences(predicate = null, lock = null) {
    if (!lock) {
      return this.withProfileLock(profileLock =>
        this.readPreferences(predicate, profileLock)
      );
    }

    this._throwIfNotLocked(lock);
    lazy.log.info(`readPreferences: profile is locked`);

    let prefs = {};
    let addPref = (kind, name, value) => {
      if (predicate && !predicate(name)) {
        return;
      }
      prefs[name] = value;
    };

    // We ignore any "user.js" file, since usage is low and doing otherwise
    // requires implementing a bit more of `nsIPrefsService` than feels safe.
    let prefsFile = lock.directory.clone();
    prefsFile.append("prefs.js");
    lazy.log.info(`readPreferences: will parse prefs ${prefsFile.path}`);

    let data = await IOUtils.read(prefsFile.path);
    lazy.log.debug(
      `readPreferences: parsing prefs from buffer of length ${data.length}`
    );

    Services.prefs.parsePrefsFromBuffer(
      data,
      {
        onStringPref: addPref,
        onIntPref: addPref,
        onBoolPref: addPref,
        onError(message) {
          // Firefox itself manages "prefs.js", so errors should be infrequent.
          lazy.log.error(message);
        },
      },
      prefsFile.path
    );

    lazy.log.debug(`readPreferences: parsed prefs from buffer`, prefs);
    return prefs;
  },

  /**
   * Reads the snapshotted Firefox Messaging System targeting out of a profile.
   *
   * If no `lock` is given, the default profile is locked and the preferences
   * read from it.  If `lock` is given, read from the given lock's directory.
   *
   * @param {nsIProfileLock} [lock] optional lock to use
   * @returns {object}
   */
  async readFirefoxMessagingSystemTargetingSnapshot(lock = null) {
    if (!lock) {
      return this.withProfileLock(profileLock =>
        this.readFirefoxMessagingSystemTargetingSnapshot(profileLock)
      );
    }

    this._throwIfNotLocked(lock);

    let snapshotFile = lock.directory.clone();
    snapshotFile.append("targeting.snapshot.json");

    lazy.log.info(
      `readFirefoxMessagingSystemTargetingSnapshot: will read Firefox Messaging ` +
        `System targeting snapshot from ${snapshotFile.path}`
    );

    return IOUtils.readJSON(snapshotFile.path);
  },

  /**
   * Reads the Telemetry Client ID out of a profile.
   *
   * If no `lock` is given, the default profile is locked and the preferences
   * read from it.  If `lock` is given, read from the given lock's directory.
   *
   * @param {nsIProfileLock} [lock] optional lock to use
   * @returns {string}
   */
  async readTelemetryClientID(lock = null) {
    if (!lock) {
      return this.withProfileLock(profileLock =>
        this.readTelemetryClientID(profileLock)
      );
    }

    this._throwIfNotLocked(lock);

    let stateFile = lock.directory.clone();
    stateFile.append("datareporting");
    stateFile.append("state.json");

    lazy.log.info(
      `readPreferences: will read Telemetry client ID from ${stateFile.path}`
    );

    let state = await IOUtils.readJSON(stateFile.path);

    return state.clientID;
  },

  /**
   * Enable the Nimbus experimentation framework.
   *
   * @param {nsICommandLine} commandLine if given, accept command line parameters
   *                                     like `--url about:studies?...` or
   *                                     `--url file:path/to.json` to explicitly
   *                                     opt-on to experiment branches.
   * @param {object} defaultProfile      snapshot of Firefox Messaging System
   *                                     targeting from default browsing profile.
   */
  async enableNimbus(commandLine, defaultProfile = {}) {
    try {
      await lazy.ExperimentManager.onStartup({ defaultProfile });
    } catch (err) {
      lazy.log.error("Failed to initialize ExperimentManager:", err);
      throw err;
    }

    try {
      await lazy.RemoteSettingsExperimentLoader.init({ forceSync: true });
    } catch (err) {
      lazy.log.error(
        "Failed to initialize RemoteSettingsExperimentLoader:",
        err
      );
      throw err;
    }

    // Allow manual explicit opt-in to experiment branches to facilitate testing.
    //
    // Process command line arguments, like
    // `--url about:studies?optin_slug=nalexander-ms-test1&optin_branch=treatment-a&optin_collection=nimbus-preview`
    // or
    // `--url file:///Users/nalexander/Mozilla/gecko/experiment.json?optin_branch=treatment-a`.
    let ar;
    while ((ar = commandLine?.handleFlagWithParam("url", false))) {
      let uri = commandLine.resolveURI(ar);
      const params = new URLSearchParams(uri.query);

      if (uri.schemeIs("about") && uri.filePath == "studies") {
        // Allow explicit opt-in.  In the future, we might take this pref from
        // the default browsing profile.
        Services.prefs.setBoolPref("nimbus.debug", true);

        const data = {
          slug: params.get("optin_slug"),
          branch: params.get("optin_branch"),
          collection: params.get("optin_collection"),
        };
        await lazy.RemoteSettingsExperimentLoader.optInToExperiment(data);
        lazy.log.info(`Opted in to experiment: ${JSON.stringify(data)}`);
      }

      if (uri.schemeIs("file")) {
        let branchSlug = params.get("optin_branch");
        let path = decodeURIComponent(uri.filePath);
        let response = await fetch(uri.spec);
        let recipe = await response.json();
        if (recipe.permissions) {
          // Saved directly from Experimenter, there's a top-level `data`.  Hand
          // written, that's not the norm.
          recipe = recipe.data;
        }
        let branch = recipe.branches.find(b => b.slug == branchSlug);

        lazy.ExperimentManager.forceEnroll(recipe, branch);
        lazy.log.info(`Forced enrollment into: ${path}, branch: ${branchSlug}`);
      }
    }
  },

  /**
   * Enable the Firefox Messaging System and, when successfully initialized,
   * trigger a message with trigger id `backgroundTask`.
   *
   * @param {object} defaultProfile - snapshot of Firefox Messaging System
   *                                  targeting from default browsing profile.
   */
  async enableFirefoxMessagingSystem(defaultProfile = {}) {
    function logArgs(tag, ...args) {
      lazy.log.debug(`FxMS invoked ${tag}: ${JSON.stringify(args)}`);
    }

    let { messageHandler, router, createStorage } =
      lazy.ASRouterDefaultConfig();

    if (!router.initialized) {
      const storage = await createStorage();
      await router.init({
        storage,
        // Background tasks never send legacy telemetry.
        sendTelemetry: logArgs.bind(null, "sendTelemetry"),
        dispatchCFRAction: messageHandler.handleCFRAction.bind(messageHandler),
        // There's no child process involved in background tasks, so swallow all
        // of these messages.
        clearChildMessages: logArgs.bind(null, "clearChildMessages"),
        clearChildProviders: logArgs.bind(null, "clearChildProviders"),
        updateAdminState: () => {},
      });
    }

    await lazy.ASRouter.waitForInitialized;

    const triggerId = "backgroundTask";
    await lazy.ASRouter.sendTriggerMessage({
      browser: null,
      id: triggerId,
      context: {
        defaultProfile,
      },
    });
    lazy.log.info(
      "Triggered Firefox Messaging System with trigger id 'backgroundTask'"
    );
  },
};
