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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  ASRouterTargeting:
    // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
    "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
  BackgroundTasksUtils: "resource://gre/modules/BackgroundTasksUtils.sys.mjs",
  ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
  JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
  TaskScheduler: "resource://gre/modules/TaskScheduler.sys.mjs",
  UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
});

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: "app.update.background.loglevel",
    prefix: "BackgroundUpdate",
  };
  return new ConsoleAPI(consoleOptions);
});

ChromeUtils.defineLazyGetter(lazy, "localization", () => {
  return new Localization(
    ["branding/brand.ftl", "toolkit/updates/backgroundupdate.ftl"],
    true
  );
});

XPCOMUtils.defineLazyServiceGetters(lazy, {
  idleService: ["@mozilla.org/widget/useridleservice;1", Ci.nsIUserIdleService],
  UpdateService: [
    "@mozilla.org/updates/update-service;1",
    Ci.nsIApplicationUpdateService,
  ],
});

// We may want to change the definition of the task over time. When we do this,
// we need to remove and re-register the task. We will make sure this happens
// by storing the installed version number of the task to a pref and comparing
// that version number to the current version. If they aren't equal, we know
// that we have to re-register the task.
const TASK_DEF_CURRENT_VERSION = 4;
const TASK_INSTALLED_VERSION_PREF =
  "app.update.background.lastInstalledTaskVersion";

// This returns the version of the task naming scheme being used which
// is different from the task version used for the task definition.
function taskNameVersion(taskVersion) {
  if (AppConstants.platform != "win" || taskVersion < 4) {
    return 1;
  }
  return 2;
}

export var BackgroundUpdate = {
  QueryInterface: ChromeUtils.generateQI([
    "nsINamed",
    "nsIObserver",
    "nsITimerCallback",
  ]),
  name: "BackgroundUpdate",

  _initialized: false,

  get taskId() {
    let taskId = "backgroundupdate";
    if (AppConstants.platform == "win") {
      // In the future, we might lift this to TaskScheduler Win impl, so that
      // all tasks associated with this installation look consistent in the
      // Windows Task Scheduler UI.
      taskId = `${AppConstants.MOZ_APP_DISPLAYNAME_DO_NOT_USE} Background Update`;
    }
    return taskId;
  },

  async deleteTasksInRange(installedVersion, currentVersion) {
    for (
      let taskVersion = installedVersion;
      taskVersion <= currentVersion;
      taskVersion++
    ) {
      try {
        await lazy.TaskScheduler.deleteTask(this.taskId, {
          nameVersion: taskNameVersion(taskVersion),
        });
      } catch (e) {
        lazy.log.error(
          `deleteTasksInRange: Error deleting task ${taskVersion}: ${e}`
        );
      }
    }
  },

  /**
   * Whether this installation has an App and a GRE omnijar.
   *
   * Installations without an omnijar are generally developer builds and should not be updated.
   *
   * @returns {boolean} - true if this installation has an App and a GRE omnijar.
   */
  async _hasOmnijar() {
    const appOmniJar = PathUtils.join(
      Services.dirsvc.get("XCurProcD", Ci.nsIFile).path,
      AppConstants.OMNIJAR_NAME
    );
    const greOmniJar = PathUtils.join(
      Services.dirsvc.get("GreD", Ci.nsIFile).path,
      AppConstants.OMNIJAR_NAME
    );

    let bothExist =
      (await IOUtils.exists(appOmniJar)) && (await IOUtils.exists(greOmniJar));

    return bothExist;
  },

  _force() {
    // We want to allow developers and testers to monkey with the system.
    return Services.prefs.getBoolPref("app.update.background.force", false);
  },

  /**
   * Check eligibility criteria determining if this installation should be updated using the
   * background updater.
   *
   * These reasons should not factor in transient reasons, for example if there are currently multiple
   * Firefox instances running.
   *
   * Both the browser proper and the backgroundupdate background task invoke this function, so avoid
   * using profile specifics here.  Profile specifics that the background task specifically sources
   * from the default profile are available here.
   *
   * @returns [string] - descriptions of failed criteria; empty if all criteria were met.
   */
  async _reasonsToNotUpdateInstallation() {
    let SLUG = "_reasonsToNotUpdateInstallation";
    let reasons = [];

    lazy.log.debug(`${SLUG}: checking app.update.auto`);
    let updateAuto = await lazy.UpdateUtils.getAppUpdateAutoEnabled();
    if (!updateAuto) {
      reasons.push(this.REASON.NO_APP_UPDATE_AUTO);
    }

    lazy.log.debug(`${SLUG}: checking app.update.background.enabled`);
    let updateBackground = await lazy.UpdateUtils.readUpdateConfigSetting(
      "app.update.background.enabled"
    );
    if (!updateBackground) {
      reasons.push(this.REASON.NO_APP_UPDATE_BACKGROUND_ENABLED);
    }

    const bts =
      "@mozilla.org/backgroundtasks;1" in Cc &&
      Cc["@mozilla.org/backgroundtasks;1"].getService(Ci.nsIBackgroundTasks);

    lazy.log.debug(`${SLUG}: checking for MOZ_BACKGROUNDTASKS`);
    if (!AppConstants.MOZ_BACKGROUNDTASKS || !bts) {
      reasons.push(this.REASON.NO_MOZ_BACKGROUNDTASKS);
    }

    // The methods exposed by the update service named like `canX` answer the
    // question "can I do action X RIGHT NOW", where-as we want the variants
    // named like `canUsuallyX` to answer the question "can I usually do X, now
    // and in the future".
    let updateService = Cc["@mozilla.org/updates/update-service;1"].getService(
      Ci.nsIApplicationUpdateService
    );

    lazy.log.debug(
      `${SLUG}: checking that updates are not disabled by policy, testing ` +
        `configuration, or abnormal runtime environment`
    );
    if (!updateService.canUsuallyCheckForUpdates) {
      reasons.push(this.REASON.CANNOT_USUALLY_CHECK);
    }

    lazy.log.debug(
      `${SLUG}: checking that we can make progress: updates can stage and/or apply`
    );
    if (
      !updateService.canUsuallyStageUpdates &&
      !updateService.canUsuallyApplyUpdates
    ) {
      reasons.push(this.REASON.CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY);
    }

    lazy.log.debug(
      `${SLUG}: checking that we are on a supported OS (currently only Windows)`
    );
    if (AppConstants.platform != "win") {
      reasons.push(this.REASON.UNSUPPORTED_OS);
    }

    if (AppConstants.platform == "win") {
      lazy.log.debug(`${SLUG}: checking that we can usually use Windows BITS`);
      if (!updateService.canUsuallyUseBits) {
        // There's no technical reason to require BITS, but the experience
        // without BITS will be worse because the background tasks will run
        // while downloading, consuming valuable resources.
        reasons.push(this.REASON.WINDOWS_CANNOT_USUALLY_USE_BITS);
      }

      // Historically the background update process assumed the Mozilla
      // Maintenance Service was available and could update this installation.
      // We want to handle unelevated installations where this is not the case,
      // and for flexibility we are rolling this out behind a Nimbus feature.
      lazy.log.debug(
        `${SLUG}: checking that the Mozilla Maintenance Service Registry key exists, ` +
          `or that the unelevated installs are permitted`
      );
      let serviceRegKeyExists = false;
      try {
        serviceRegKeyExists = Cc["@mozilla.org/updates/update-processor;1"]
          .createInstance(Ci.nsIUpdateProcessor)
          .getServiceRegKeyExists();
      } catch (ex) {
        lazy.log.error(
          `${SLUG}: Failed to check for Maintenance Service Registry Key: ${ex}`
        );
      }

      if (!serviceRegKeyExists) {
        // A Nimbus rollout sets this preference and allows users with
        // unelevated installations to update in the background. For that to
        // work we use the setPref function to toggle a preference, because the
        // value for Nimbus is currently not readable in a backgroundtask. The
        // preference serves in that case as our communication channel.
        let allowUnelevated = await Services.prefs.getBoolPref(
          "app.update.background.allowUpdatesForUnelevatedInstallations"
        );

        if (!allowUnelevated) {
          // With the nimbus feature disabled and without the registry key we
          // do not want to attempt an update for unelevated installations.
          reasons.push(this.REASON.SERVICE_REGISTRY_KEY_MISSING);
        } else {
          // We record in telemetry, that the service registry key is missing
          // and the experiment is enabled. This is the first time that the
          // Nimbus feature could impact Firefox behaviour.
          lazy.NimbusFeatures.backgroundUpdate.recordExposureEvent();
          lazy.log.debug(
            `${SLUG}: ` +
              "expermiment active: trying to update unelevated installations."
          );

          // Now check if the path is writable. If not we are dealing with an
          // elevated installation and cannot update it without the service for
          // which the registry key is missing at this point.
          if (!updateService.isAppBaseDirWritable) {
            reasons.push(this.REASON.SERVICE_REGISTRY_KEY_MISSING);
            reasons.push(this.REASON.APPBASEDIR_NOT_WRITABLE);
          }
        }
      }
    }

    lazy.log.debug(`${SLUG}: checking that this installation has an omnijar`);
    if (!(await this._hasOmnijar())) {
      reasons.push(this.REASON.NO_OMNIJAR);
    }

    if (updateService.manualUpdateOnly) {
      reasons.push(this.REASON.MANUAL_UPDATE_ONLY);
    }

    await this._recordGleanMetrics(reasons);

    return reasons;
  },

  /**
   * Check if this particular profile should schedule tasks to update this installation using the
   * background updater.
   *
   * Only the browser proper should invoke this function, not background tasks, so this is the place
   * to use profile specifics.
   *
   * @returns [string] - descriptions of failed criteria; empty if all criteria were met.
   */
  async _reasonsToNotScheduleUpdates() {
    let SLUG = "_reasonsToNotScheduleUpdates";
    let reasons = [];

    const bts =
      "@mozilla.org/backgroundtasks;1" in Cc &&
      Cc["@mozilla.org/backgroundtasks;1"].getService(Ci.nsIBackgroundTasks);

    if (bts && bts.isBackgroundTaskMode) {
      throw new Components.Exception(
        `Not available in --backgroundtask mode`,
        Cr.NS_ERROR_NOT_AVAILABLE
      );
    }

    // No default profile happens under xpcshell but also when running local
    // builds.  It's unexpected in the wild so we track it separately.
    if (!lazy.BackgroundTasksUtils.hasDefaultProfile()) {
      reasons.push(this.REASON.NO_DEFAULT_PROFILE_EXISTS);
    }

    if (!lazy.BackgroundTasksUtils.currentProfileIsDefaultProfile()) {
      reasons.push(this.REASON.NOT_DEFAULT_PROFILE);
    }

    lazy.log.debug(`${SLUG}: checking app.update.langpack.enabled`);
    let updateLangpack = Services.prefs.getBoolPref(
      "app.update.langpack.enabled",
      true
    );
    if (updateLangpack) {
      lazy.log.debug(
        `${SLUG}: app.update.langpack.enabled=true, checking that no langpacks are installed`
      );

      let langpacks = await lazy.AddonManager.getAddonsByTypes(["locale"]);
      lazy.log.debug(`${langpacks.length} langpacks installed`);
      if (langpacks.length) {
        reasons.push(this.REASON.LANGPACK_INSTALLED);
      }
    }

    await this._recordGleanMetrics(reasons);

    return reasons;
  },

  /**
   * Register a background update task.
   *
   * @param {string} [taskId]
   *        The task identifier; defaults to the platform-specific background update task ID.
   * @return {object} non-null if the background task was registered.
   */
  async _registerBackgroundUpdateTask(taskId = this.taskId) {
    let binary = Services.dirsvc.get("XREExeF", Ci.nsIFile);
    let args = [
      "--MOZ_LOG",
      // Note: `maxsize:1` means 1Mb total size, trimmed to 512kb on overflow.
      "sync,prependheader,timestamp,append,maxsize:1,Dump:5",
      "--MOZ_LOG_FILE",
      // The full path might hit command line length limits, but also makes it
      // much easier to find the relevant log file when starting from the
      // Windows Task Scheduler UI.
      PathUtils.join(
        Services.dirsvc.get("UpdRootD", Ci.nsIFile).path,
        "backgroundupdate.moz_log"
      ),
      "--backgroundtask",
      "backgroundupdate",
    ];

    let workingDirectory = Services.dirsvc.get("UpdRootD", Ci.nsIFile).path;
    await IOUtils.makeDirectory(workingDirectory, { ignoreExisting: true });

    let description = await lazy.localization.formatValue(
      "backgroundupdate-task-description"
    );

    // Let the task run for a maximum of 20 minutes before the task scheduler
    // stops it.
    let executionTimeoutSec = 20 * 60;

    let result = await lazy.TaskScheduler.registerTask(
      taskId,
      binary.path,
      // Keep this default in sync with the preference in firefox.js.
      Services.prefs.getIntPref("app.update.background.interval", 60 * 60 * 7),
      {
        workingDirectory,
        args,
        description,
        executionTimeoutSec,
      }
    );

    Services.prefs.setIntPref(
      TASK_INSTALLED_VERSION_PREF,
      TASK_DEF_CURRENT_VERSION
    );

    return result;
  },

  /**
   * Background Update is controlled by the per-installation pref
   * "app.update.background.enabled". When Background Update was still in the
   * experimental phase, the default value of this pref may have been changed.
   * Now that the feature has been rolled out, we need to make sure that the
   * desired default value is restored.
   */
  async ensureExperimentToRolloutTransitionPerformed() {
    if (!lazy.UpdateUtils.PER_INSTALLATION_PREFS_SUPPORTED) {
      return;
    }
    const transitionPerformedPref = "app.update.background.rolledout";
    if (Services.prefs.getBoolPref(transitionPerformedPref, false)) {
      // writeUpdateConfigSetting serializes access to the config file. Because
      // of this, we can safely return here without worrying about another call
      // to this function that might still be in progress.
      return;
    }
    Services.prefs.setBoolPref(transitionPerformedPref, true);

    const defaultValue =
      lazy.UpdateUtils.PER_INSTALLATION_PREFS["app.update.background.enabled"]
        .defaultValue;
    await lazy.UpdateUtils.writeUpdateConfigSetting(
      "app.update.background.enabled",
      defaultValue,
      { setDefaultOnly: true }
    );

    // To be thorough, remove any traces of the pref that used to control the
    // default value that we just set. We don't want any users to have the
    // impression that that pref is still useful.
    Services.prefs.clearUserPref("app.update.background.scheduling.enabled");
  },

  observe(subject, topic, data) {
    let whatChanged;
    switch (topic) {
      case "idle-daily":
        this._snapshot.saveSoon();
        return;

      case "user-interaction-active":
        this._startTargetingSnapshottingTimer();
        Services.obs.removeObserver(this, "idle-daily");
        Services.obs.removeObserver(this, "user-interaction-active");
        lazy.log.debug(
          `observe: ${topic}; started targeting snapshotting timer`
        );
        return;

      case "nsPref:changed":
        whatChanged = `per-profile pref ${data}`;
        break;

      case "auto-update-config-change":
        whatChanged = `per-installation pref app.update.auto`;
        break;

      case "background-update-config-change":
        whatChanged = `per-installation pref app.update.background.enabled`;
        break;

      case "nimbus-update":
        whatChanged = `Nimbus ${data}`;
        break;
    }

    lazy.log.debug(
      `observe: ${whatChanged} may have changed; invoking maybeScheduleBackgroundUpdateTask`
    );
    this.maybeScheduleBackgroundUpdateTask();
  },

  /**
   * Maybe schedule (or unschedule) background tasks using OS-level task scheduling mechanisms.
   *
   * @return {boolean} true if a task is now scheduled, false otherwise.
   */
  async maybeScheduleBackgroundUpdateTask() {
    let SLUG = "maybeScheduleBackgroundUpdateTask";

    await this.ensureExperimentToRolloutTransitionPerformed();

    lazy.log.info(
      `${SLUG}: checking eligibility before scheduling background update task`
    );

    // datetime with an empty parameter records 'now'
    Glean.backgroundUpdate.timeLastUpdateScheduled.set();

    let previousEnabled;
    let successfullyReadPrevious;
    try {
      previousEnabled = await lazy.TaskScheduler.taskExists(this.taskId);
      successfullyReadPrevious = true;
    } catch (ex) {
      successfullyReadPrevious = false;
    }

    const previousReasons = Services.prefs.getCharPref(
      "app.update.background.previous.reasons",
      null
    );

    if (!this._initialized) {
      Services.obs.addObserver(this, "auto-update-config-change");
      Services.obs.addObserver(this, "background-update-config-change");

      // Witness when our own prefs change.
      Services.prefs.addObserver("app.update.background.force", this);
      Services.prefs.addObserver("app.update.background.interval", this);
      lazy.NimbusFeatures.backgroundUpdate.onUpdate((event, reason) => {
        this.observe(null, "nimbus-update", reason);
      });

      // Witness when the langpack updating feature is changed.
      Services.prefs.addObserver("app.update.langpack.enabled", this);

      // Witness when langpacks come and go.
      const onAddonEvent = async addon => {
        if (addon.type != "locale") {
          return;
        }
        lazy.log.debug(
          `${SLUG}: langpacks may have changed; invoking maybeScheduleBackgroundUpdateTask`
        );
        // No need to await this promise.
        this.maybeScheduleBackgroundUpdateTask();
      };
      const addonsListener = {
        onEnabled: onAddonEvent,
        onDisabled: onAddonEvent,
        onInstalled: onAddonEvent,
        onUninstalled: onAddonEvent,
      };
      lazy.AddonManager.addAddonListener(addonsListener);

      this._initialized = true;
    }

    lazy.log.debug(
      `${SLUG}: checking for reasons to not update this installation`
    );
    let reasons = await this._reasonsToNotUpdateInstallation();

    lazy.log.debug(
      `${SLUG}: checking for reasons to not schedule background updates with this profile`
    );
    let moreReasons = await this._reasonsToNotScheduleUpdates();
    reasons.push(...moreReasons);

    let enabled = !reasons.length;

    if (this._force()) {
      // We want to allow developers and testers to monkey with the system.
      lazy.log.debug(
        `${SLUG}: app.update.background.force=true, ignoring reasons: ${JSON.stringify(
          reasons
        )}`
      );
      reasons = [];
      enabled = true;
    }

    let updatePreviousPrefs = () => {
      if (reasons.length) {
        Services.prefs.setCharPref(
          "app.update.background.previous.reasons",
          JSON.stringify(reasons)
        );
      } else {
        Services.prefs.clearUserPref("app.update.background.previous.reasons");
      }
    };

    try {
      // Interacting with `TaskScheduler.sys.mjs` can throw, so we'll catch.
      if (!enabled) {
        lazy.log.info(
          `${SLUG}: not scheduling background update: '${JSON.stringify(
            reasons
          )}'`
        );

        if (!successfullyReadPrevious || previousEnabled) {
          let installedVersion = Services.prefs.getIntPref(
            TASK_INSTALLED_VERSION_PREF,
            TASK_DEF_CURRENT_VERSION
          );
          await this.deleteTasksInRange(
            installedVersion,
            TASK_DEF_CURRENT_VERSION
          );
          lazy.log.debug(
            `${SLUG}: witnessed falling (enabled -> disabled) edge; deleted task ${this.taskId}.`
          );
        }

        updatePreviousPrefs();

        return false;
      }

      if (successfullyReadPrevious && previousEnabled) {
        let taskInstalledVersion = Services.prefs.getIntPref(
          TASK_INSTALLED_VERSION_PREF,
          1
        );
        if (taskInstalledVersion == TASK_DEF_CURRENT_VERSION) {
          lazy.log.info(
            `${SLUG}: background update was previously enabled; not registering task.`
          );

          return true;
        }
        lazy.log.info(
          `${SLUG}: Detected task version change from ` +
            `${taskInstalledVersion} to ${TASK_DEF_CURRENT_VERSION}. ` +
            `Removing task so the new version can be registered`
        );
        try {
          let installedVersion = Services.prefs.getIntPref(
            TASK_INSTALLED_VERSION_PREF,
            TASK_DEF_CURRENT_VERSION
          );
          await this.deleteTasksInRange(
            installedVersion,
            TASK_DEF_CURRENT_VERSION
          );
        } catch (e) {
          lazy.log.error(`${SLUG}: Error removing old task: ${e}`);
        }
        try {
          // When the update directory was moved, we migrated the old contents
          // to the new location. This can potentially happen in a background
          // task. However, we also need to re-register the background task
          // with the task scheduler in order to update the MOZ_LOG_FILE value
          // to point to the new location. If the task runs before Firefox has
          // a chance to re-register the task, the log file may be recreated in
          // the old location. In practice, this would be unusual, because
          // MOZ_LOG_FILE will not create the parent directories necessary to
          // put a log file in the specified location. But just to be safe,
          // we'll do some cleanup when we re-register the task to make sure
          // that no log file is hanging around in the old location.
          let oldUpdateDir = Services.dirsvc.get(
            "OldUpdRootD",
            Ci.nsIFile
          ).path;
          let oldLog = PathUtils.join(oldUpdateDir, "backgroundupdate.moz_log");

          if (await IOUtils.exists(oldLog)) {
            try {
              await IOUtils.remove(oldLog);
              // We may have created some directories in order to put this log
              // file in this location. Clean them up if they are empty.
              //
              // Potentially removes "C:\ProgramData\Mozilla\updates\<hash>"
              await IOUtils.remove(oldUpdateDir);
              // Potentially removes "C:\ProgramData\Mozilla\updates"
              await IOUtils.remove(PathUtils.parent(oldUpdateDir));
              // Potentially removes "C:\ProgramData\Mozilla"
              await IOUtils.remove(PathUtils.parent(oldUpdateDir, 2));
            } catch (e) {
              if (
                !(
                  DOMException.isInstance(e) &&
                  e.name === "OperationError" &&
                  e.message.includes(
                    "Could not remove the non-empty directory at"
                  )
                )
              ) {
                throw e;
              }
            }
          }
        } catch (ex) {
          lazy.log.warn(
            `${SLUG}: Ignoring error encountered attempting to remove stale log file: ${ex}`
          );
        }
      }

      lazy.log.info(
        `${SLUG}: background update was previously disabled for reasons: '${previousReasons}'`
      );

      await this._registerBackgroundUpdateTask(this.taskId);
      lazy.log.info(
        `${SLUG}: witnessed rising (disabled -> enabled) edge; registered task ${this.taskId}`
      );

      updatePreviousPrefs();

      return true;
    } catch (e) {
      lazy.log.error(
        `${SLUG}: exiting after uncaught exception in maybeScheduleBackgroundUpdateTask!`,
        e
      );

      return false;
    }
  },

  /**
   * Record parts of the update environment for our custom Glean ping.
   *
   * This is just like the Telemetry Environment, but pared down to what we're
   * likely to use in background update-specific analyses.
   *
   * Right now this is only for use in the background update task, but after Bug
   * 1703313 (migrating AUS telemetry to be Glean-aware) we might use it more
   * generally.
   */
  async recordUpdateEnvironment() {
    try {
      Glean.update.serviceEnabled.set(
        Services.prefs.getBoolPref("app.update.service.enabled", false)
      );
    } catch (e) {
      // It's fine if some or all of these are missing.
    }

    // In the background update task, this should always be enabled, but let's
    // find out if there's an error in the system.
    Glean.update.autoDownload.set(
      await lazy.UpdateUtils.getAppUpdateAutoEnabled()
    );
    Glean.update.backgroundUpdate.set(
      await lazy.UpdateUtils.readUpdateConfigSetting(
        "app.update.background.enabled"
      )
    );

    Glean.update.channel.set(lazy.UpdateUtils.UpdateChannel);
    Glean.update.enabled.set(
      !Services.policies || Services.policies.isAllowed("appUpdate")
    );

    Glean.update.canUsuallyApplyUpdates.set(
      lazy.UpdateService.canUsuallyApplyUpdates
    );
    Glean.update.canUsuallyCheckForUpdates.set(
      lazy.UpdateService.canUsuallyCheckForUpdates
    );
    Glean.update.canUsuallyStageUpdates.set(
      lazy.UpdateService.canUsuallyStageUpdates
    );
    Glean.update.canUsuallyUseBits.set(lazy.UpdateService.canUsuallyUseBits);
  },

  /**
   * Schedule periodic snapshotting of the Firefox Messaging System
   * targeting configuration.
   *
   * The background update task will target messages based on the
   * latest snapshot of the default profile's targeting configuration.
   */
  async scheduleFirefoxMessagingSystemTargetingSnapshotting() {
    let SLUG = "scheduleFirefoxMessagingSystemTargetingSnapshotting";
    let path = PathUtils.join(PathUtils.profileDir, "targeting.snapshot.json");

    let snapshot = new lazy.JSONFile({
      beforeSave: async () => {
        if (Services.startup.shuttingDown) {
          // Collecting targeting information can be slow and cause shutdown
          // crashes.  Just write what we have in that case.  During shutdown,
          // the regular log apparatus is not available, so use `dump`.
          if (lazy.log.shouldLog("debug")) {
            dump(
              `${SLUG}: shutting down, so not updating Firefox Messaging System targeting information from beforeSave\n`
            );
          }
          return;
        }

        lazy.log.debug(
          `${SLUG}: preparing to write Firefox Messaging System targeting information to ${path}`
        );

        // Merge latest data into existing data.  This data may be partial, due
        // to runtime errors and abbreviated collections, especially when
        // shutting down.  We accept the risk of incomplete or even internally
        // inconsistent data: it's generally better to have stale data (and
        // potentially target a user as they appeared in the past) than to block
        // shutdown for more accurate results.  An alternate approach would be
        // to restrict the targeting data collected, but it's hard to
        // distinguish expensive collection operations and the system loses
        // flexibility when restrictions of this type are added.
        let latestData = await lazy.ASRouterTargeting.getEnvironmentSnapshot({
          targets: [
            lazy.ExperimentAPI.manager.createTargetingContext(),
            lazy.ASRouterTargeting.Environment,
          ],
        });
        // We expect to always have data, but: belt-and-braces.
        if (snapshot?.data?.environment) {
          Object.assign(snapshot.data.environment, latestData.environment);
        } else {
          snapshot.data = latestData;
        }
      },
      path,
    });

    // We don't `load`, since we don't care about reading existing (now stale)
    // data.
    snapshot.data = await lazy.ASRouterTargeting.getEnvironmentSnapshot(
      lazy.ASRouterTargeting.Environment,
      lazy.ExperimentAPI.manager.createTargetingContext()
    );

    // Persist.
    snapshot.saveSoon();

    this._snapshot = snapshot;

    // Continue persisting periodically.  `JSONFile.sys.mjs` will also persist one
    // last time before shutdown.
    // Hold a reference to prevent GC.
    this._targetingSnapshottingTimer = Cc[
      "@mozilla.org/timer;1"
    ].createInstance(Ci.nsITimer);
    // By default, snapshot Firefox Messaging System targeting for use by the
    // background update task every 60 minutes.
    this._targetingSnapshottingTimerIntervalSec = Services.prefs.getIntPref(
      "app.update.background.messaging.targeting.snapshot.intervalSec",
      3600
    );
    this._startTargetingSnapshottingTimer();
  },

  // nsITimerCallback
  notify() {
    const SLUG = "_targetingSnapshottingTimerCallback";

    if (Services.startup.shuttingDown) {
      // Collecting targeting information can be slow and cause shutdown
      // crashes, so if we're shutting down, don't try to collect.  During
      // shutdown, the regular log apparatus is not available, so use `dump`.
      if (lazy.log.shouldLog("debug")) {
        dump(
          `${SLUG}: shutting down, so not updating Firefox Messaging System targeting information from timer\n`
        );
      }
      return;
    }

    this._snapshot.saveSoon();

    if (
      lazy.idleService.idleTime >
      this._targetingSnapshottingTimerIntervalSec * 1000
    ) {
      lazy.log.debug(
        `${SLUG}: idle time longer than interval, adding observers`
      );
      Services.obs.addObserver(this, "idle-daily");
      Services.obs.addObserver(this, "user-interaction-active");
    } else {
      lazy.log.debug(`${SLUG}: restarting timer`);
      this._startTargetingSnapshottingTimer();
    }
  },

  _startTargetingSnapshottingTimer() {
    this._targetingSnapshottingTimer.initWithCallback(
      this,
      this._targetingSnapshottingTimerIntervalSec * 1000,
      Ci.nsITimer.TYPE_ONE_SHOT
    );
  },

  /**
   * Reads the snapshotted Firefox Messaging System targeting out of a profile.
   * Collects background update specific telemetry.  Never throws.
   *
   * 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} possibly empty targeting snapshot.
   */
  async readFirefoxMessagingSystemTargetingSnapshot(lock = null) {
    let SLUG = "readFirefoxMessagingSystemTargetingSnapshot";

    let defaultProfileTargetingSnapshot = {};

    Glean.backgroundUpdate.targetingExists.set(false);
    Glean.backgroundUpdate.targetingException.set(true);
    try {
      defaultProfileTargetingSnapshot =
        await lazy.BackgroundTasksUtils.readFirefoxMessagingSystemTargetingSnapshot(
          lock
        );
      Glean.backgroundUpdate.targetingExists.set(true);
      Glean.backgroundUpdate.targetingException.set(false);

      if (defaultProfileTargetingSnapshot?.version) {
        Glean.backgroundUpdate.targetingVersion.set(
          defaultProfileTargetingSnapshot.version
        );
      }

      let environment = defaultProfileTargetingSnapshot?.environment;
      if (environment) {
        if (environment.firefoxVersion) {
          Glean.backgroundUpdate.targetingEnvFirefoxVersion.set(
            environment.firefoxVersion
          );
        }
        if (environment.currentDate) {
          Glean.backgroundUpdate.targetingEnvCurrentDate.set(
            // Glean date times are provided in nanoseconds, `getTime()` yields
            // milliseconds (after the Unix epoch).
            new Date(environment.currentDate).getTime() * 1000
          );
        }
        if (environment.profileAgeCreated) {
          Glean.backgroundUpdate.targetingEnvProfileAge.set(
            // Glean date times are provided in nanoseconds, `profileAgeCreated`
            // is in milliseconds (after the Unix epoch).
            environment.profileAgeCreated * 1000
          );
        }

        // Experiment details.
        let activeExperiments = (
          environment.activeExperiments || []
        ).toSorted();
        let activeRollouts = (environment.activeRollouts || []).toSorted();
        let previousExperiments = (
          environment.previousExperiments || []
        ).toSorted();
        let previousRollouts = (environment.previousRollouts || []).toSorted();

        // Add default profile experiments to background task profile Glean experiments.
        for (let slug of Object.keys(environment.enrollmentsMap || [])) {
          let branch = environment.enrollmentsMap[slug];
          let source = "defaultProfile";

          // Experiments have type "nimbus-nimbus", rollouts type "nimbus-rollout".
          let type;
          if (
            activeExperiments.includes(slug) ||
            previousExperiments.includes(slug)
          ) {
            type = "nimbus-nimbus";
          } else if (
            activeRollouts.includes(slug) ||
            previousRollouts.includes(slug)
          ) {
            type = "nimbus-rollout";
          } else {
            // This shouldn't happen, but it's not worth failing.
            lazy.log.warn(
              `${SLUG}: enrollment not recognized as experiment or rollout: '${slug}'`
            );
            type = "nimbus-unexpected";
          }

          let extras = { type, source };
          Services.fog.setExperimentActive(slug, branch, extras);

          if (
            previousExperiments.includes(slug) ||
            previousRollouts.includes(slug)
          ) {
            Services.fog.setExperimentInactive(slug, branch, extras);
          }
        }
      }
    } catch (f) {
      if (DOMException.isInstance(f) && f.name === "NotFoundError") {
        Glean.backgroundUpdate.targetingException.set(false);
        lazy.log.info(`${SLUG}: no default profile targeting snapshot exists`);
      } else {
        lazy.log.warn(
          `${SLUG}: ignoring exception reading default profile targeting snapshot`,
          f
        );
      }
    }

    return defaultProfileTargetingSnapshot;
  },

  /**
   * Local helper function to record all reasons why the background updater is
   * not used with Glean. This function will only track the first 20 reasons.
   *
   * @param {[string]} [reasons]
   *        a list of BackgroundUpdate.REASON values (=> string)
   */
  async _recordGleanMetrics(reasons) {
    // Record Glean metrics with all the reasons why the update was impossible.
    for (const [key, value] of Object.entries(this.REASON)) {
      if (reasons.includes(value)) {
        Glean.backgroundUpdate.reasonsToNotUpdate.add(key);
      }
    }
  },

  getDaysSinceLastBrowsed(
    defaultProfileTargetingSnapshot,
    { nowSeconds = Math.floor(Date.now() / 1000) } = {}
  ) {
    let environment = defaultProfileTargetingSnapshot?.environment;
    if (!environment || !environment.currentDate) {
      return 0;
    }

    let secondsSince =
      nowSeconds -
      Math.floor(new Date(environment.currentDate).getTime() / 1000);
    let daysSince = Math.floor(secondsSince / (60 * 60 * 24));
    return Math.max(-1, daysSince);
  },

  /**
   * Runs the specified function with the actions that ought to be performed
   * as arguments.
   *
   * @param  {object}
   *         A config object with these keys:
   *           defaultProfileTargetingSnapshot
   *             The snapshotted Firefox Messaging System targeting from the
   *             profile.
   *           nowSeconds
   *             Optional. The current epoch time in seconds. If specified, this
   *             will be used as the current time. If not, this will calculate
   *             the current time itself.
   * @param  {callbackFn}
   *         The function to call. It will be passed a single argument: the Set
   *         of `Action`s that ought to be performed. This will be called
   *         exactly once by this function.
   * @return The value that `callbackFn` returned.
   */
  async withActionsToPerform(
    {
      defaultProfileTargetingSnapshot,
      nowSeconds = Math.floor(Date.now() / 1000),
    },
    callbackFn
  ) {
    let SLUG = "withActionsToPerform";

    let actionSet = new Set();

    let throttled = false;
    let debounced = false;

    // The last time the task ran without throwing or being debounced.
    let lastTaskRunSeconds = Services.prefs.getIntPref(
      "app.update.background.lastTaskRunSecondsAfterEpoch",
      0
    );

    let daysSinceLastBrowsed = BackgroundUpdate.getDaysSinceLastBrowsed(
      defaultProfileTargetingSnapshot,
      { nowSeconds }
    );
    // A convenience: it's possible to determine this from existing data, but it
    // requires non-trivial date/time calculations.  This is simpler and should
    // be more robust.
    Glean.backgroundUpdate.daysSinceLastBrowsed.set(daysSinceLastBrowsed);

    let throttleFeature = lazy.NimbusFeatures.backgroundUpdateCheckPolicy;
    if (throttleFeature.getVariable("throttleEnabled")) {
      let throttleAfterDays = throttleFeature.getVariable("throttleAfterDays");

      if (throttleAfterDays >= 0 && daysSinceLastBrowsed > throttleAfterDays) {
        lazy.log.warn(
          `${SLUG}: last browsed ${daysSinceLastBrowsed} days ago > ${throttleAfterDays} days ago, throttled`
        );
        throttled = true;
      } else {
        lazy.log.info(
          `${SLUG}: last browsed ${daysSinceLastBrowsed} days ago < ${throttleAfterDays} days ago, not throttled`
        );
      }

      if (throttled) {
        let throttleDebouncePeriodInHours = throttleFeature.getVariable(
          "throttleDebouncePeriodInHours"
        );

        let hoursSinceLastTask = Math.floor(
          Math.max(nowSeconds - lastTaskRunSeconds, 0) / (60 * 60)
        );

        lazy.log.debug(
          `${SLUG}: throttled, checking debounce: last ran task ${hoursSinceLastTask} hours ago`
        );

        if (
          throttleDebouncePeriodInHours > 0 &&
          hoursSinceLastTask < throttleDebouncePeriodInHours
        ) {
          lazy.log.warn(
            `${SLUG}: last ran task ${hoursSinceLastTask} hours ago < ${throttleDebouncePeriodInHours} hours ago, debounced`
          );
          debounced = true;
        } else {
          lazy.log.info(
            `${SLUG}: last ran task ${hoursSinceLastTask} hours ago >= ${throttleDebouncePeriodInHours} hours ago, not debounced`
          );
        }
      }
    } else {
      lazy.log.warn(`${SLUG}: throttle feature not enabled`);
    }

    // Throttled will be reported each time we submit.
    Glean.backgroundUpdate.throttled.set(throttled);
    // Debounced will be accumulated until we submit.
    if (debounced) {
      Glean.backgroundUpdate.debounced.add();
    }

    let taskCompletedCallback = () => {};

    if (!debounced) {
      actionSet.add(BackgroundUpdate.ACTION.EXPERIMENTER);
      actionSet.add(BackgroundUpdate.ACTION.SUBMIT_PING);

      // It's possible for the background update task to crash after this point.
      // We don't want to debounce crashing tasks prematurely, so we provide a
      // "task completed" callback to invoke that updates the state.  The
      // callback captures `nowSeconds` so that it is deterministic.
      taskCompletedCallback = () => {
        Services.prefs.setIntPref(
          "app.update.background.lastTaskRunSecondsAfterEpoch",
          nowSeconds
        );
        lazy.log.warn(
          `${SLUG}: setting last task run timestamp to ${nowSeconds} after the Unix epoch (${Date(nowSeconds * 1000)})`
        );
      };

      if (throttled) {
        actionSet.add(BackgroundUpdate.ACTION.UPDATE_CHECK);
      } else {
        actionSet.add(BackgroundUpdate.ACTION.UPDATE);
      }
    }

    let d = Array.from(actionSet).toSorted();
    lazy.log.warn(`${SLUG}: actions to take: ${JSON.stringify(d)}`);

    let result = await Promise.try(callbackFn, actionSet);

    // The task is considered completed only if and only if the callback
    // function exits without throwing/rejecting.
    taskCompletedCallback();

    return result;
  },
};

BackgroundUpdate.REASON = {
  CANNOT_USUALLY_CHECK:
    "cannot usually check for updates due to policy, testing configuration, or runtime environment",
  CANNOT_USUALLY_STAGE_AND_CANNOT_USUALLY_APPLY:
    "updates cannot usually stage and cannot usually apply",
  LANGPACK_INSTALLED:
    "app.update.langpack.enabled=true and at least one langpack is installed",
  MANUAL_UPDATE_ONLY: "the ManualAppUpdateOnly policy is enabled",
  NO_DEFAULT_PROFILE_EXISTS: "no default profile exists",
  NOT_DEFAULT_PROFILE: "not default profile",
  NO_APP_UPDATE_AUTO: "app.update.auto=false",
  NO_APP_UPDATE_BACKGROUND_ENABLED: "app.update.background.enabled=false",
  NO_MOZ_BACKGROUNDTASKS: "MOZ_BACKGROUNDTASKS=0",
  NO_OMNIJAR: "no omnijar",
  SERVICE_REGISTRY_KEY_MISSING:
    "the maintenance service registry key is not present",
  UNSUPPORTED_OS: "unsupported OS",
  WINDOWS_CANNOT_USUALLY_USE_BITS: "on Windows but cannot usually use BITS",
  APPBASEDIR_NOT_WRITABLE: "the base directory is not writable",
};

/**
 * Specific exit codes for `--backgroundtask backgroundupdate`.
 *
 * These help distinguish common failure cases.  In particular, they distinguish
 * "default profile does not exist" from "default profile cannot be locked" from
 * more general errors reading from the default profile.
 */
BackgroundUpdate.EXIT_CODE = {
  ...EXIT_CODE,
  // We clone the other exit codes simply so we can use one object for all the codes.
  DEFAULT_PROFILE_DOES_NOT_EXIST: 11,
  DEFAULT_PROFILE_CANNOT_BE_LOCKED: 12,
  DEFAULT_PROFILE_CANNOT_BE_READ: 13,
  // Another instance is running.
  OTHER_INSTANCE: 21,
};

BackgroundUpdate.ACTION = {
  // Enables Nimbus and the Firefox Messaging System in order to potentially
  // show a toast notification to the user.
  EXPERIMENTER: "ACTION_EXPERIMENTER",
  // Causes telemetry to be sent by this Background Update Task invocation.
  SUBMIT_PING: "ACTION_SUBMIT_PING",
  // This is basically the core action of the Background Update Task. This
  // action pumps the update loop. If an update is ready and the configuration
  // has enabled this, the browser may restart as a result of performing this
  // action.
  UPDATE: "ACTION_UPDATE",
  // This is an alternate version of the `UPDATE` action that checks for updates
  // but only installs them if Balrog specifies an optional forcing flag.
  UPDATE_CHECK: "ACTION_UPDATE_CHECK",
};
