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

const lazy = {};

XPCOMUtils.defineLazyGetter(lazy, "gFluentStrings", function () {
  return new Localization([
    "branding/brand.ftl",
    "browser/migrationWizard.ftl",
  ]);
});

ChromeUtils.defineESModuleGetters(lazy, {
  FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs",
  InternalTestingProfileMigrator:
    "resource:///modules/InternalTestingProfileMigrator.sys.mjs",
  MigrationWizardConstants:
    "chrome://browser/content/migration/migration-wizard-constants.mjs",
  PasswordFileMigrator: "resource:///modules/FileMigrators.sys.mjs",
});

if (AppConstants.platform == "macosx") {
  ChromeUtils.defineESModuleGetters(lazy, {
    SafariProfileMigrator: "resource:///modules/SafariProfileMigrator.sys.mjs",
  });
}

XPCOMUtils.defineLazyModuleGetters(lazy, {
  LoginCSVImport: "resource://gre/modules/LoginCSVImport.jsm",
});

/**
 * This class is responsible for communicating with MigrationUtils to do the
 * actual heavy-lifting of any kinds of migration work, based on messages from
 * the associated MigrationWizardChild.
 */
export class MigrationWizardParent extends JSWindowActorParent {
  constructor() {
    super();
    Services.telemetry.setEventRecordingEnabled("browser.migration", true);
  }

  didDestroy() {
    Services.obs.notifyObservers(this, "MigrationWizard:Destroyed");
    MigrationUtils.finishMigration();
  }

  /**
   * General message handler function for messages received from the
   * associated MigrationWizardChild JSWindowActor.
   *
   * @param {ReceiveMessageArgument} message
   *   The message received from the MigrationWizardChild.
   * @returns {Promise}
   */
  async receiveMessage(message) {
    // Some belt-and-suspenders here, mainly because the migration-wizard
    // component can be embedded in less privileged content pages, so let's
    // make sure that any messages from content are coming from the privileged
    // about content process type.
    if (
      !this.browsingContext.currentWindowGlobal.isInProcess &&
      this.browsingContext.currentRemoteType !=
        E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE
    ) {
      throw new Error(
        "MigrationWizardParent: received message from the wrong content process type."
      );
    }

    switch (message.name) {
      case "GetAvailableMigrators": {
        let availableMigrators = [];
        for (const key of MigrationUtils.availableMigratorKeys) {
          availableMigrators.push(this.#getMigratorAndProfiles(key));
        }

        // Wait for all getMigrator calls to resolve in parallel
        let results = await Promise.all(availableMigrators);

        for (const migrator of MigrationUtils.availableFileMigrators.values()) {
          results.push(await this.#serializeFileMigrator(migrator));
        }

        // Each migrator might give us a single MigratorProfileInstance,
        // or an Array of them, so we flatten them out and filter out
        // any that ended up going wrong and returning null from the
        // #getMigratorAndProfiles call.
        let filteredResults = results
          .flat()
          .filter(result => result)
          .sort((a, b) => {
            return b.lastModifiedDate - a.lastModifiedDate;
          });

        return filteredResults;
      }

      case "Migrate": {
        let { migrationDetails, extraArgs } = message.data;
        if (
          migrationDetails.type ==
          lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER
        ) {
          return this.#doBrowserMigration(migrationDetails, extraArgs);
        } else if (
          migrationDetails.type ==
          lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE
        ) {
          let window = this.browsingContext.topChromeWindow;
          await this.#doFileMigration(window, migrationDetails.key);
          return extraArgs;
        }
        break;
      }

      case "CheckPermissions": {
        if (
          message.data.type ==
          lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER
        ) {
          let migrator = await MigrationUtils.getMigrator(message.data.key);
          return migrator.hasPermissions();
        }
        return true;
      }

      case "RequestSafariPermissions": {
        let safariMigrator = await MigrationUtils.getMigrator("safari");
        return safariMigrator.getPermissions(
          this.browsingContext.topChromeWindow
        );
      }

      case "SelectSafariPasswordFile": {
        return this.#selectSafariPasswordFile(
          this.browsingContext.topChromeWindow
        );
      }

      case "RecordEvent": {
        this.#recordEvent(message.data.type, message.data.args);
        break;
      }
    }

    return null;
  }

  /**
   * Used for recording telemetry in the migration wizard.
   *
   * @param {string} type
   *   The type of event being recorded.
   * @param {object} args
   *   The data to pass to telemetry when the event is recorded.
   */
  #recordEvent(type, args = null) {
    Services.telemetry.recordEvent(
      "browser.migration",
      type,
      "wizard",
      null,
      args
    );
  }

  /**
   * Gets the FileMigrator associated with the passed in key, and then opens
   * a native file picker configured for that migrator. Once the user selects
   * a file from the native file picker, this is then passed to the
   * FileMigrator.migrate method.
   *
   * As the migration occurs, this will send UpdateProgress messages to the
   * MigrationWizardChild to show the beginning and then the ending state of
   * the migration.
   *
   * @param {DOMWindow} window
   *   The window that the native file picker should be associated with. This
   *   cannot be null. See nsIFilePicker.init for more details.
   * @param {string} key
   *   The unique identification key for a file migrator.
   * @returns {Promise<undefined>}
   *   Resolves once the file migrator's migrate method has resolved.
   */
  async #doFileMigration(window, key) {
    let fileMigrator = MigrationUtils.getFileMigrator(key);
    let filePickerConfig = await fileMigrator.getFilePickerConfig();

    let { result, path } = await new Promise(resolve => {
      let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
      fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen);

      for (let filter of filePickerConfig.filters) {
        fp.appendFilter(filter.title, filter.extensionPattern);
      }
      fp.appendFilters(Ci.nsIFilePicker.filterAll);
      fp.open(async fileOpenResult => {
        resolve({ result: fileOpenResult, path: fp.file.path });
      });
    });

    if (result == Ci.nsIFilePicker.returnCancel) {
      // If the user cancels out of the file picker, the migration wizard should
      // still be in the state that lets the user re-open the file picker if
      // they closed it by accident, so we don't have to do anything else here.
      return;
    }

    let progress = {};
    for (let resourceType of fileMigrator.displayedResourceTypes) {
      progress[resourceType] = {
        value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING,
        message: "",
      };
    }

    let [progressHeaderString, successHeaderString] =
      await lazy.gFluentStrings.formatValues([
        fileMigrator.progressHeaderL10nID,
        fileMigrator.successHeaderL10nID,
      ]);

    this.sendAsyncMessage("UpdateFileImportProgress", {
      title: progressHeaderString,
      progress,
    });

    let migrationResult;
    try {
      migrationResult = await fileMigrator.migrate(path);
    } catch (e) {
      this.sendAsyncMessage("FileImportProgressError", {
        migratorKey: key,
        fileImportErrorMessage: e.message,
      });
      return;
    }

    let successProgress = {};
    for (let resourceType in migrationResult) {
      successProgress[resourceType] = {
        value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS,
        message: migrationResult[resourceType],
      };
    }
    this.sendAsyncMessage("UpdateFileImportProgress", {
      title: successHeaderString,
      progress: successProgress,
    });
  }

  /**
   * Handles a request to open a native file picker to get the path to a
   * CSV file that contains passwords exported from Safari. The returned
   * path is in the form of a string, or `null` if the user cancelled the
   * native picker.
   *
   * @param {DOMWindow} window
   *   The window that the native file picker should be associated with. This
   *   cannot be null. See nsIFilePicker.init for more details.
   * @returns {Promise<string|null>}
   */
  async #selectSafariPasswordFile(window) {
    let fileMigrator = MigrationUtils.getFileMigrator(
      lazy.PasswordFileMigrator.key
    );
    let filePickerConfig = await fileMigrator.getFilePickerConfig();

    let { result, path } = await new Promise(resolve => {
      let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
      fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen);

      for (let filter of filePickerConfig.filters) {
        fp.appendFilter(filter.title, filter.extensionPattern);
      }
      fp.appendFilters(Ci.nsIFilePicker.filterAll);
      fp.open(async fileOpenResult => {
        resolve({ result: fileOpenResult, path: fp.file.path });
      });
    });

    if (result == Ci.nsIFilePicker.returnCancel) {
      // If the user cancels out of the file picker, the migration wizard should
      // still be in the state that lets the user re-open the file picker if
      // they closed it by accident, so we don't have to do anything else here.
      return null;
    }

    return path;
  }

  /**
   * Calls into MigrationUtils to perform a migration given the parameters
   * sent via the wizard.
   *
   * @param {MigrationDetails} migrationDetails
   *   See migration-wizard.mjs for a definition of MigrationDetails.
   * @param {object} extraArgs
   *   Extra argument object that will be passed to the Event Telemetry for
   *   finishing the migration. This was initialized in the child actor, and
   *   will be sent back down to it to write to Telemetry once migration
   *   completes.
   *
   * @returns {Promise<object>}
   *   Resolves once the Migration:Ended observer notification has fired,
   *   passing the extraArgs for Telemetry back with any relevant properties
   *   updated.
   */
  async #doBrowserMigration(migrationDetails, extraArgs) {
    Services.telemetry
      .getHistogramById("FX_MIGRATION_SOURCE_BROWSER")
      .add(MigrationUtils.getSourceIdForTelemetry(migrationDetails.key));

    let migrator = await MigrationUtils.getMigrator(migrationDetails.key);
    let availableResourceTypes = await migrator.getMigrateData(
      migrationDetails.profile
    );
    let resourceTypesToMigrate = 0;
    let progress = {};
    let migrationUsageHist =
      Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE");

    for (let resourceTypeName of migrationDetails.resourceTypes) {
      let resourceType = MigrationUtils.resourceTypes[resourceTypeName];
      if (availableResourceTypes & resourceType) {
        resourceTypesToMigrate |= resourceType;
        progress[resourceTypeName] = {
          value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING,
          message: "",
        };

        if (!migrationDetails.autoMigration) {
          migrationUsageHist.add(migrationDetails.key, Math.log2(resourceType));
        }
      }
    }

    if (
      migrationDetails.key == lazy.SafariProfileMigrator?.key &&
      migrationDetails.safariPasswordFilePath
    ) {
      // The caller supplied a password export file for Safari. We're going to
      // pretend that there was a PASSWORDS resource for Safari to represent
      // the state of importing from that file.
      progress[
        lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS
      ] = {
        value: lazy.MigrationWizardConstants.PROGRESS_VALUE.LOADING,
        message: "",
      };

      this.sendAsyncMessage("UpdateProgress", {
        key: migrationDetails.key,
        progress,
      });

      let summary = await lazy.LoginCSVImport.importFromCSV(
        migrationDetails.safariPasswordFilePath
      );
      let quantity = summary.filter(entry => entry.result == "added").length;

      progress[
        lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS
      ] = {
        value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS,
        message: await lazy.gFluentStrings.formatValue(
          "migration-wizard-progress-success-passwords",
          {
            quantity,
          }
        ),
      };
    }

    this.sendAsyncMessage("UpdateProgress", {
      key: migrationDetails.key,
      progress,
    });

    // It's possible that only a Safari password file path was sent up, and
    // there's nothing left to migrate, in which case we're done here.
    if (
      migrationDetails.safariPasswordFilePath &&
      !migrationDetails.resourceTypes.length
    ) {
      return extraArgs;
    }

    try {
      await migrator.migrate(
        resourceTypesToMigrate,
        false,
        migrationDetails.profile,
        async (resourceTypeNum, success, details) => {
          // Unfortunately, MigratorBase hands us the the numeric value of the
          // MigrationUtils.resourceType for this callback. For now, we'll just
          // do a look-up to map it to the right constant.
          let foundResourceTypeName;
          for (let resourceTypeName in MigrationUtils.resourceTypes) {
            if (
              MigrationUtils.resourceTypes[resourceTypeName] == resourceTypeNum
            ) {
              foundResourceTypeName = resourceTypeName;
              break;
            }
          }

          if (!foundResourceTypeName) {
            console.error(
              "Could not find a resource type for value: ",
              resourceTypeNum
            );
          } else {
            if (!success) {
              Services.telemetry
                .getKeyedHistogramById("FX_MIGRATION_ERRORS")
                .add(migrationDetails.key, Math.log2(resourceTypeNum));
            }
            if (
              foundResourceTypeName ==
              lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.EXTENSIONS
            ) {
              if (!success) {
                // did not match any extensions
                extraArgs.extensions =
                  lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.NONE_MATCHED;
                progress[foundResourceTypeName] = {
                  value: lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING,
                  message: await lazy.gFluentStrings.formatValue(
                    "migration-wizard-progress-no-matched-extensions"
                  ),
                  linkURL: Services.urlFormatter.formatURLPref(
                    "extensions.getAddons.link.url"
                  ),
                  linkText: await lazy.gFluentStrings.formatValue(
                    "migration-wizard-progress-extensions-addons-link"
                  ),
                };
              } else if (
                details?.progressValue ==
                lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS
              ) {
                // did match all extensions
                extraArgs.extensions =
                  lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.ALL_MATCHED;
                progress[foundResourceTypeName] = {
                  value: lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS,
                  message: await lazy.gFluentStrings.formatValue(
                    "migration-wizard-progress-success-extensions",
                    {
                      quantity: details.totalExtensions.length,
                    }
                  ),
                };
              } else if (
                details?.progressValue ==
                lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO
              ) {
                // did match some extensions
                extraArgs.extensions =
                  lazy.MigrationWizardConstants.EXTENSIONS_IMPORT_RESULT.PARTIAL_MATCH;
                progress[foundResourceTypeName] = {
                  value: lazy.MigrationWizardConstants.PROGRESS_VALUE.INFO,
                  message: await lazy.gFluentStrings.formatValue(
                    "migration-wizard-progress-partial-success-extensions",
                    {
                      matched: details.importedExtensions.length,
                      quantity: details.totalExtensions.length,
                    }
                  ),
                  linkURL:
                    Services.urlFormatter.formatURLPref("app.support.baseURL") +
                    "import-data-another-browser",
                  linkText: await lazy.gFluentStrings.formatValue(
                    "migration-wizard-progress-extensions-support-link"
                  ),
                };
              }
            } else {
              progress[foundResourceTypeName] = {
                value: success
                  ? lazy.MigrationWizardConstants.PROGRESS_VALUE.SUCCESS
                  : lazy.MigrationWizardConstants.PROGRESS_VALUE.WARNING,
                message: await this.#getStringForImportQuantity(
                  migrationDetails.key,
                  foundResourceTypeName
                ),
              };
            }
            this.sendAsyncMessage("UpdateProgress", {
              key: migrationDetails.key,
              progress,
            });
          }
        }
      );
    } catch (e) {
      console.error(e);
    }

    return extraArgs;
  }

  /**
   * @typedef {object} MigratorProfileInstance
   *   An object that describes a single user profile (or the default
   *   user profile) for a particular migrator.
   * @property {string} key
   *   The unique identification key for a migrator.
   * @property {string} displayName
   *   The display name for the migrator that will be shown to the user
   *   in the wizard.
   * @property {string[]} resourceTypes
   *   An array of strings, where each string represents a resource type
   *   that can be imported for this migrator and profile. The strings
   *   should be one of the key values of
   *   MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.
   *
   *   Example: ["HISTORY", "FORMDATA", "PASSWORDS", "BOOKMARKS"]
   * @property {object|null} profile
   *   A description of the user profile that the migrator can import.
   * @property {string} profile.id
   *   A unique ID for the user profile.
   * @property {string} profile.name
   *   The display name for the user profile.
   */

  /**
   * Asynchronously fetches a migrator for a particular key, and then
   * also gets any user profiles that exist on for that migrator. Resolves
   * to null if something goes wrong getting information about the migrator
   * or any of the user profiles.
   *
   * @param {string} key
   *   The unique identification key for a migrator.
   * @returns {Promise<MigratorProfileInstance[]|null>}
   */
  async #getMigratorAndProfiles(key) {
    try {
      let migrator = await MigrationUtils.getMigrator(key);
      if (!migrator?.enabled) {
        return null;
      }

      let sourceProfiles = await migrator.getSourceProfiles();
      if (Array.isArray(sourceProfiles)) {
        if (!sourceProfiles.length) {
          return null;
        }

        Services.telemetry.keyedScalarAdd(
          "migration.discovered_migrators",
          key,
          sourceProfiles.length
        );

        let result = [];
        for (let profile of sourceProfiles) {
          result.push(
            await this.#serializeMigratorAndProfile(migrator, profile)
          );
        }
        return result;
      }

      Services.telemetry.keyedScalarAdd(
        "migration.discovered_migrators",
        key,
        1
      );
      return this.#serializeMigratorAndProfile(migrator, sourceProfiles);
    } catch (e) {
      console.error(`Could not get migrator with key ${key}`, e);
    }
    return null;
  }

  /**
   * Asynchronously fetches information about what resource types can be
   * migrated for a particular migrator and user profile, and then packages
   * the migrator, user profile data, and resource type data into an object
   * that can be sent down to the MigrationWizardChild.
   *
   * @param {MigratorBase} migrator
   *   A migrator subclass of MigratorBase.
   * @param {object|null} profileObj
   *   The user profile object representing the profile to get information
   *   about. This object is usually gotten by calling getSourceProfiles on
   *   the migrator.
   * @returns {Promise<MigratorProfileInstance>}
   */
  async #serializeMigratorAndProfile(migrator, profileObj) {
    let [profileMigrationData, lastModifiedDate] = await Promise.all([
      migrator.getMigrateData(profileObj),
      migrator.getLastUsedDate(),
    ]);

    let availableResourceTypes = [];

    for (let resourceType in MigrationUtils.resourceTypes) {
      // Normally, we check each possible resourceType to see if we have one or
      // more corresponding resourceTypes in profileMigrationData. The exception
      // is for Safari, where the migrator does not expose a PASSWORDS resource
      // type, but we allow the user to express that they'd like to import
      // passwords from it anyways. This is because the Safari migration flow is
      // special, and allows the user to import passwords from a file exported
      // from Safari.
      if (
        profileMigrationData & MigrationUtils.resourceTypes[resourceType] ||
        (migrator.constructor.key == lazy.SafariProfileMigrator?.key &&
          MigrationUtils.resourceTypes[resourceType] ==
            MigrationUtils.resourceTypes.PASSWORDS &&
          Services.prefs.getBoolPref(
            "signon.management.page.fileImport.enabled",
            false
          ))
      ) {
        availableResourceTypes.push(resourceType);
      }
    }

    let displayName;

    if (migrator.constructor.key == lazy.InternalTestingProfileMigrator.key) {
      // In the case of the InternalTestingProfileMigrator, which is never seen
      // by users outside of testing, we don't make our localization community
      // localize it's display name, and just display the ID instead.
      displayName = migrator.constructor.displayNameL10nID;
    } else {
      displayName = await lazy.gFluentStrings.formatValue(
        migrator.constructor.displayNameL10nID
      );
    }

    return {
      type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER,
      key: migrator.constructor.key,
      displayName,
      brandImage: migrator.constructor.brandImage,
      resourceTypes: availableResourceTypes,
      profile: profileObj,
      lastModifiedDate,
    };
  }

  /**
   * Returns the "success" string for a particular resource type after
   * migration has completed.
   *
   * @param {string} migratorKey
   *   The key for the migrator being used.
   * @param {string} resourceTypeStr
   *   A string mapping to one of the key values of
   *   MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.
   * @returns {Promise<string>}
   *   The success string for the resource type after migration has completed.
   */
  #getStringForImportQuantity(migratorKey, resourceTypeStr) {
    if (migratorKey == lazy.FirefoxProfileMigrator.key) {
      return "";
    }

    switch (resourceTypeStr) {
      case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS: {
        let quantity = MigrationUtils.getImportedCount("bookmarks");
        let stringID = "migration-wizard-progress-success-bookmarks";

        if (
          lazy.MigrationWizardConstants.USES_FAVORITES.includes(migratorKey)
        ) {
          stringID = "migration-wizard-progress-success-favorites";
        }

        return lazy.gFluentStrings.formatValue(stringID, {
          quantity,
        });
      }
      case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY: {
        return lazy.gFluentStrings.formatValue(
          "migration-wizard-progress-success-history",
          {
            maxAgeInDays: MigrationUtils.HISTORY_MAX_AGE_IN_DAYS,
          }
        );
      }
      case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: {
        let quantity = MigrationUtils.getImportedCount("logins");
        return lazy.gFluentStrings.formatValue(
          "migration-wizard-progress-success-passwords",
          {
            quantity,
          }
        );
      }
      case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA: {
        return lazy.gFluentStrings.formatValue(
          "migration-wizard-progress-success-formdata"
        );
      }
      case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES
        .PAYMENT_METHODS: {
        let quantity = MigrationUtils.getImportedCount("cards");
        return lazy.gFluentStrings.formatValue(
          "migration-wizard-progress-success-payment-methods",
          {
            quantity,
          }
        );
      }
      default: {
        return "";
      }
    }
  }

  /**
   * Returns a Promise that resolves to a serializable representation of a
   * FileMigrator for sending down to the MigrationWizard.
   *
   * @param {FileMigrator} fileMigrator
   *   The FileMigrator to serialize.
   * @returns {Promise<object|null>}
   *   The serializable representation of the FileMigrator, or null if the
   *   migrator is disabled.
   */
  async #serializeFileMigrator(fileMigrator) {
    if (!fileMigrator.enabled) {
      return null;
    }

    return {
      type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE,
      key: fileMigrator.constructor.key,
      displayName: await lazy.gFluentStrings.formatValue(
        fileMigrator.constructor.displayNameL10nID
      ),
      brandImage: fileMigrator.constructor.brandImage,
      resourceTypes: [],
    };
  }
}
