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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AMBrowserExtensionsImport: "resource://gre/modules/AddonManager.sys.mjs",
  LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
  PlacesUIUtils: "moz-src:///browser/components/places/PlacesUIUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
  Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
  MigrationWizardConstants:
    "chrome://browser/content/migration/migration-wizard-constants.mjs",
});

ChromeUtils.defineLazyGetter(
  lazy,
  "gCanGetPermissionsOnPlatformPromise",
  () => {
    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    return fp.isModeSupported(Ci.nsIFilePicker.modeGetFolder);
  }
);

var gMigrators = null;
var gFileMigrators = null;
var gProfileStartup = null;
var gL10n = null;

let gForceExitSpinResolve = false;
let gKeepUndoData = false;
let gUndoData = null;

function getL10n() {
  if (!gL10n) {
    gL10n = new Localization(["browser/migrationWizard.ftl"]);
  }
  return gL10n;
}

const MIGRATOR_MODULES = Object.freeze({
  EdgeProfileMigrator: {
    moduleURI: "resource:///modules/EdgeProfileMigrator.sys.mjs",
    platforms: ["win"],
  },
  FirefoxProfileMigrator: {
    moduleURI: "resource:///modules/FirefoxProfileMigrator.sys.mjs",
    platforms: ["linux", "macosx", "win"],
  },
  IEProfileMigrator: {
    moduleURI: "resource:///modules/IEProfileMigrator.sys.mjs",
    platforms: ["win"],
  },
  SafariProfileMigrator: {
    moduleURI: "resource:///modules/SafariProfileMigrator.sys.mjs",
    platforms: ["macosx"],
  },

  // The following migrators are all variants of the ChromeProfileMigrator

  BraveProfileMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["linux", "macosx", "win"],
  },
  CanaryProfileMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["macosx", "win"],
  },
  ChromeProfileMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["linux", "macosx", "win"],
  },
  ChromeBetaMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["linux", "win"],
  },
  ChromeDevMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["linux"],
  },
  ChromiumProfileMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["linux", "macosx", "win"],
  },
  Chromium360seMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["win"],
  },
  ChromiumEdgeMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["macosx", "win"],
  },
  ChromiumEdgeBetaMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["macosx", "win"],
  },
  OperaProfileMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["linux", "macosx", "win"],
  },
  VivaldiProfileMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["linux", "macosx", "win"],
  },
  OperaGXProfileMigrator: {
    moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
    platforms: ["macosx", "win"],
  },

  InternalTestingProfileMigrator: {
    moduleURI: "resource:///modules/InternalTestingProfileMigrator.sys.mjs",
    platforms: ["linux", "macosx", "win"],
  },
});

const FILE_MIGRATOR_MODULES = Object.freeze({
  PasswordFileMigrator: {
    moduleURI: "resource:///modules/FileMigrators.sys.mjs",
  },
  BookmarksFileMigrator: {
    moduleURI: "resource:///modules/FileMigrators.sys.mjs",
  },
});

/**
 * The singleton MigrationUtils service. This service is the primary mechanism
 * by which migrations from other browsers to this browser occur. The singleton
 * instance of this class is exported from this module as `MigrationUtils`.
 */
class MigrationUtils {
  constructor() {
    XPCOMUtils.defineLazyPreferenceGetter(
      this,
      "HISTORY_MAX_AGE_IN_DAYS",
      "browser.migrate.history.maxAgeInDays",
      180
    );

    ChromeUtils.registerWindowActor("MigrationWizard", {
      parent: {
        esModuleURI: "resource:///actors/MigrationWizardParent.sys.mjs",
      },

      child: {
        esModuleURI: "resource:///actors/MigrationWizardChild.sys.mjs",
        events: {
          "MigrationWizard:RequestState": { wantUntrusted: true },
          "MigrationWizard:BeginMigration": { wantUntrusted: true },
          "MigrationWizard:RequestSafariPermissions": { wantUntrusted: true },
          "MigrationWizard:SelectManualPasswordFile": { wantUntrusted: true },
          "MigrationWizard:OpenAboutAddons": { wantUntrusted: true },
          "MigrationWizard:PermissionsNeeded": { wantUntrusted: true },
          "MigrationWizard:GetPermissions": { wantUntrusted: true },
          "MigrationWizard:OpenURL": { wantUntrusted: true },
        },
      },

      includeChrome: true,
      allFrames: true,
      matches: [
        "about:welcome",
        "about:welcome?*",
        "about:preferences",
        "about:settings",
        "chrome://browser/content/migration/migration-dialog-window.html",
        "chrome://browser/content/spotlight.html",
        "about:firefoxview",
      ],
    });

    ChromeUtils.defineLazyGetter(this, "IS_LINUX_SNAP_PACKAGE", () => {
      if (
        AppConstants.platform != "linux" ||
        !Cc["@mozilla.org/gio-service;1"]
      ) {
        return false;
      }

      let gIOSvc = Cc["@mozilla.org/gio-service;1"].getService(
        Ci.nsIGIOService
      );
      return gIOSvc.isRunningUnderSnap;
    });
  }

  resourceTypes = Object.freeze({
    ALL: 0x0000,
    /* 0x01 used to be used for settings, but was removed. */
    COOKIES: 0x0002,
    HISTORY: 0x0004,
    FORMDATA: 0x0008,
    PASSWORDS: 0x0010,
    BOOKMARKS: 0x0020,
    OTHERDATA: 0x0040,
    SESSION: 0x0080,
    PAYMENT_METHODS: 0x0100,
    EXTENSIONS: 0x0200,
  });

  /**
   * Helper for implementing simple asynchronous cases of migration resources'
   * ``migrate(aCallback)`` (see MigratorBase).  If your ``migrate`` method
   * just waits for some file to be read, for example, and then migrates
   * everything right away, you can wrap the async-function with this helper
   * and not worry about notifying the callback.
   *
   * @example
   * // For example, instead of writing:
   * setTimeout(function() {
   *   try {
   *     ....
   *     aCallback(true);
   *   }
   *   catch() {
   *     aCallback(false);
   *   }
   * }, 0);
   *
   * // You may write:
   * setTimeout(MigrationUtils.wrapMigrateFunction(function() {
   *   if (importingFromMosaic)
   *     throw Cr.NS_ERROR_UNEXPECTED;
   * }, aCallback), 0);
   *
   * // ... and aCallback will be called with aSuccess=false when importing
   * // from Mosaic, or with aSuccess=true otherwise.
   *
   * @param {Function} aFunction
   *   the function that will be called sometime later.  If aFunction
   *   throws when it's called, aCallback(false) is called, otherwise
   *   aCallback(true) is called.
   * @param {Function} aCallback
   *   the callback function passed to ``migrate``.
   * @returns {Function}
   *   the wrapped function.
   */
  wrapMigrateFunction(aFunction, aCallback) {
    return function () {
      let success = false;
      try {
        aFunction.apply(null, arguments);
        success = true;
      } catch (ex) {
        console.error(ex);
      }
      // Do not change this to call aCallback directly in try try & catch
      // blocks, because if aCallback throws, we may end up calling aCallback
      // twice.
      aCallback(success);
    };
  }

  /**
   * Gets localized string corresponding to l10n-id
   *
   * @param {string} aKey
   *   The key of the id of the localization to retrieve.
   * @param {object} [aArgs=undefined]
   *   An optional map of arguments to the id.
   * @returns {Promise<string>}
   *   A promise that resolves to the retrieved localization.
   */
  getLocalizedString(aKey, aArgs) {
    let l10n = getL10n();
    return l10n.formatValue(aKey, aArgs);
  }

  /**
   * Get all the rows corresponding to a select query from a database, without
   * requiring a lock on the database. If fetching data fails (because someone
   * else tried to write to the DB at the same time, for example), we will
   * retry the fetch after a 100ms timeout, up to 10 times.
   *
   * @param {string} path
   *   The file path to the database we want to open.
   * @param {string} description
   *   A developer-readable string identifying what kind of database we're
   *   trying to open.
   * @param {string} selectQuery
   *   The SELECT query to use to fetch the rows.
   * @param {Promise} [testDelayPromise]
   *   An optional promise to await for after the first loop, used in tests.
   *
   * @returns {Promise<object[]|Error>}
   *   A promise that resolves to an array of rows. The promise will be
   *   rejected if the read/fetch failed even after retrying.
   */
  getRowsFromDBWithoutLocks(
    path,
    description,
    selectQuery,
    testDelayPromise = null
  ) {
    let dbOptions = {
      readOnly: true,
      ignoreLockingMode: true,
      path,
    };

    const RETRYLIMIT = 10;
    const RETRYINTERVAL = 100;
    return (async function innerGetRows() {
      let rows = null;
      for (let retryCount = RETRYLIMIT; retryCount; retryCount--) {
        // Attempt to get the rows. If this succeeds, we will bail out of the loop,
        // close the database in a failsafe way, and pass the rows back.
        // If fetching the rows throws, we will wait RETRYINTERVAL ms
        // and try again. This will repeat a maximum of RETRYLIMIT times.
        let db;
        let didOpen = false;
        let previousExceptionMessage = null;
        try {
          db = await lazy.Sqlite.openConnection(dbOptions);
          didOpen = true;
          rows = await db.execute(selectQuery);
          break;
        } catch (ex) {
          if (previousExceptionMessage != ex.message) {
            console.error(ex);
          }
          previousExceptionMessage = ex.message;
          if (ex.name == "NS_ERROR_FILE_CORRUPTED") {
            break;
          }
        } finally {
          try {
            if (didOpen) {
              await db.close();
            }
          } catch (ex) {}
        }
        await Promise.all([
          new Promise(resolve => lazy.setTimeout(resolve, RETRYINTERVAL)),
          testDelayPromise,
        ]);
      }
      if (!rows) {
        throw new Error(
          "Couldn't get rows from the " + description + " database."
        );
      }
      return rows;
    })();
  }

  get #migrators() {
    if (!gMigrators) {
      gMigrators = new Map();
      for (let [symbol, { moduleURI, platforms }] of Object.entries(
        MIGRATOR_MODULES
      )) {
        if (platforms.includes(AppConstants.platform)) {
          let { [symbol]: migratorClass } =
            ChromeUtils.importESModule(moduleURI);
          if (gMigrators.has(migratorClass.key)) {
            console.error(
              "A pre-existing migrator exists with key " +
                `${migratorClass.key}. Not registering.`
            );
            continue;
          }
          gMigrators.set(migratorClass.key, new migratorClass());
        }
      }
    }
    return gMigrators;
  }

  get #fileMigrators() {
    if (!gFileMigrators) {
      gFileMigrators = new Map();
      for (let [symbol, { moduleURI }] of Object.entries(
        FILE_MIGRATOR_MODULES
      )) {
        let { [symbol]: migratorClass } = ChromeUtils.importESModule(moduleURI);
        if (gFileMigrators.has(migratorClass.key)) {
          console.error(
            "A pre-existing file migrator exists with key " +
              `${migratorClass.key}. Not registering.`
          );
          continue;
        }
        gFileMigrators.set(migratorClass.key, new migratorClass());
      }
    }
    return gFileMigrators;
  }

  forceExitSpinResolve() {
    gForceExitSpinResolve = true;
  }

  spinResolve(promise) {
    if (!(promise instanceof Promise)) {
      return promise;
    }
    let done = false;
    let result = null;
    let error = null;
    gForceExitSpinResolve = false;
    promise
      .catch(e => {
        error = e;
      })
      .then(r => {
        result = r;
        done = true;
      });

    Services.tm.spinEventLoopUntil(
      "MigrationUtils.sys.mjs:MU_spinResolve",
      () => done || gForceExitSpinResolve
    );
    if (!done) {
      throw new Error("Forcefully exited event loop.");
    } else if (error) {
      throw error;
    } else {
      return result;
    }
  }

  /**
   * Returns the migrator for the given source, if any data is available
   * for this source, or if permissions are required in order to read
   * data from this source. Returns null otherwise.
   *
   * @param {string} aKey
   *   Internal name of the migration source. See `availableMigratorKeys`
   *   for supported values by OS.
   * @returns {Promise<MigratorBase|null>}
   *   A profile migrator implementing nsIBrowserProfileMigrator, if it can
   *   import any data, null otherwise.
   */
  async getMigrator(aKey) {
    let migrator = this.#migrators.get(aKey);
    if (!migrator) {
      console.error(`Could not find a migrator class for key ${aKey}`);
      return null;
    }

    try {
      if (!migrator) {
        return null;
      }

      if (
        (await migrator.isSourceAvailable()) ||
        (!(await migrator.hasPermissions()) && migrator.canGetPermissions())
      ) {
        return migrator;
      }

      return null;
    } catch (ex) {
      console.error(ex);
      return null;
    }
  }

  getFileMigrator(aKey) {
    let migrator = this.#fileMigrators.get(aKey);
    if (!migrator) {
      console.error(`Could not find a file migrator class for key ${aKey}`);
      return null;
    }
    return migrator;
  }

  /**
   * Returns true if a migrator is registered with key aKey. No check is made
   * to determine if a profile exists that the migrator can migrate from.
   *
   * @param {string} aKey
   *   Internal name of the migration source. See `availableMigratorKeys`
   *   for supported values by OS.
   * @returns {boolean}
   */
  migratorExists(aKey) {
    return this.#migrators.has(aKey);
  }

  /**
   * Figure out what is the default browser, and if there is a migrator
   * for it, return that migrator's internal name.
   *
   * For the time being, the "internal name" of a migrator is its contract-id
   * trailer (e.g. ie for @mozilla.org/profile/migrator;1?app=browser&type=ie),
   * but it will soon be exposed properly.
   *
   * @returns {string}
   */
  getMigratorKeyForDefaultBrowser() {
    // Canary uses the same description as Chrome so we can't distinguish them.
    // Edge Beta on macOS uses "Microsoft Edge" with no "beta" indication.
    const APP_DESC_TO_KEY = {
      "Internet Explorer": "ie",
      "Microsoft Edge": "edge",
      Safari: "safari",
      Firefox: "firefox",
      Nightly: "firefox",
      Opera: "opera",
      Vivaldi: "vivaldi",
      "Opera GX": "opera-gx",
      "Brave Web Browser": "brave", // Windows, Linux
      Brave: "brave", // OS X
      "Google Chrome": "chrome", // Windows, Linux
      Chrome: "chrome", // OS X
      Chromium: "chromium", // Windows, OS X
      "Chromium Web Browser": "chromium", // Linux
      "360\u5b89\u5168\u6d4f\u89c8\u5668": "chromium-360se",
    };

    let key = "";
    try {
      let browserDesc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
        .getService(Ci.nsIExternalProtocolService)
        .getApplicationDescription("http");
      key = APP_DESC_TO_KEY[browserDesc] || "";
      // Handle devedition, as well as "FirefoxNightly" on OS X.
      if (!key && browserDesc.startsWith("Firefox")) {
        key = "firefox";
      }
    } catch (ex) {
      console.error("Could not detect default browser: ", ex);
    }

    return key;
  }

  /**
   * True if we're in the process of a startup migration.
   *
   * @type {boolean}
   */
  get isStartupMigration() {
    return gProfileStartup != null;
  }

  /**
   * In the case of startup migration, this is set to the nsIProfileStartup
   * instance passed to ProfileMigrator's migrate.
   *
   * @see showMigrationWizard
   * @type {nsIProfileStartup|null}
   */
  get profileStartup() {
    return gProfileStartup;
  }

  /**
   * Show the migration wizard in about:preferences, or if there is not an existing
   * browser window open, in a new top-level dialog window.
   *
   * NB: If you add new consumers, please add a migration entry point constant to
   * MIGRATION_ENTRYPOINTS and supply that entrypoint with the entrypoint property
   * in the aOptions argument.
   *
   * @param {Window} [aOpener=null]
   *   optional; the window that asks to open the wizard.
   * @param {object} [aOptions=null]
   *   optional named arguments for the migration wizard.
   * @param {string} [aOptions.entrypoint=undefined]
   *   migration entry point constant. See MIGRATION_ENTRYPOINTS.
   * @param {string} [aOptions.migratorKey=undefined]
   *   The key for which migrator to use automatically. This is the key that is exposed
   *   as a static getter on the migrator class.
   * @param {MigratorBase} [aOptions.migrator=undefined]
   *   A migrator instance to use automatically.
   * @param {boolean} [aOptions.isStartupMigration=undefined]
   *   True if this is a startup migration.
   * @param {boolean} [aOptions.skipSourceSelection=undefined]
   *   True if the source selection page of the wizard should be skipped.
   * @param {string} [aOptions.profileId]
   *   An identifier for the profile to use when migrating.
   * @returns {Promise<undefined>}
   *   If an about:preferences tab can be opened, this will resolve when
   *   that tab has been switched to. Otherwise, this will resolve
   *   just after opening the top-level dialog window.
   */
  showMigrationWizard(aOpener, aOptions) {
    // When migration is kicked off from about:welcome, there are
    // a few different behaviors that we want to test, controlled
    // by a preference that is instrumented for Nimbus. The pref
    // has the following possible states:
    //
    // "autoclose":
    //   The user will be directed to the migration wizard in
    //   about:preferences, but once the wizard is dismissed,
    //   the tab will close.
    //
    // "standalone":
    //   The migration wizard will open in a new top-level content
    //   window.
    //
    // "default" / other
    //   The user will be directed to the migration wizard in
    //   about:preferences. The tab will not close once the
    //   user closes the wizard.
    let aboutWelcomeBehavior = Services.prefs.getCharPref(
      "browser.migrate.content-modal.about-welcome-behavior",
      "default"
    );

    let entrypoint = aOptions.entrypoint || this.MIGRATION_ENTRYPOINTS.UNKNOWN;
    Glean.browserMigration.entryPointCategorical[entrypoint].add(1);

    let openStandaloneWindow = blocking => {
      let features = "dialog,centerscreen,resizable=no";

      if (blocking) {
        features += ",modal";
      }

      Services.ww.openWindow(
        aOpener,
        "chrome://browser/content/migration/migration-dialog-window.html",
        "_blank",
        features,
        {
          options: aOptions,
        }
      );
      return Promise.resolve();
    };

    if (aOptions.isStartupMigration) {
      // Record that the uninstaller requested a profile refresh
      if (Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) {
        Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", "");
        Glean.migration.uninstallerProfileRefresh.set(true);
      }

      openStandaloneWindow(true /* blocking */);
      return Promise.resolve();
    }

    if (aOpener?.openPreferences) {
      if (aOptions.entrypoint == this.MIGRATION_ENTRYPOINTS.NEWTAB) {
        if (aboutWelcomeBehavior == "autoclose") {
          return aOpener.openPreferences("general-migrate-autoclose");
        } else if (aboutWelcomeBehavior == "standalone") {
          openStandaloneWindow(false /* blocking */);
          return Promise.resolve();
        }
      }
      return aOpener.openPreferences("general-migrate");
    }

    // If somehow we failed to open about:preferences, fall back to opening
    // the top-level window.
    openStandaloneWindow(false /* blocking */);
    return Promise.resolve();
  }

  /**
   * Show the migration wizard for startup-migration.  This should only be
   * called by ProfileMigrator (see ProfileMigrator.js), which implements
   * nsIProfileMigrator. This runs asynchronously if we are running an
   * automigration.
   *
   * @param {nsIProfileStartup} aProfileStartup
   *   the nsIProfileStartup instance provided to ProfileMigrator.migrate.
   * @param {string|null} [aMigratorKey=null]
   *   If set, the migration wizard will import from the corresponding
   *   migrator, bypassing the source-selection page.  Otherwise, the
   *   source-selection page will be displayed, either with the default
   *   browser selected, if it could be detected and if there is a
   *   migrator for it, or with the first option selected as a fallback
   * @param {string|null} [aProfileToMigrate=null]
   *   If set, the migration wizard will import from the profile indicated.
   * @throws
   *   if aMigratorKey is invalid or if it points to a non-existent
   *   source.
   */
  startupMigration(aProfileStartup, aMigratorKey, aProfileToMigrate) {
    this.spinResolve(
      this.asyncStartupMigration(
        aProfileStartup,
        aMigratorKey,
        aProfileToMigrate
      )
    );
  }

  async asyncStartupMigration(
    aProfileStartup,
    aMigratorKey,
    aProfileToMigrate
  ) {
    if (!aProfileStartup) {
      throw new Error(
        "an profile-startup instance is required for startup-migration"
      );
    }
    gProfileStartup = aProfileStartup;

    let skipSourceSelection = false,
      migrator = null,
      migratorKey = "";
    if (aMigratorKey) {
      migrator = await this.getMigrator(aMigratorKey);
      if (!migrator) {
        // aMigratorKey must point to a valid source, so, if it doesn't
        // cleanup and throw.
        this.finishMigration();
        throw new Error(
          "startMigration was asked to open auto-migrate from " +
            "a non-existent source: " +
            aMigratorKey
        );
      }
      migratorKey = aMigratorKey;
      skipSourceSelection = true;
    } else {
      let defaultBrowserKey = this.getMigratorKeyForDefaultBrowser();
      if (defaultBrowserKey) {
        migrator = await this.getMigrator(defaultBrowserKey);
        if (migrator) {
          migratorKey = defaultBrowserKey;
        }
      }
    }

    if (!migrator) {
      let migrators = await Promise.all(
        this.availableMigratorKeys.map(key => this.getMigrator(key))
      );
      // If there's no migrator set so far, ensure that there is at least one
      // migrator available before opening the wizard.
      // Note that we don't need to check the default browser first, because
      // if that one existed we would have used it in the block above this one.
      if (!migrators.some(m => m)) {
        // None of the keys produced a usable migrator, so finish up here:
        this.finishMigration();
        return;
      }
    }

    let isRefresh =
      migrator &&
      skipSourceSelection &&
      migratorKey == AppConstants.MOZ_APP_NAME;

    let entrypoint = this.MIGRATION_ENTRYPOINTS.FIRSTRUN;
    if (isRefresh) {
      entrypoint = this.MIGRATION_ENTRYPOINTS.FXREFRESH;
    }

    this.showMigrationWizard(null, {
      entrypoint,
      migratorKey,
      migrator,
      isStartupMigration: !!aProfileStartup,
      skipSourceSelection,
      profileId: aProfileToMigrate,
    });
  }

  /**
   * This is only pseudo-private because some tests and helper functions
   * still expect to be able to directly access it.
   */
  _importQuantities = {
    bookmarks: 0,
    logins: 0,
    history: 0,
    cards: 0,
    extensions: 0,
  };

  getImportedCount(type) {
    if (!this._importQuantities.hasOwnProperty(type)) {
      throw new Error(
        `Unknown import data type "${type}" passed to getImportedCount`
      );
    }
    return this._importQuantities[type];
  }

  insertBookmarkWrapper(bookmark) {
    this._importQuantities.bookmarks++;
    let insertionPromise = lazy.PlacesUtils.bookmarks.insert(bookmark);
    if (!gKeepUndoData) {
      return insertionPromise;
    }
    // If we keep undo data, add a promise handler that stores the undo data once
    // the bookmark has been inserted in the DB, and then returns the bookmark.
    let { parentGuid } = bookmark;
    return insertionPromise.then(bm => {
      let { guid, lastModified, type } = bm;
      gUndoData.get("bookmarks").push({
        parentGuid,
        guid,
        lastModified,
        type,
      });
      return bm;
    });
  }

  insertManyBookmarksWrapper(bookmarks, parent) {
    let insertionPromise = lazy.PlacesUtils.bookmarks.insertTree({
      guid: parent,
      children: bookmarks,
    });
    return insertionPromise.then(
      insertedItems => {
        this._importQuantities.bookmarks += insertedItems.length;
        if (gKeepUndoData) {
          let bmData = gUndoData.get("bookmarks");
          for (let bm of insertedItems) {
            let { parentGuid, guid, lastModified, type } = bm;
            bmData.push({ parentGuid, guid, lastModified, type });
          }
        }
        if (parent == lazy.PlacesUtils.bookmarks.toolbarGuid) {
          lazy.PlacesUIUtils.maybeToggleBookmarkToolbarVisibility(
            true /* aForceVisible */
          ).catch(console.error);
        }
      },
      ex => console.error(ex)
    );
  }

  insertVisitsWrapper(pageInfos) {
    let now = new Date();
    // Ensure that none of the dates are in the future. If they are, rewrite
    // them to be now. This means we don't loose history entries, but they will
    // be valid for the history store.
    for (let pageInfo of pageInfos) {
      for (let visit of pageInfo.visits) {
        if (visit.date && visit.date > now) {
          visit.date = now;
        }
      }
    }
    this._importQuantities.history += pageInfos.length;
    if (gKeepUndoData) {
      this.#updateHistoryUndo(pageInfos);
    }
    return lazy.PlacesUtils.history.insertMany(pageInfos);
  }

  async insertLoginsWrapper(logins) {
    this._importQuantities.logins += logins.length;
    let inserted = await lazy.LoginHelper.maybeImportLogins(logins);
    // Note that this means that if we import a login that has a newer password
    // than we know about, we will update the login, and an undo of the import
    // will not revert this. This seems preferable over removing the login
    // outright or storing the old password in the undo file.
    if (gKeepUndoData) {
      for (let { guid, timePasswordChanged } of inserted) {
        gUndoData.get("logins").push({ guid, timePasswordChanged });
      }
    }
  }

  /**
   * Called by MigrationWizardParent during a migration to indicate that a
   * manual migration of logins occurred via import from a CSV / TSV file, and
   * should be counted towards the total number of imported logins.
   *
   * @param {number} totalLogins
   *   The number of logins imported manually from a CSV / TSV file.
   */
  notifyLoginsManuallyImported(totalLogins) {
    this._importQuantities.logins += totalLogins;
  }

  /**
   * Iterates through the favicons, sniffs for a mime type,
   * and uses the mime type to properly import the favicon.
   *
   * Note: You may not want to await on the returned promise, especially if by
   *       doing so there's risk of interrupting the migration of more critical
   *       data (e.g. bookmarks).
   *
   * @param {object[]} favicons
   *   An array of Objects with these properties:
   *     {Uint8Array} faviconData: The binary data of a favicon
   *     {nsIURI} uri: The URI of the associated page
   */
  async insertManyFavicons(favicons) {
    let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance(
      Ci.nsIContentSniffer
    );

    for (let faviconDataItem of favicons) {
      try {
        // getMIMETypeFromContent throws error if could not get the mime type
        // from the data.
        let mimeType = sniffer.getMIMETypeFromContent(
          null,
          faviconDataItem.faviconData,
          faviconDataItem.faviconData.length
        );

        let dataURL = await new Promise((resolve, reject) => {
          let buffer = new Uint8ClampedArray(faviconDataItem.faviconData);
          let blob = new Blob([buffer], { type: mimeType });
          let reader = new FileReader();
          reader.addEventListener("load", () => resolve(reader.result));
          reader.addEventListener("error", reject);
          reader.readAsDataURL(blob);
        });

        let fakeFaviconURI = Services.io.newURI(
          "fake-favicon-uri:" + faviconDataItem.uri.spec
        );
        lazy.PlacesUtils.favicons
          .setFaviconForPage(
            faviconDataItem.uri,
            fakeFaviconURI,
            Services.io.newURI(dataURL)
          )
          .catch(console.warn);
      } catch (e) {
        // Even if error happens for favicon, continue the process.
        console.warn(e);
      }
    }
  }

  async insertCreditCardsWrapper(cards) {
    this._importQuantities.cards += cards.length;
    let { formAutofillStorage } = ChromeUtils.importESModule(
      "resource://autofill/FormAutofillStorage.sys.mjs"
    );

    await formAutofillStorage.initialize();
    for (let card of cards) {
      try {
        await formAutofillStorage.creditCards.add(card);
      } catch (e) {
        console.error("Failed to insert credit card due to error: ", e, card);
      }
    }
  }

  /**
   * Responsible for calling the AddonManager API that ultimately installs the
   * matched add-ons.
   *
   * @param {string} migratorKey a migrator key that we pass to
   *                             `AMBrowserExtensionsImport` as the "browser
   *                             identifier" used to match add-ons
   * @param {string[]} extensionIDs a list of extension IDs from another browser
   * @returns {(lazy.MigrationWizardConstants.PROGRESS_VALUE|string[])[]}
   *   An array whose first element is a `MigrationWizardConstants.PROGRESS_VALUE`
   *   and second element is an array of imported add-on ids.
   */
  async installExtensionsWrapper(migratorKey, extensionIDs) {
    const totalExtensions = extensionIDs.length;

    let importedAddonIDs = [];
    try {
      const result = await lazy.AMBrowserExtensionsImport.stageInstalls(
        migratorKey,
        extensionIDs
      );
      importedAddonIDs = result.importedAddonIDs;
    } catch (e) {
      console.error(`Failed to import extensions: ${e}`);
    }

    this._importQuantities.extensions += importedAddonIDs.length;

    if (!importedAddonIDs.length) {
      return [
        lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING,
        importedAddonIDs,
      ];
    }
    if (totalExtensions == importedAddonIDs.length) {
      return [
        lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS,
        importedAddonIDs,
      ];
    }
    return [
      lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO,
      importedAddonIDs,
    ];
  }

  initializeUndoData() {
    gKeepUndoData = true;
    gUndoData = new Map([
      ["bookmarks", []],
      ["visits", []],
      ["logins", []],
    ]);
  }

  async #postProcessUndoData(state) {
    if (!state) {
      return state;
    }
    let bookmarkFolders = state
      .get("bookmarks")
      .filter(b => b.type == lazy.PlacesUtils.bookmarks.TYPE_FOLDER);

    let bookmarkFolderData = [];
    let bmPromises = bookmarkFolders.map(({ guid }) => {
      // Ignore bookmarks where the promise doesn't resolve (ie that are missing)
      // Also check that the bookmark fetch returns isn't null before adding it.
      return lazy.PlacesUtils.bookmarks.fetch(guid).then(
        bm => bm && bookmarkFolderData.push(bm),
        () => {}
      );
    });

    await Promise.all(bmPromises);
    let folderLMMap = new Map(
      bookmarkFolderData.map(b => [b.guid, b.lastModified])
    );
    for (let bookmark of bookmarkFolders) {
      let lastModified = folderLMMap.get(bookmark.guid);
      // If the bookmark was deleted, the map will be returning null, so check:
      if (lastModified) {
        bookmark.lastModified = lastModified;
      }
    }
    return state;
  }

  stopAndRetrieveUndoData() {
    let undoData = gUndoData;
    gUndoData = null;
    gKeepUndoData = false;
    return this.#postProcessUndoData(undoData);
  }

  #updateHistoryUndo(pageInfos) {
    let visits = gUndoData.get("visits");
    let visitMap = new Map(visits.map(v => [v.url, v]));
    for (let pageInfo of pageInfos) {
      let visitCount = pageInfo.visits.length;
      let first, last;
      if (visitCount > 1) {
        let dates = pageInfo.visits.map(v => v.date);
        first = Math.min.apply(Math, dates);
        last = Math.max.apply(Math, dates);
      } else {
        first = last = pageInfo.visits[0].date;
      }
      let url = pageInfo.url;
      if (url instanceof Ci.nsIURI) {
        url = pageInfo.url.spec;
      }

      if (!URL.canParse(url)) {
        // This won't save and we won't need to 'undo' it, so ignore this URL.
        continue;
      }
      if (!visitMap.has(url)) {
        visitMap.set(url, { url, visitCount, first, last });
      } else {
        let currentData = visitMap.get(url);
        currentData.visitCount += visitCount;
        currentData.first = Math.min(currentData.first, first);
        currentData.last = Math.max(currentData.last, last);
      }
    }
    gUndoData.set("visits", Array.from(visitMap.values()));
  }

  /**
   * Cleans up references to migrators and nsIProfileInstance instances.
   */
  finishMigration() {
    gMigrators = null;
    gProfileStartup = null;
    gL10n = null;
  }

  get availableMigratorKeys() {
    return [...this.#migrators.keys()];
  }

  get availableFileMigrators() {
    return [...this.#fileMigrators.values()];
  }

  /**
   * Enum for the entrypoint that is being used to start migration.
   * Callers can use the MIGRATION_ENTRYPOINTS getter to use these.
   *
   * These values are what's written into the
   * FX_MIGRATION_ENTRY_POINT_CATEGORICAL histogram after a migration.
   *
   * @see MIGRATION_ENTRYPOINTS
   * @readonly
   * @enum {string}
   */
  #MIGRATION_ENTRYPOINTS_ENUM = Object.freeze({
    /** The entrypoint was not supplied */
    UNKNOWN: "unknown",

    /** Migration is occurring at startup */
    FIRSTRUN: "firstrun",

    /** Migration is occurring at after a profile refresh */
    FXREFRESH: "fxrefresh",

    /** Migration is being started from the Library window */
    PLACES: "places",

    /** Migration is being started from our password management UI */
    PASSWORDS: "passwords",

    /** Migration is being started from the default about:home/about:newtab */
    NEWTAB: "newtab",

    /** Migration is being started from the File menu */
    FILE_MENU: "file_menu",

    /** Migration is being started from the Help menu */
    HELP_MENU: "help_menu",

    /** Migration is being started from the Bookmarks Toolbar */
    BOOKMARKS_TOOLBAR: "bookmarks_toolbar",

    /** Migration is being started from about:preferences */
    PREFERENCES: "preferences",

    /** Migration is being started from about:firefoxview */
    FIREFOX_VIEW: "firefox_view",
  });

  /**
   * Returns an enum that should be used to record the entrypoint for
   * starting a migration.
   *
   * @returns {number}
   */
  get MIGRATION_ENTRYPOINTS() {
    return this.#MIGRATION_ENTRYPOINTS_ENUM;
  }

  /**
   * Enum for the numeric value written to the FX_MIGRATION_SOURCE_BROWSER.
   * histogram
   *
   * @see getSourceIdForTelemetry
   * @readonly
   * @enum {number}
   */
  #SOURCE_NAME_TO_ID_MAPPING_ENUM = Object.freeze({
    nothing: 1,
    firefox: 2,
    edge: 3,
    ie: 4,
    chrome: 5,
    "chrome-beta": 5,
    "chrome-dev": 5,
    chromium: 6,
    canary: 7,
    safari: 8,
    "chromium-360se": 9,
    "chromium-edge": 10,
    "chromium-edge-beta": 10,
    brave: 11,
    opera: 12,
    "opera-gx": 14,
    vivaldi: 13,
  });

  getSourceIdForTelemetry(sourceName) {
    return this.#SOURCE_NAME_TO_ID_MAPPING_ENUM[sourceName] || 0;
  }

  get HISTORY_MAX_AGE_IN_MILLISECONDS() {
    return this.HISTORY_MAX_AGE_IN_DAYS * 24 * 60 * 60 * 1000;
  }

  /**
   * Determines whether or not the underlying platform supports creating
   * native file pickers that can do folder selection, which is a
   * pre-requisite for getting read-access permissions for data from other
   * browsers that we can import from.
   *
   * @returns {Promise<boolean>}
   */
  canGetPermissionsOnPlatform() {
    return lazy.gCanGetPermissionsOnPlatformPromise;
  }
}

const MigrationUtilsSingleton = new MigrationUtils();

export { MigrationUtilsSingleton as MigrationUtils };
