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

const TOPIC_WILL_IMPORT_BOOKMARKS =
  "initial-migration-will-import-default-bookmarks";
const TOPIC_DID_IMPORT_BOOKMARKS =
  "initial-migration-did-import-default-bookmarks";
const TOPIC_PLACES_DEFAULTS_FINISHED = "places-browser-init-complete";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs",
  FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs",
  MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.sys.mjs",
});

/**
 * @typedef {object} MigratorResource
 *   A resource returned by a subclass of MigratorBase that can migrate
 *   data to this browser.
 * @property {number} type
 *   A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate
 *   what this resource represents. A resource can represent one or more types
 *   of data, for example HISTORY and FORMDATA.
 * @property {Function} migrate
 *   A function that will actually perform the migration of this resource's
 *   data into this browser.
 */

/**
 * Shared prototype for migrators.
 *
 * To implement a migrator:
 * 1. Import this module.
 * 2. Create a subclass of MigratorBase for your new migrator.
 * 3. Override the `key` static getter with a unique identifier for the browser
 *    that this migrator migrates from.
 * 4. If the migrator supports multiple profiles, override the sourceProfiles
 *    Here we default for single-profile migrator.
 * 5. Implement getResources(aProfile) (see below).
 * 6. For startup-only migrators, override ``startupOnlyMigrator``.
 * 7. Add the migrator to the MIGRATOR_MODULES structure in MigrationUtils.sys.mjs.
 */
export class MigratorBase {
  /**
   * This must be overridden to return a simple string identifier for the
   * migrator, for example "firefox", "chrome", "opera-gx". This key is what
   * is used as an identifier when calling MigrationUtils.getMigrator.
   *
   * @type {string}
   */
  static get key() {
    throw new Error("MigratorBase.key must be overridden.");
  }

  /**
   * This must be overridden to return a Fluent string ID mapping to the display
   * name for this migrator. These strings should be defined in migrationWizard.ftl.
   *
   * @type {string}
   */
  static get displayNameL10nID() {
    throw new Error("MigratorBase.displayNameL10nID must be overridden.");
  }

  /**
   * This method should get overridden to return an icon url of the browser
   * to be imported from. By default, this will just use the default Favicon
   * image.
   *
   * @type {string}
   */
  static get brandImage() {
    return "chrome://global/skin/icons/defaultFavicon.svg";
  }

  /**
   * OVERRIDE IF AND ONLY IF the source supports multiple profiles.
   *
   * Returns array of profile objects from which data may be imported. The object
   * should have the following keys:
   *   id - a unique string identifier for the profile
   *   name - a pretty name to display to the user in the UI
   *
   * Only profiles from which data can be imported should be listed.  Otherwise
   * the behavior of the migration wizard isn't well-defined.
   *
   * For a single-profile source (e.g. safari, ie), this returns null,
   * and not an empty array.  That is the default implementation.
   *
   * @abstract
   * @returns {object[]|null}
   */
  getSourceProfiles() {
    return null;
  }

  /**
   * MUST BE OVERRIDDEN.
   *
   * Returns an array of "migration resources" objects for the given profile,
   * or for the "default" profile, if the migrator does not support multiple
   * profiles.
   *
   * Each migration resource should provide:
   * - a ``type`` getter, returning any of the migration resource types (see
   *   MigrationUtils.resourceTypes).
   *
   * - a ``migrate`` method, taking two arguments,
   *   aCallback(bool success, object details), for migrating the data for
   *   this resource.  It may do its job synchronously or asynchronously.
   *   Either way, it must call aCallback(bool aSuccess, object details)
   *   when it's done.  In the case of an exception thrown from ``migrate``,
   *   it's taken as if aCallback(false, {}) is called. The details
   *   argument is sometimes optional, but conditional on how the
   *   migration wizard wants to display the migration state for the
   *   resource.
   *
   *   Note: In the case of a simple asynchronous implementation, you may find
   *   MigrationUtils.wrapMigrateFunction handy for handling aCallback easily.
   *
   * For each migration type listed in MigrationUtils.resourceTypes, multiple
   * migration resources may be provided.  This practice is useful when the
   * data for a certain migration type is independently stored in few
   * locations.  For example, the mac version of Safari stores its "reading list"
   * bookmarks in a separate property list.
   *
   * Note that the importation of a particular migration type is reported as
   * successful if _any_ of its resources succeeded to import (that is, called,
   * ``aCallback(true, {})``).  However, completion-status for a particular migration
   * type is reported to the UI only once all of its migrators have called
   * aCallback.
   *
   * NOTE: The returned array should only include resources from which data
   * can be imported.  So, for example, before adding a resource for the
   * BOOKMARKS migration type, you should check if you should check that the
   * bookmarks file exists.
   *
   * @abstract
   * @param {object|string} _aProfile
   *  The profile from which data may be imported, or an empty string
   *  in the case of a single-profile migrator.
   *  In the case of multiple-profiles migrator, it is guaranteed that
   *  aProfile is a value returned by the sourceProfiles getter (see
   *  above).
   * @returns {Promise<MigratorResource[]>|MigratorResource[]}
   */
  getResources(_aProfile) {
    throw new Error("getResources must be overridden");
  }

  /**
   * OVERRIDE in order to provide an estimate of when the last time was
   * that somebody used the browser. It is OK that this is somewhat fuzzy -
   * history may not be available (or be wiped or not present due to e.g.
   * incognito mode).
   *
   * If not overridden, the promise will resolve to the Unix epoch.
   *
   * @returns {Promise<Date>}
   *   A Promise that resolves to the last used date.
   */
  getLastUsedDate() {
    return Promise.resolve(new Date(0));
  }

  /**
   * OVERRIDE IF AND ONLY IF the migrator is a startup-only migrator (For now,
   * that is just the Firefox migrator, see bug 737381).  Default: false.
   *
   * Startup-only migrators are different in two ways:
   * - they may only be used during startup.
   * - the user-profile is half baked during migration.  The folder exists,
   *   but it's only accessible through MigrationUtils.profileStartup.
   *   The migrator can call MigrationUtils.profileStartup.doStartup
   *   at any point in order to initialize the profile.
   *
   * @returns {boolean}
   *   true if the migrator is start-up only.
   */
  get startupOnlyMigrator() {
    return false;
  }

  /**
   * Returns true if the migrator is configured to be enabled. This is
   * controlled by the `browser.migrate.<BROWSER_KEY>.enabled` boolean
   * preference.
   *
   * @returns {boolean}
   *   true if the migrator should be shown in the migration wizard.
   */
  get enabled() {
    let key = this.constructor.key;
    return Services.prefs.getBoolPref(`browser.migrate.${key}.enabled`, false);
  }

  /**
   * Subclasses should implement this if special checks need to be made to determine
   * if certain permissions need to be requested before data can be imported.
   * The returned Promise resolves to true if the required permissions have
   * been granted and a migration could proceed.
   *
   * @returns {Promise<boolean>}
   */
  async hasPermissions() {
    return Promise.resolve(true);
  }

  /**
   * Subclasses should implement this if special permissions need to be
   * requested from the user or the operating system in order to perform
   * a migration with this MigratorBase. This will be called only if
   * hasPermissions resolves to false.
   *
   * The returned Promise will resolve to true if permissions were successfully
   * obtained, and false otherwise. Implementors should ensure that if a call
   * to getPermissions resolves to true, that the MigratorBase will be able to
   * get read access to all of the resources it needs to do a migration.
   *
   * @param {DOMWindow} _win
   *   The top-level DOM window hosting the UI that is requesting the permission.
   *   This can be used to, for example, anchor a file picker window to the
   *   same window that is hosting the migration UI.
   * @returns {Promise<boolean>}
   */
  async getPermissions(_win) {
    return Promise.resolve(true);
  }

  /**
   * @returns {Promise<boolean|string>}
   */
  async canGetPermissions() {
    return Promise.resolve(false);
  }

  /**
   * This method returns a number that is the bitwise OR of all resource
   * types that are available in aProfile. See MigrationUtils.resourceTypes
   * for each resource type.
   *
   * @param {object|string} aProfile
   *   The profile from which data may be imported, or an empty string
   *   in the case of a single-profile migrator.
   * @returns {number}
   */
  async getMigrateData(aProfile) {
    let resources = await this.#getMaybeCachedResources(aProfile);
    if (!resources) {
      return 0;
    }
    let types = resources.map(r => r.type);
    return types.reduce((a, b) => {
      a |= b;
      return a;
    }, 0);
  }

  /**
   * @see MigrationUtils
   *
   * @param {number} aItems
   *   A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate
   *   what types of resources should be migrated.
   * @param {boolean} aStartup
   *   True if this migration is occurring during startup.
   * @param {object|string} aProfile
   *   The other browser profile that is being migrated from.
   * @param {Function|null} aProgressCallback
   *   An optional callback that will be fired once a resourceType has finished
   *   migrating. The callback will be passed the numeric representation of the
   *   resource type followed by a boolean indicating whether or not the resource
   *   was migrated successfully and optionally an object containing additional
   *   details.
   */
  async migrate(aItems, aStartup, aProfile, aProgressCallback = () => {}) {
    let resources = await this.#getMaybeCachedResources(aProfile);
    if (!resources.length) {
      throw new Error("migrate called for a non-existent source");
    }

    if (aItems != lazy.MigrationUtils.resourceTypes.ALL) {
      resources = resources.filter(r => aItems & r.type);
    }

    // Used to periodically give back control to the main-thread loop.
    let unblockMainThread = function () {
      return new Promise(resolve => {
        Services.tm.dispatchToMainThread(resolve);
      });
    };

    let getHistogramIdForResourceType = (resourceType, template) => {
      if (resourceType == lazy.MigrationUtils.resourceTypes.HISTORY) {
        return template.replace("*", "HISTORY");
      }
      if (resourceType == lazy.MigrationUtils.resourceTypes.BOOKMARKS) {
        return template.replace("*", "BOOKMARKS");
      }
      if (resourceType == lazy.MigrationUtils.resourceTypes.PASSWORDS) {
        return template.replace("*", "LOGINS");
      }
      return null;
    };

    let browserKey = this.constructor.key;

    let maybeStartTelemetryStopwatch = resourceType => {
      let histogramId = getHistogramIdForResourceType(
        resourceType,
        "FX_MIGRATION_*_IMPORT_MS"
      );
      if (histogramId) {
        TelemetryStopwatch.startKeyed(histogramId, browserKey);
      }
      return histogramId;
    };

    let maybeStartResponsivenessMonitor = resourceType => {
      let responsivenessMonitor;
      let responsivenessHistogramId = getHistogramIdForResourceType(
        resourceType,
        "FX_MIGRATION_*_JANK_MS"
      );
      if (responsivenessHistogramId) {
        responsivenessMonitor = new lazy.ResponsivenessMonitor();
      }
      return { responsivenessMonitor, responsivenessHistogramId };
    };

    let maybeFinishResponsivenessMonitor = (
      responsivenessMonitor,
      histogramId
    ) => {
      if (responsivenessMonitor) {
        let accumulatedDelay = responsivenessMonitor.finish();
        if (histogramId) {
          try {
            Services.telemetry
              .getKeyedHistogramById(histogramId)
              .add(browserKey, accumulatedDelay);
          } catch (ex) {
            console.error(histogramId, ": ", ex);
          }
        }
      }
    };

    let collectQuantityTelemetry = () => {
      for (let resourceType of Object.keys(
        lazy.MigrationUtils._importQuantities
      )) {
        let histogramId =
          "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY";
        try {
          Services.telemetry
            .getKeyedHistogramById(histogramId)
            .add(
              browserKey,
              lazy.MigrationUtils._importQuantities[resourceType]
            );
        } catch (ex) {
          console.error(histogramId, ": ", ex);
        }
      }
    };

    let collectMigrationTelemetry = resourceType => {
      // We don't want to collect this if the migration is occurring due to a
      // profile refresh.
      if (this.constructor.key == lazy.FirefoxProfileMigrator.key) {
        return;
      }

      let prefKey = null;
      switch (resourceType) {
        case lazy.MigrationUtils.resourceTypes.BOOKMARKS: {
          prefKey = "browser.migrate.interactions.bookmarks";
          break;
        }
        case lazy.MigrationUtils.resourceTypes.HISTORY: {
          prefKey = "browser.migrate.interactions.history";
          break;
        }
        case lazy.MigrationUtils.resourceTypes.PASSWORDS: {
          prefKey = "browser.migrate.interactions.passwords";
          break;
        }
        default: {
          return;
        }
      }

      if (prefKey) {
        Services.prefs.setBoolPref(prefKey, true);
      }
    };

    // Called either directly or through the bookmarks import callback.
    let doMigrate = async function () {
      let resourcesGroupedByItems = new Map();
      resources.forEach(function (resource) {
        if (!resourcesGroupedByItems.has(resource.type)) {
          resourcesGroupedByItems.set(resource.type, new Set());
        }
        resourcesGroupedByItems.get(resource.type).add(resource);
      });

      if (resourcesGroupedByItems.size == 0) {
        throw new Error("No items to import");
      }

      let notify = function (aMsg, aItemType) {
        Services.obs.notifyObservers(null, aMsg, aItemType);
      };

      for (let resourceType of Object.keys(
        lazy.MigrationUtils._importQuantities
      )) {
        lazy.MigrationUtils._importQuantities[resourceType] = 0;
      }
      notify("Migration:Started");
      for (let [migrationType, itemResources] of resourcesGroupedByItems) {
        notify("Migration:ItemBeforeMigrate", migrationType);

        let stopwatchHistogramId = maybeStartTelemetryStopwatch(migrationType);

        let { responsivenessMonitor, responsivenessHistogramId } =
          maybeStartResponsivenessMonitor(migrationType);

        let itemSuccess = false;
        for (let res of itemResources) {
          let completeDeferred = Promise.withResolvers();
          let resourceDone = function (aSuccess, details) {
            itemResources.delete(res);
            itemSuccess |= aSuccess;
            if (itemResources.size == 0) {
              notify(
                itemSuccess
                  ? "Migration:ItemAfterMigrate"
                  : "Migration:ItemError",
                migrationType
              );
              collectMigrationTelemetry(migrationType);

              aProgressCallback(migrationType, itemSuccess, details);

              resourcesGroupedByItems.delete(migrationType);

              if (stopwatchHistogramId) {
                TelemetryStopwatch.finishKeyed(
                  stopwatchHistogramId,
                  browserKey
                );
              }

              maybeFinishResponsivenessMonitor(
                responsivenessMonitor,
                responsivenessHistogramId
              );

              if (resourcesGroupedByItems.size == 0) {
                collectQuantityTelemetry();

                notify("Migration:Ended");
              }
            }
            completeDeferred.resolve();
          };

          // If migrate throws, an error occurred, and the callback
          // (itemMayBeDone) might haven't been called.
          try {
            res.migrate(resourceDone);
          } catch (ex) {
            console.error(ex);
            resourceDone(false);
          }

          await completeDeferred.promise;
          await unblockMainThread();
        }
      }
    };

    if (
      lazy.MigrationUtils.isStartupMigration &&
      !this.startupOnlyMigrator &&
      Services.policies.isAllowed("defaultBookmarks")
    ) {
      lazy.MigrationUtils.profileStartup.doStartup();
      // First import the default bookmarks.
      // Note: We do not need to do so for the Firefox migrator
      // (=startupOnlyMigrator), as it just copies over the places database
      // from another profile.
      await (async function () {
        // Tell nsBrowserGlue we're importing default bookmarks.
        let browserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService(
          Ci.nsIObserver
        );
        browserGlue.observe(null, TOPIC_WILL_IMPORT_BOOKMARKS, "");

        // Import the default bookmarks. We ignore whether or not we succeed.
        await lazy.BookmarkHTMLUtils.importFromURL(
          "chrome://browser/content/default-bookmarks.html",
          {
            replace: true,
            source: lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
          }
        ).catch(console.error);

        // We'll tell nsBrowserGlue we've imported bookmarks, but before that
        // we need to make sure we're going to know when it's finished
        // initializing places:
        let placesInitedPromise = new Promise(resolve => {
          let onPlacesInited = function () {
            Services.obs.removeObserver(
              onPlacesInited,
              TOPIC_PLACES_DEFAULTS_FINISHED
            );
            resolve();
          };
          Services.obs.addObserver(
            onPlacesInited,
            TOPIC_PLACES_DEFAULTS_FINISHED
          );
        });
        browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, "");
        await placesInitedPromise;
        await doMigrate();
      })();
      return;
    }
    await doMigrate();
  }

  /**
   * Checks to see if one or more profiles exist for the browser that this
   * migrator migrates from.
   *
   * @returns {Promise<boolean>}
   *   True if one or more profiles exists that this migrator can migrate
   *   resources from.
   */
  async isSourceAvailable() {
    if (this.startupOnlyMigrator && !lazy.MigrationUtils.isStartupMigration) {
      return false;
    }

    // For a single-profile source, check if any data is available.
    // For multiple-profiles source, make sure that at least one
    // profile is available.
    let exists = false;
    try {
      let profiles = await this.getSourceProfiles();
      if (!profiles) {
        let resources = await this.#getMaybeCachedResources("");
        if (resources && resources.length) {
          exists = true;
        }
      } else {
        exists = !!profiles.length;
      }
    } catch (ex) {
      console.error(ex);
    }
    return exists;
  }

  /*** PRIVATE STUFF - DO NOT OVERRIDE ***/

  /**
   * Returns resources for a particular profile and then caches them for later
   * lookups.
   *
   * @param {object|string} aProfile
   *   The profile that resources are being imported from.
   * @returns {Promise<MigrationResource[]>}
   */
  async #getMaybeCachedResources(aProfile) {
    let profileKey = aProfile ? aProfile.id : "";
    if (this._resourcesByProfile) {
      if (profileKey in this._resourcesByProfile) {
        return this._resourcesByProfile[profileKey];
      }
    } else {
      this._resourcesByProfile = {};
    }
    this._resourcesByProfile[profileKey] = await this.getResources(aProfile);
    return this._resourcesByProfile[profileKey];
  }
}
