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

/*
 * This file defines the add-on sync functionality.
 *
 * There are currently a number of known limitations:
 *  - We only sync XPI extensions and themes available from addons.mozilla.org.
 *    We hope to expand support for other add-ons eventually.
 *  - We only attempt syncing of add-ons between applications of the same type.
 *    This means add-ons will not synchronize between Firefox desktop and
 *    Firefox mobile, for example. This is because of significant add-on
 *    incompatibility between application types.
 *
 * Add-on records exist for each known {add-on, app-id} pair in the Sync client
 * set. Each record has a randomly chosen GUID. The records then contain
 * basic metadata about the add-on.
 *
 * We currently synchronize:
 *
 *  - Installations
 *  - Uninstallations
 *  - User enabling and disabling
 *
 * Synchronization is influenced by the following preferences:
 *
 *  - services.sync.addons.ignoreUserEnabledChanges
 *  - services.sync.addons.trustedSourceHostnames
 *
 *  and also influenced by whether addons have repository caching enabled and
 *  whether they allow installation of addons from insecure options (both of
 *  which are themselves influenced by the "extensions." pref branch)
 *
 * See the documentation in all.js for the behavior of these prefs.
 */

import { AddonUtils } from "resource://services-sync/addonutils.sys.mjs";
import { AddonsReconciler } from "resource://services-sync/addonsreconciler.sys.mjs";
import {
  Store,
  SyncEngine,
  LegacyTracker,
} from "resource://services-sync/engines.sys.mjs";
import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
import { Svc, Utils } from "resource://services-sync/util.sys.mjs";

import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
import { CollectionValidator } from "resource://services-sync/collection_validator.sys.mjs";

const lazy = {};

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

// 7 days in milliseconds.
const PRUNE_ADDON_CHANGES_THRESHOLD = 60 * 60 * 24 * 7 * 1000;

/**
 * AddonRecord represents the state of an add-on in an application.
 *
 * Each add-on has its own record for each application ID it is installed
 * on.
 *
 * The ID of add-on records is a randomly-generated GUID. It is random instead
 * of deterministic so the URIs of the records cannot be guessed and so
 * compromised server credentials won't result in disclosure of the specific
 * add-ons present in a Sync account.
 *
 * The record contains the following fields:
 *
 *  addonID
 *    ID of the add-on. This correlates to the "id" property on an Addon type.
 *
 *  applicationID
 *    The application ID this record is associated with.
 *
 *  enabled
 *    Boolean stating whether add-on is enabled or disabled by the user.
 *
 *  source
 *    String indicating where an add-on is from. Currently, we only support
 *    the value "amo" which indicates that the add-on came from the official
 *    add-ons repository, addons.mozilla.org. In the future, we may support
 *    installing add-ons from other sources. This provides a future-compatible
 *    mechanism for clients to only apply records they know how to handle.
 */
function AddonRecord(collection, id) {
  CryptoWrapper.call(this, collection, id);
}
AddonRecord.prototype = {
  _logName: "Record.Addon",
};
Object.setPrototypeOf(AddonRecord.prototype, CryptoWrapper.prototype);

Utils.deferGetSet(AddonRecord, "cleartext", [
  "addonID",
  "applicationID",
  "enabled",
  "source",
]);

/**
 * The AddonsEngine handles synchronization of add-ons between clients.
 *
 * The engine maintains an instance of an AddonsReconciler, which is the entity
 * maintaining state for add-ons. It provides the history and tracking APIs
 * that AddonManager doesn't.
 *
 * The engine instance overrides a handful of functions on the base class. The
 * rationale for each is documented by that function.
 */
export function AddonsEngine(service) {
  SyncEngine.call(this, "Addons", service);

  this._reconciler = new AddonsReconciler(this._tracker.asyncObserver);
}

AddonsEngine.prototype = {
  _storeObj: AddonsStore,
  _trackerObj: AddonsTracker,
  _recordObj: AddonRecord,
  version: 1,

  syncPriority: 5,

  _reconciler: null,

  async initialize() {
    await SyncEngine.prototype.initialize.call(this);
    await this._reconciler.ensureStateLoaded();
  },

  /**
   * Override parent method to find add-ons by their public ID, not Sync GUID.
   */
  async _findDupe(item) {
    let id = item.addonID;

    // The reconciler should have been updated at the top of the sync, so we
    // can assume it is up to date when this function is called.
    let addons = this._reconciler.addons;
    if (!(id in addons)) {
      return null;
    }

    let addon = addons[id];
    if (addon.guid != item.id) {
      return addon.guid;
    }

    return null;
  },

  /**
   * Override getChangedIDs to pull in tracker changes plus changes from the
   * reconciler log.
   */
  async getChangedIDs() {
    let changes = {};
    const changedIDs = await this._tracker.getChangedIDs();
    for (let [id, modified] of Object.entries(changedIDs)) {
      changes[id] = modified;
    }

    let lastSync = await this.getLastSync();
    let lastSyncDate = new Date(lastSync * 1000);

    // The reconciler should have been refreshed at the beginning of a sync and
    // we assume this function is only called from within a sync.
    let reconcilerChanges = this._reconciler.getChangesSinceDate(lastSyncDate);
    let addons = this._reconciler.addons;
    for (let change of reconcilerChanges) {
      let changeTime = change[0];
      let id = change[2];

      if (!(id in addons)) {
        continue;
      }

      // Keep newest modified time.
      if (id in changes && changeTime < changes[id]) {
        continue;
      }

      if (!(await this.isAddonSyncable(addons[id]))) {
        continue;
      }

      this._log.debug("Adding changed add-on from changes log: " + id);
      let addon = addons[id];
      changes[addon.guid] = changeTime.getTime() / 1000;
    }

    return changes;
  },

  /**
   * Override start of sync function to refresh reconciler.
   *
   * Many functions in this class assume the reconciler is refreshed at the
   * top of a sync. If this ever changes, those functions should be revisited.
   *
   * Technically speaking, we don't need to refresh the reconciler on every
   * sync since it is installed as an AddonManager listener. However, add-ons
   * are complicated and we force a full refresh, just in case the listeners
   * missed something.
   */
  async _syncStartup() {
    // We refresh state before calling parent because syncStartup in the parent
    // looks for changed IDs, which is dependent on add-on state being up to
    // date.
    await this._refreshReconcilerState();
    return SyncEngine.prototype._syncStartup.call(this);
  },

  /**
   * Override end of sync to perform a little housekeeping on the reconciler.
   *
   * We prune changes to prevent the reconciler state from growing without
   * bound. Even if it grows unbounded, there would have to be many add-on
   * changes (thousands) for it to slow things down significantly. This is
   * highly unlikely to occur. Still, we exercise defense just in case.
   */
  async _syncCleanup() {
    let lastSync = await this.getLastSync();
    let ms = 1000 * lastSync - PRUNE_ADDON_CHANGES_THRESHOLD;
    this._reconciler.pruneChangesBeforeDate(new Date(ms));
    return SyncEngine.prototype._syncCleanup.call(this);
  },

  /**
   * Helper function to ensure reconciler is up to date.
   *
   * This will load the reconciler's state from the file
   * system (if needed) and refresh the state of the reconciler.
   */
  async _refreshReconcilerState() {
    this._log.debug("Refreshing reconciler state");
    return this._reconciler.refreshGlobalState();
  },

  // Returns a promise
  isAddonSyncable(addon, ignoreRepoCheck) {
    return this._store.isAddonSyncable(addon, ignoreRepoCheck);
  },
};
Object.setPrototypeOf(AddonsEngine.prototype, SyncEngine.prototype);

/**
 * This is the primary interface between Sync and the Addons Manager.
 *
 * In addition to the core store APIs, we provide convenience functions to wrap
 * Add-on Manager APIs with Sync-specific semantics.
 */
function AddonsStore(name, engine) {
  Store.call(this, name, engine);
}
AddonsStore.prototype = {
  // Define the add-on types (.type) that we support.
  _syncableTypes: ["extension", "theme"],

  _extensionsPrefs: Services.prefs.getBranch("extensions."),

  get reconciler() {
    return this.engine._reconciler;
  },

  /**
   * Override applyIncoming to filter out records we can't handle.
   */
  async applyIncoming(record) {
    // The fields we look at aren't present when the record is deleted.
    if (!record.deleted) {
      // Ignore records not belonging to our application ID because that is the
      // current policy.
      if (record.applicationID != Services.appinfo.ID) {
        this._log.info(
          "Ignoring incoming record from other App ID: " + record.id
        );
        return;
      }

      // Ignore records that aren't from the official add-on repository, as that
      // is our current policy.
      if (record.source != "amo") {
        this._log.info(
          "Ignoring unknown add-on source (" +
            record.source +
            ")" +
            " for " +
            record.id
        );
        return;
      }
    }

    // Ignore incoming records for which an existing non-syncable addon
    // exists. Note that we do not insist that the addon manager already have
    // metadata for this addon - it's possible our reconciler previously saw the
    // addon but the addon-manager cache no longer has it - which is fine for a
    // new incoming addon.
    // (Note that most other cases where the addon-manager cache is invalid
    // doesn't get this treatment because that cache self-repairs after some
    // time - but it only re-populates addons which are currently installed.)
    let existingMeta = this.reconciler.addons[record.addonID];
    if (
      existingMeta &&
      !(await this.isAddonSyncable(existingMeta, /* ignoreRepoCheck */ true))
    ) {
      this._log.info(
        "Ignoring incoming record for an existing but non-syncable addon",
        record.addonID
      );
      return;
    }

    await Store.prototype.applyIncoming.call(this, record);
  },

  /**
   * Provides core Store API to create/install an add-on from a record.
   */
  async create(record) {
    // This will throw if there was an error. This will get caught by the sync
    // engine and the record will try to be applied later.
    const results = await AddonUtils.installAddons([
      {
        id: record.addonID,
        syncGUID: record.id,
        enabled: record.enabled,
        requireSecureURI: this._extensionsPrefs.getBoolPref(
          "install.requireSecureOrigin",
          true
        ),
      },
    ]);

    if (results.skipped.includes(record.addonID)) {
      this._log.info("Add-on skipped: " + record.addonID);
      // Just early-return for skipped addons - we don't want to arrange to
      // try again next time because the condition that caused up to skip
      // will remain true for this addon forever.
      return;
    }

    let addon;
    for (let a of results.addons) {
      if (a.id == record.addonID) {
        addon = a;
        break;
      }
    }

    // This should never happen, but is present as a fail-safe.
    if (!addon) {
      throw new Error("Add-on not found after install: " + record.addonID);
    }

    this._log.info("Add-on installed: " + record.addonID);
  },

  /**
   * Provides core Store API to remove/uninstall an add-on from a record.
   */
  async remove(record) {
    // If this is called, the payload is empty, so we have to find by GUID.
    let addon = await this.getAddonByGUID(record.id);
    if (!addon) {
      // We don't throw because if the add-on could not be found then we assume
      // it has already been uninstalled and there is nothing for this function
      // to do.
      return;
    }

    this._log.info("Uninstalling add-on: " + addon.id);
    await AddonUtils.uninstallAddon(addon);
  },

  /**
   * Provides core Store API to update an add-on from a record.
   */
  async update(record) {
    let addon = await this.getAddonByID(record.addonID);

    // update() is called if !this.itemExists. And, since itemExists consults
    // the reconciler only, we need to take care of some corner cases.
    //
    // First, the reconciler could know about an add-on that was uninstalled
    // and no longer present in the add-ons manager.
    if (!addon) {
      await this.create(record);
      return;
    }

    // It's also possible that the add-on is non-restartless and has pending
    // install/uninstall activity.
    //
    // We wouldn't get here if the incoming record was for a deletion. So,
    // check for pending uninstall and cancel if necessary.
    if (addon.pendingOperations & lazy.AddonManager.PENDING_UNINSTALL) {
      addon.cancelUninstall();

      // We continue with processing because there could be state or ID change.
    }

    await this.updateUserDisabled(addon, !record.enabled);
  },

  /**
   * Provide core Store API to determine if a record exists.
   */
  async itemExists(guid) {
    let addon = this.reconciler.getAddonStateFromSyncGUID(guid);

    return !!addon;
  },

  /**
   * Create an add-on record from its GUID.
   *
   * @param guid
   *        Add-on GUID (from extensions DB)
   * @param collection
   *        Collection to add record to.
   *
   * @return AddonRecord instance
   */
  async createRecord(guid, collection) {
    let record = new AddonRecord(collection, guid);
    record.applicationID = Services.appinfo.ID;

    let addon = this.reconciler.getAddonStateFromSyncGUID(guid);

    // If we don't know about this GUID or if it has been uninstalled, we mark
    // the record as deleted.
    if (!addon || !addon.installed) {
      record.deleted = true;
      return record;
    }

    record.modified = addon.modified.getTime() / 1000;

    record.addonID = addon.id;
    record.enabled = addon.enabled;

    // This needs to be dynamic when add-ons don't come from AddonRepository.
    record.source = "amo";

    return record;
  },

  /**
   * Changes the id of an add-on.
   *
   * This implements a core API of the store.
   */
  async changeItemID(oldID, newID) {
    // We always update the GUID in the reconciler because it will be
    // referenced later in the sync process.
    let state = this.reconciler.getAddonStateFromSyncGUID(oldID);
    if (state) {
      state.guid = newID;
      await this.reconciler.saveState();
    }

    let addon = await this.getAddonByGUID(oldID);
    if (!addon) {
      this._log.debug(
        "Cannot change item ID (" +
          oldID +
          ") in Add-on " +
          "Manager because old add-on not present: " +
          oldID
      );
      return;
    }

    addon.syncGUID = newID;
  },

  /**
   * Obtain the set of all syncable add-on Sync GUIDs.
   *
   * This implements a core Store API.
   */
  async getAllIDs() {
    let ids = {};

    let addons = this.reconciler.addons;
    for (let id in addons) {
      let addon = addons[id];
      if (await this.isAddonSyncable(addon)) {
        ids[addon.guid] = true;
      }
    }

    return ids;
  },

  /**
   * Wipe engine data.
   *
   * This uninstalls all syncable addons from the application. In case of
   * error, it logs the error and keeps trying with other add-ons.
   */
  async wipe() {
    this._log.info("Processing wipe.");

    await this.engine._refreshReconcilerState();

    // We only wipe syncable add-ons. Wipe is a Sync feature not a security
    // feature.
    let ids = await this.getAllIDs();
    for (let guid in ids) {
      let addon = await this.getAddonByGUID(guid);
      if (!addon) {
        this._log.debug(
          "Ignoring add-on because it couldn't be obtained: " + guid
        );
        continue;
      }

      this._log.info("Uninstalling add-on as part of wipe: " + addon.id);
      await Utils.catch.call(this, () => addon.uninstall())();
    }
  },

  /***************************************************************************
   * Functions below are unique to this store and not part of the Store API  *
   ***************************************************************************/

  /**
   * Obtain an add-on from its public ID.
   *
   * @param id
   *        Add-on ID
   * @return Addon or undefined if not found
   */
  async getAddonByID(id) {
    return lazy.AddonManager.getAddonByID(id);
  },

  /**
   * Obtain an add-on from its Sync GUID.
   *
   * @param  guid
   *         Add-on Sync GUID
   * @return DBAddonInternal or null
   */
  async getAddonByGUID(guid) {
    return lazy.AddonManager.getAddonBySyncGUID(guid);
  },

  /**
   * Determines whether an add-on is suitable for Sync.
   *
   * @param  addon
   *         Addon instance
   * @param ignoreRepoCheck
   *         Should we skip checking the Addons repository (primarially useful
   *         for testing and validation).
   * @return Boolean indicating whether it is appropriate for Sync
   */
  async isAddonSyncable(addon, ignoreRepoCheck = false) {
    // Currently, we limit syncable add-ons to those that are:
    //   1) In a well-defined set of types
    //   2) Installed in the current profile
    //   3) Not installed by a foreign entity (i.e. installed by the app)
    //      since they act like global extensions.
    //   4) Is not a hotfix.
    //   5) The addons XPIProvider doesn't veto it (i.e not being installed in
    //      the profile directory, or any other reasons it says the addon can't
    //      be synced)
    //   6) Are installed from AMO

    // We could represent the test as a complex boolean expression. We go the
    // verbose route so the failure reason is logged.
    if (!addon) {
      this._log.debug("Null object passed to isAddonSyncable.");
      return false;
    }

    if (!this._syncableTypes.includes(addon.type)) {
      this._log.debug(
        addon.id + " not syncable: type not in allowed list: " + addon.type
      );
      return false;
    }

    if (!(addon.scope & lazy.AddonManager.SCOPE_PROFILE)) {
      this._log.debug(addon.id + " not syncable: not installed in profile.");
      return false;
    }

    // If the addon manager says it's not syncable, we skip it.
    if (!addon.isSyncable) {
      this._log.debug(addon.id + " not syncable: vetoed by the addon manager.");
      return false;
    }

    // This may be too aggressive. If an add-on is downloaded from AMO and
    // manually placed in the profile directory, foreignInstall will be set.
    // Arguably, that add-on should be syncable.
    // TODO Address the edge case and come up with more robust heuristics.
    if (addon.foreignInstall) {
      this._log.debug(addon.id + " not syncable: is foreign install.");
      return false;
    }

    // If the AddonRepository's cache isn't enabled (which it typically isn't
    // in tests), getCachedAddonByID always returns null - so skip the check
    // in that case. We also provide a way to specifically opt-out of the check
    // even if the cache is enabled, which is used by the validators.
    if (ignoreRepoCheck || !lazy.AddonRepository.cacheEnabled) {
      return true;
    }

    let result = await new Promise(res => {
      lazy.AddonRepository.getCachedAddonByID(addon.id, res);
    });

    if (!result) {
      this._log.debug(
        addon.id + " not syncable: add-on not found in add-on repository."
      );
      return false;
    }

    return this.isSourceURITrusted(result.sourceURI);
  },

  /**
   * Determine whether an add-on's sourceURI field is trusted and the add-on
   * can be installed.
   *
   * This function should only ever be called from isAddonSyncable(). It is
   * exposed as a separate function to make testing easier.
   *
   * @param  uri
   *         nsIURI instance to validate
   * @return bool
   */
  isSourceURITrusted: function isSourceURITrusted(uri) {
    // For security reasons, we currently limit synced add-ons to those
    // installed from trusted hostname(s). We additionally require TLS with
    // the add-ons site to help prevent forgeries.
    let trustedHostnames = Svc.PrefBranch.getStringPref(
      "addons.trustedSourceHostnames",
      ""
    ).split(",");

    if (!uri) {
      this._log.debug("Undefined argument to isSourceURITrusted().");
      return false;
    }

    // Scheme is validated before the hostname because uri.host may not be
    // populated for certain schemes. It appears to always be populated for
    // https, so we avoid the potential NS_ERROR_FAILURE on field access.
    if (uri.scheme != "https") {
      this._log.debug("Source URI not HTTPS: " + uri.spec);
      return false;
    }

    if (!trustedHostnames.includes(uri.host)) {
      this._log.debug("Source hostname not trusted: " + uri.host);
      return false;
    }

    return true;
  },

  /**
   * Update the userDisabled flag on an add-on.
   *
   * This will enable or disable an add-on. It has no return value and does
   * not catch or handle exceptions thrown by the addon manager. If no action
   * is needed it will return immediately.
   *
   * @param addon
   *        Addon instance to manipulate.
   * @param value
   *        Boolean to which to set userDisabled on the passed Addon.
   */
  async updateUserDisabled(addon, value) {
    if (addon.userDisabled == value) {
      return;
    }

    // A pref allows changes to the enabled flag to be ignored.
    if (Svc.PrefBranch.getBoolPref("addons.ignoreUserEnabledChanges", false)) {
      this._log.info(
        "Ignoring enabled state change due to preference: " + addon.id
      );
      return;
    }

    AddonUtils.updateUserDisabled(addon, value);
    // updating this flag doesn't send a notification for appDisabled addons,
    // meaning the reconciler will not update its state and may resync the
    // addon - so explicitly rectify the state (bug 1366994)
    if (addon.appDisabled) {
      await this.reconciler.rectifyStateFromAddon(addon);
    }
  },
};

Object.setPrototypeOf(AddonsStore.prototype, Store.prototype);

/**
 * The add-ons tracker keeps track of real-time changes to add-ons.
 *
 * It hooks up to the reconciler and receives notifications directly from it.
 */
function AddonsTracker(name, engine) {
  LegacyTracker.call(this, name, engine);
}
AddonsTracker.prototype = {
  get reconciler() {
    return this.engine._reconciler;
  },

  get store() {
    return this.engine._store;
  },

  /**
   * This callback is executed whenever the AddonsReconciler sends out a change
   * notification. See AddonsReconciler.addChangeListener().
   */
  async changeListener(date, change, addon) {
    this._log.debug("changeListener invoked: " + change + " " + addon.id);
    // Ignore changes that occur during sync.
    if (this.ignoreAll) {
      return;
    }

    if (!(await this.store.isAddonSyncable(addon))) {
      this._log.debug(
        "Ignoring change because add-on isn't syncable: " + addon.id
      );
      return;
    }

    const added = await this.addChangedID(addon.guid, date.getTime() / 1000);
    if (added) {
      this.score += SCORE_INCREMENT_XLARGE;
    }
  },

  onStart() {
    this.reconciler.startListening();
    this.reconciler.addChangeListener(this);
  },

  onStop() {
    this.reconciler.removeChangeListener(this);
    this.reconciler.stopListening();
  },
};

Object.setPrototypeOf(AddonsTracker.prototype, LegacyTracker.prototype);

export class AddonValidator extends CollectionValidator {
  constructor(engine = null) {
    super("addons", "id", ["addonID", "enabled", "applicationID", "source"]);
    this.engine = engine;
  }

  async getClientItems() {
    return lazy.AddonManager.getAllAddons();
  }

  normalizeClientItem(item) {
    let enabled = !item.userDisabled;
    if (item.pendingOperations & lazy.AddonManager.PENDING_ENABLE) {
      enabled = true;
    } else if (item.pendingOperations & lazy.AddonManager.PENDING_DISABLE) {
      enabled = false;
    }
    return {
      enabled,
      id: item.syncGUID,
      addonID: item.id,
      applicationID: Services.appinfo.ID,
      source: "amo", // check item.foreignInstall?
      original: item,
    };
  }

  async normalizeServerItem(item) {
    let guid = await this.engine._findDupe(item);
    if (guid) {
      item.id = guid;
    }
    return item;
  }

  clientUnderstands(item) {
    return item.applicationID === Services.appinfo.ID;
  }

  async syncedByClient(item) {
    return (
      !item.original.hidden &&
      !item.original.isSystem &&
      !(
        item.original.pendingOperations & lazy.AddonManager.PENDING_UNINSTALL
      ) &&
      // No need to await the returned promise explicitely:
      // |expr1 && expr2| evaluates to expr2 if expr1 is true.
      this.engine.isAddonSyncable(item.original, true)
    );
  }
}
