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

const lazy = {};

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

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "usernameAutocompleteSearch",
  "@mozilla.org/autocomplete/search;1?name=login-doorhanger-username",
  Ci.nsIAutoCompleteSimpleSearch
);

ChromeUtils.defineLazyGetter(lazy, "l10n", () => {
  return new Localization(["toolkit/passwordmgr/passwordmgr.ftl"], true);
});

const LoginInfo = Components.Constructor(
  "@mozilla.org/login-manager/loginInfo;1",
  "nsILoginInfo",
  "init"
);

/**
 * Constants for password prompt telemetry.
 */
const PROMPT_DISPLAYED = 0;
const PROMPT_ADD_OR_UPDATE = 1;
const PROMPT_NOTNOW_OR_DONTUPDATE = 2;
const PROMPT_NEVER = 3;
const PROMPT_DELETE = 3;

/**
 * The minimum age of a doorhanger in ms before it will get removed after a locationchange
 */
const NOTIFICATION_TIMEOUT_MS = 10 * 1000; // 10 seconds

/**
 * The minimum age of an attention-requiring dismissed doorhanger in ms
 * before it will get removed after a locationchange
 */
const ATTENTION_NOTIFICATION_TIMEOUT_MS = 60 * 1000; // 1 minute

function autocompleteSelected(popup) {
  const doc = popup.ownerDocument;
  const nameField = doc.getElementById("password-notification-username");
  const passwordField = doc.getElementById("password-notification-password");

  const activeElement = nameField.ownerDocument.activeElement;
  if (activeElement == nameField) {
    popup.onUsernameSelect();
  } else if (activeElement == passwordField) {
    popup.onPasswordSelect();
  }
}

const observer = {
  QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),

  // nsIObserver
  observe(subject, topic, _data) {
    switch (topic) {
      case "autocomplete-did-enter-text": {
        const input = subject.QueryInterface(Ci.nsIAutoCompleteInput);
        autocompleteSelected(input.popupElement);
        break;
      }
    }
  },
};

/**
 * Implements interfaces for prompting the user to enter/save/change login info
 * found in HTML forms.
 */
export class LoginManagerPrompter {
  get classID() {
    return Components.ID("{c47ff942-9678-44a5-bc9b-05e0d676c79c}");
  }

  get QueryInterface() {
    return ChromeUtils.generateQI(["nsILoginManagerPrompter"]);
  }

  /**
   * Called when we detect a password or username that is not yet saved as
   * an existing login.
   *
   * @param {Element} aBrowser
   *                  The browser element that the request came from.
   * @param {nsILoginInfo} aLogin
   *                       The new login from the page form.
   * @param {boolean} [dismissed = false]
   *                  If the prompt should be automatically dismissed on being shown.
   * @param {boolean} [notifySaved = false]
   *                  Whether the notification should indicate that a login has been saved
   * @param {string} [autoSavedLoginGuid = ""]
   *                 A guid value for the old login to be removed if the changes match it
   *                 to a different login
   * @param {object?} possibleValues
   *                 Contains values from anything that we think, but are not sure, might be
   *                 a username or password.  Has two properties, 'usernames' and 'passwords'.
   * @param {Set<string>} possibleValues.usernames
   * @param {Set<string>} possibleValues.passwords
   */
  promptToSavePassword(
    aBrowser,
    aLogin,
    dismissed = false,
    notifySaved = false,
    autoFilledLoginGuid = "",
    possibleValues = undefined
  ) {
    lazy.log.debug("Prompting user to save login.");
    const inPrivateBrowsing = PrivateBrowsingUtils.isBrowserPrivate(aBrowser);
    const notification = LoginManagerPrompter._showLoginCaptureDoorhanger(
      aBrowser,
      aLogin,
      "password-save",
      {
        dismissed: inPrivateBrowsing || dismissed,
        extraAttr: notifySaved ? "attention" : "",
      },
      possibleValues,
      {
        notifySaved,
        autoFilledLoginGuid,
      }
    );
    Services.obs.notifyObservers(aLogin, "passwordmgr-prompt-save");

    return {
      dismiss() {
        const { PopupNotifications } = aBrowser.documentGlobal.wrappedJSObject;
        PopupNotifications.remove(notification);
      },
    };
  }

  /**
   * Displays the PopupNotifications.sys.mjs doorhanger for password save or change.
   *
   * @param {Element} browser
   *        The browser to show the doorhanger on.
   * @param {nsILoginInfo} login
   *        Login to save or change. For changes, this login should contain the
   *        new password and/or username
   * @param {string} type
   *        This is "password-save" or "password-change" depending on the
   *        original notification type. This is used for telemetry and tests.
   * @param {object} showOptions
   *        Options to pass along to PopupNotifications.show().
   * @param {bool} [options.notifySaved = false]
   *        Whether to indicate to the user that the login was already saved.
   * @param {string} [options.messageStringID = undefined]
   *        An optional string ID to override the default message.
   * @param {string} [options.autoSavedLoginGuid = ""]
   *        A string guid value for the auto-saved login to be removed if the changes
   *        match it to a different login
   * @param {string} [options.autoFilledLoginGuid = ""]
   *        A string guid value for the autofilled login
   * @param {object?} possibleValues
   *                 Contains values from anything that we think, but are not sure, might be
   *                 a username or password.  Has two properties, 'usernames' and 'passwords'.
   * @param {Set<string>} possibleValues.usernames
   * @param {Set<string>} possibleValues.passwords
   */
  static _showLoginCaptureDoorhanger(
    browser,
    login,
    type,
    showOptions = {},
    possibleValues = undefined,
    {
      notifySaved = false,
      messageStringID,
      autoSavedLoginGuid = "",
      autoFilledLoginGuid = "",
    } = {}
  ) {
    lazy.log.debug(
      `Got autoSavedLoginGuid: ${autoSavedLoginGuid} and autoFilledLoginGuid ${autoFilledLoginGuid}.`
    );

    const saveMessageIds = {
      prompt: "password-manager-save-password-message",
      mainButton: "password-manager-save-password-button-allow",
      secondaryButton: "password-manager-save-password-button-deny",
    };

    const changeMessageIds = {
      prompt: messageStringID ?? "password-manager-update-password-message",
      mainButton: "password-manager-password-password-button-allow",
      secondaryButton: "password-manager-save-password-button-deny",
    };

    const initialMessageIds =
      type == "password-save" ? saveMessageIds : changeMessageIds;

    const promptId = initialMessageIds.prompt;
    const host = this._getShortDisplayHost(login.origin);
    const promptMessage = lazy.l10n.formatValueSync(promptId, { host });

    const histogramName =
      type == "password-save"
        ? "PWMGR_PROMPT_REMEMBER_ACTION"
        : "PWMGR_PROMPT_UPDATE_ACTION";
    const histogramAdd = sample => {
      if (type == "password-save") {
        Glean.pwmgr.promptRememberAction.accumulateSingleSample(sample);
      } else {
        Glean.pwmgr.promptUpdateAction.accumulateSingleSample(sample);
      }
    };

    const chromeDoc = browser.ownerDocument;
    let currentNotification;

    const wasModifiedEvent = {
      // Values are mutated
      did_edit_un: false,
      did_select_un: false,
      did_edit_pw: false,
      did_select_pw: false,
    };

    const updateButtonStatus = () => {
      if (!currentNotification) {
        return;
      }
      const element = [...currentNotification.owner.panel.childNodes].find(
        n => n.notification == currentNotification
      );
      if (!element) {
        return;
      }
      const mainActionButton = element.button;
      // Disable the main button inside the menu-button if the password field is empty.
      if (!login.password.length) {
        mainActionButton.setAttribute("disabled", true);
        chromeDoc
          .getElementById("password-notification-password")
          .classList.add("popup-notification-invalid-input");
      } else {
        mainActionButton.removeAttribute("disabled");
        chromeDoc
          .getElementById("password-notification-password")
          .classList.remove("popup-notification-invalid-input");
      }
    };

    const updateButtonLabel = async () => {
      const foundLogins = await Services.logins.searchLoginsAsync({
        formActionOrigin: login.formActionOrigin,
        origin: login.origin,
        httpRealm: login.httpRealm,
        schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
      });

      const logins = this._filterUpdatableLogins(
        login,
        foundLogins,
        autoSavedLoginGuid
      );
      const messageIds = !logins.length ? saveMessageIds : changeMessageIds;

      // Update the label based on whether this will be a new login or not.

      const mainButton = this.getLabelAndAccessKey(messageIds.mainButton);

      if (!currentNotification) {
        console.error("updateButtonLabel, no currentNotification");
        return;
      }

      // Update the labels for the next time the panel is opened.
      currentNotification.mainAction.label = mainButton.label;
      currentNotification.mainAction.accessKey = mainButton.accessKey;

      // Update the labels in real time if the notification is displayed.
      const element = [...currentNotification.owner.panel.childNodes].find(
        n => n.notification == currentNotification
      );
      if (element) {
        element.setAttribute("buttonlabel", mainButton.label);
        element.setAttribute("buttonaccesskey", mainButton.accessKey);
        updateButtonStatus();
      }
    };

    const writeDataToUI = () => {
      const nameField = chromeDoc.getElementById(
        "password-notification-username"
      );

      nameField.placeholder = usernamePlaceholder;
      nameField.value = login.username;

      const passwordField = chromeDoc.getElementById(
        "password-notification-password"
      );
      // Ensure the field is masked.
      passwordField.inputEl.revealPassword = false;
      passwordField.value = login.password;

      updateButtonLabel();
    };

    const readDataFromUI = () => {
      login.username = chromeDoc.getElementById(
        "password-notification-username"
      ).value;
      login.password = chromeDoc.getElementById(
        "password-notification-password"
      ).value;
    };

    const onInput = () => {
      readDataFromUI();
      // Update the button's disabled state synchronously so it doesn't depend
      // on the async searchLoginsAsync call in updateButtonLabel.
      updateButtonStatus();
      updateButtonLabel();
    };

    const onUsernameInput = () => {
      wasModifiedEvent.did_edit_un = true;
      wasModifiedEvent.did_select_un = false;
      onInput();
    };

    const onUsernameSelect = () => {
      wasModifiedEvent.did_edit_un = false;
      wasModifiedEvent.did_select_un = true;
    };

    const onPasswordInput = () => {
      wasModifiedEvent.did_edit_pw = true;
      wasModifiedEvent.did_select_pw = false;
      onInput();
    };

    const onPasswordSelect = () => {
      wasModifiedEvent.did_edit_pw = false;
      wasModifiedEvent.did_select_pw = true;
    };

    const onKeyUp = e => {
      if (e.key == "Enter") {
        e.target.closest("popupnotification").button.click();
      }
    };

    const onDropmarkerClick = event => {
      event.currentTarget.inputEl.toggleHistoryPopup();
    };

    const persistData = async () => {
      const foundLogins = await Services.logins.searchLoginsAsync({
        formActionOrigin: login.formActionOrigin,
        origin: login.origin,
        httpRealm: login.httpRealm,
        schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
      });

      let logins = this._filterUpdatableLogins(
        login,
        foundLogins,
        autoSavedLoginGuid
      );
      const resolveBy = ["scheme", "timePasswordChanged"];
      logins = lazy.LoginHelper.dedupeLogins(
        logins,
        ["username"],
        resolveBy,
        login.origin
      );
      // sort exact username matches to the top
      logins.sort(l => (l.username == login.username ? -1 : 1));

      lazy.log.debug(`Matched ${logins.length} logins.`);

      let loginToRemove;
      const loginToUpdate = logins.shift();

      if (logins.length && logins[0].guid == autoSavedLoginGuid) {
        loginToRemove = logins.shift();
      }
      if (logins.length) {
        lazy.log.warn(
          "persistData:",
          logins.length,
          "other updatable logins!",
          logins.map(l => l.guid),
          "loginToUpdate:",
          loginToUpdate && loginToUpdate.guid,
          "loginToRemove:",
          loginToRemove && loginToRemove.guid
        );
        // Proceed with updating the login with the best username match rather
        // than returning and losing the edit.
      }

      if (!loginToUpdate) {
        // Create a new login, don't update an original.
        // The original login we have been provided with might have its own
        // metadata, but we don't want it propagated to the newly created one.
        await Services.logins.addLoginAsync(
          new LoginInfo(
            login.origin,
            login.formActionOrigin,
            login.httpRealm,
            login.username,
            login.password,
            login.usernameField,
            login.passwordField
          )
        );
      } else if (
        loginToUpdate.password == login.password &&
        loginToUpdate.username == login.username
      ) {
        // We only want to touch the login's use count and last used time.
        lazy.log.debug(`Touch matched login: ${loginToUpdate.guid}.`);
        await Services.logins.recordPasswordUseAsync(
          loginToUpdate,
          PrivateBrowsingUtils.isBrowserPrivate(browser),
          loginToUpdate.username ? "FormPassword" : "FormLogin",
          !!autoFilledLoginGuid
        );
      } else {
        lazy.log.debug(`Update matched login: ${loginToUpdate.guid}.`);
        await this._updateLogin(loginToUpdate, login);
        // notify that this auto-saved login has been merged
        if (loginToRemove && loginToRemove.guid == autoSavedLoginGuid) {
          Services.obs.notifyObservers(
            loginToRemove,
            "passwordmgr-autosaved-login-merged"
          );
        }
      }

      if (loginToRemove) {
        lazy.log.debug(`Removing login ${loginToRemove.guid}.`);
        await Services.logins.removeLoginAsync(loginToRemove);
      }
    };

    const mainButton = this.getLabelAndAccessKey(initialMessageIds.mainButton);

    // The main action is the "Save" or "Update" button.
    const mainAction = {
      label: mainButton.label,
      accessKey: mainButton.accessKey,
      callback: async () => {
        const eventTypeMapping = {
          "password-save": {
            eventObject: "Save",
            confirmationHintFtlId: "confirmation-hint-password-created",
          },
          "password-change": {
            eventObject: "Update",
            confirmationHintFtlId: "confirmation-hint-password-updated",
          },
        };

        if (!eventTypeMapping[type]) {
          throw new Error(`Unexpected doorhanger type: '${type}'`);
        }

        readDataFromUI();
        if (
          type == "password-save" &&
          !Services.policies.isAllowed("removeMasterPassword")
        ) {
          if (!lazy.LoginHelper.isPrimaryPasswordSet()) {
            browser.documentGlobal.openDialog(
              "chrome://mozapps/content/preferences/changemp.xhtml",
              "",
              "centerscreen,chrome,modal,titlebar"
            );
            if (!lazy.LoginHelper.isPrimaryPasswordSet()) {
              return;
            }
          }
        }
        histogramAdd(PROMPT_ADD_OR_UPDATE);

        showConfirmation(browser, eventTypeMapping[type].confirmationHintFtlId);
        // The popup does not wait until this promise is resolved, but is
        // closed immediately when the function is returned. Therefore, we set
        // the focus before awaiting the asynchronous operation.
        browser.focus();
        await persistData();

        Glean.pwmgr[
          "doorhangerSubmitted" + eventTypeMapping[type].eventObject
        ].record(wasModifiedEvent);

        if (histogramName == "PWMGR_PROMPT_REMEMBER_ACTION") {
          Services.obs.notifyObservers(browser, "LoginStats:NewSavedPassword");
        } else if (histogramName == "PWMGR_PROMPT_UPDATE_ACTION") {
          Services.obs.notifyObservers(browser, "LoginStats:LoginUpdateSaved");
        }

        Services.obs.notifyObservers(
          null,
          "weave:telemetry:histogram",
          histogramName
        );
      },
    };

    const secondaryButton = this.getLabelAndAccessKey(
      initialMessageIds.secondaryButton
    );

    const secondaryActions = [
      {
        label: secondaryButton.label,
        accessKey: secondaryButton.accessKey,
        callback: () => {
          histogramAdd(PROMPT_NOTNOW_OR_DONTUPDATE);
          Services.obs.notifyObservers(
            null,
            "weave:telemetry:histogram",
            histogramName
          );
          browser.focus();
        },
      },
    ];
    // Include a "Never for this site" button when saving a new password.
    if (type == "password-save") {
      const neverSaveButton = this.getLabelAndAccessKey(
        "password-manager-save-password-button-never"
      );
      secondaryActions.push({
        label: neverSaveButton.label,
        accessKey: neverSaveButton.accessKey,
        callback: () => {
          histogramAdd(PROMPT_NEVER);
          Services.obs.notifyObservers(
            null,
            "weave:telemetry:histogram",
            histogramName
          );
          Services.logins.setLoginSavingEnabled(login.origin, false);
          browser.focus();
        },
      });
    }

    const updatePasswordButtonDelete = this.getLabelAndAccessKey(
      "password-manager-update-password-button-delete"
    );

    // Include a "Delete this login" button when updating an existing password
    if (type == "password-change") {
      secondaryActions.push({
        label: updatePasswordButtonDelete.label,
        accessKey: updatePasswordButtonDelete.accessKey,
        callback: async () => {
          histogramAdd(PROMPT_DELETE);
          Services.obs.notifyObservers(
            null,
            "weave:telemetry:histogram",
            histogramName
          );
          const matchingLogins = await Services.logins.searchLoginsAsync({
            guid: login.guid,
            origin: login.origin,
          });
          await Services.logins.removeLoginAsync(matchingLogins[0]);
          browser.focus();
          lazy.log.debug("Showing the ConfirmationHint");
          showConfirmation(browser, "confirmation-hint-password-removed");
        },
      });
    }

    const usernamePlaceholder = lazy.l10n.formatValueSync(
      "password-manager-no-username-placeholder"
    );

    // .wrappedJSObject needed here -- see bug 422974 comment 5.
    const { PopupNotifications } = browser.documentGlobal.wrappedJSObject;

    const notificationID = "password";

    // Don't downgrade an already-open (shown) doorhanger to dismissed. A form
    // submission and a field-edit notification can race to (re)create the same
    // "password" doorhanger, so the dismissed edit prompt can land last and
    // hide a doorhanger the user is looking at.
    if (showOptions.dismissed) {
      const existing = PopupNotifications?.getNotification(
        notificationID,
        browser
      );
      if (existing && !existing.dismissed) {
        showOptions = { ...showOptions, dismissed: false };
      }
    }

    // keep attention notifications around for longer after a locationchange
    const timeoutMs =
      showOptions.dismissed && showOptions.extraAttr == "attention"
        ? ATTENTION_NOTIFICATION_TIMEOUT_MS
        : NOTIFICATION_TIMEOUT_MS;

    const options = Object.assign(
      {
        timeout: Date.now() + timeoutMs,
        autofocus: true,
        persistWhileVisible: true,
        lowerPanelLevel: true,
        passwordNotificationType: type,
        hideClose: true,
        eventCallback(topic) {
          switch (topic) {
            case "showing":
              {
                lazy.log.debug("showing");
                currentNotification = this;

                // Record the first time this instance of the doorhanger is shown.
                if (!this.timeShown) {
                  histogramAdd(PROMPT_DISPLAYED);
                  Services.obs.notifyObservers(
                    null,
                    "weave:telemetry:histogram",
                    histogramName
                  );
                }

                chromeDoc
                  .getElementById("password-notification-password")
                  .removeAttribute("focused");
                chromeDoc
                  .getElementById("password-notification-username")
                  .removeAttribute("focused");
                chromeDoc
                  .getElementById("password-notification-username")
                  .addEventListener("input", onUsernameInput);
                chromeDoc
                  .getElementById("password-notification-username")
                  .addEventListener("keyup", onKeyUp);
                chromeDoc
                  .getElementById("password-notification-username")
                  .addEventListener("dropmarker-click", onDropmarkerClick);
                chromeDoc
                  .getElementById("password-notification-password")
                  .addEventListener("keyup", onKeyUp);
                chromeDoc
                  .getElementById("password-notification-password")
                  .addEventListener("input", onPasswordInput);
                let popup = chromeDoc.getElementById("PopupAutoComplete");
                popup.onUsernameSelect = onUsernameSelect;
                popup.onPasswordSelect = onPasswordSelect;

                LoginManagerPrompter._getUsernameSuggestions(
                  login,
                  possibleValues?.usernames
                ).then(usernameSuggestions => {
                  // Set autocomplete result before enabling the dropmarker
                  LoginManagerPrompter._setUsernameAutocomplete(
                    usernameSuggestions
                  );
                  const usernameField = chromeDoc?.getElementById(
                    "password-notification-username"
                  );
                  if (usernameField) {
                    usernameField.showDropmarker = !!usernameSuggestions.length;
                  }
                });
              }
              break;
            case "shown": {
              lazy.log.debug("shown");
              writeDataToUI();
              const anchorIcon = this.anchorElement;
              if (anchorIcon && this.options.extraAttr == "attention") {
                anchorIcon.removeAttribute("extraAttr");
                delete this.options.extraAttr;
              }
              break;
            }
            case "dismissed":
              // Note that this can run after `showing` but before `shown` upon tab switch.
              this.wasDismissed = true;
            // Fall through.
            case "removed": {
              // Note that this can run after `showing` and `shown` for the
              // notification it's replacing.
              lazy.log.debug(topic);
              currentNotification = null;

              const usernameField = chromeDoc.getElementById(
                "password-notification-username"
              );
              usernameField.removeEventListener("input", onUsernameInput);
              usernameField.removeEventListener("keyup", onKeyUp);
              usernameField.removeEventListener(
                "dropmarker-click",
                onDropmarkerClick
              );
              usernameField.inputEl?.detachController();
              const passwordField = chromeDoc.getElementById(
                "password-notification-password"
              );
              passwordField.removeEventListener("input", onPasswordInput);
              passwordField.removeEventListener("keyup", onKeyUp);
              break;
            }
          }
          return false;
        },
      },
      showOptions
    );

    const notification = PopupNotifications.show(
      browser,
      notificationID,
      promptMessage,
      "password-notification-icon",
      mainAction,
      secondaryActions,
      options
    );

    if (notifySaved) {
      showConfirmation(
        browser,
        "confirmation-hint-password-created",
        "password-notification-icon"
      );
    }

    return notification;
  }

  /**
   * Called when we think we detect a password or username change for
   * an existing login, when the form being submitted contains multiple
   * password fields.
   *
   * @param {Element} aBrowser
   *                  The browser element that the request came from.
   * @param {nsILoginInfo} aOldLogin
   *                       The old login we may want to update.
   * @param {nsILoginInfo} aNewLogin
   *                       The new login from the page form.
   * @param {boolean} [dismissed = false]
   *                  If the prompt should be automatically dismissed on being shown.
   * @param {boolean} [notifySaved = false]
   *                  Whether the notification should indicate that a login has been saved
   * @param {string} [autoSavedLoginGuid = ""]
   *                 A guid value for the old login to be removed if the changes match it
   *                 to a different login
   * @param {object?} possibleValues
   *                 Contains values from anything that we think, but are not sure, might be
   *                 a username or password.  Has two properties, 'usernames' and 'passwords'.
   * @param {Set<string>} possibleValues.usernames
   * @param {Set<string>} possibleValues.passwords
   */
  promptToChangePassword(
    aBrowser,
    aOldLogin,
    aNewLogin,
    dismissed = false,
    notifySaved = false,
    autoSavedLoginGuid = "",
    autoFilledLoginGuid = "",
    possibleValues = undefined
  ) {
    const login = aOldLogin.clone();
    login.origin = aNewLogin.origin;
    login.formActionOrigin = aNewLogin.formActionOrigin;
    login.password = aNewLogin.password;
    login.username = aNewLogin.username;

    let messageStringID;
    if (
      aOldLogin.username === "" &&
      login.username !== "" &&
      login.password == aOldLogin.password
    ) {
      // If the saved password matches the password we're prompting with then we
      // are only prompting to let the user add a username since there was one in
      // the form. Change the message so the purpose of the prompt is clearer.
      messageStringID = "password-manager-update-login-add-username";
    }

    const notification = LoginManagerPrompter._showLoginCaptureDoorhanger(
      aBrowser,
      login,
      "password-change",
      {
        dismissed,
        extraAttr: notifySaved ? "attention" : "",
      },
      possibleValues,
      {
        notifySaved,
        messageStringID,
        autoSavedLoginGuid,
        autoFilledLoginGuid,
      }
    );

    const oldGUID = aOldLogin.QueryInterface(Ci.nsILoginMetaInfo).guid;
    Services.obs.notifyObservers(
      aNewLogin,
      "passwordmgr-prompt-change",
      oldGUID
    );

    return {
      dismiss() {
        const { PopupNotifications } = aBrowser.documentGlobal.wrappedJSObject;
        PopupNotifications.remove(notification);
      },
    };
  }

  /**
   * Called when we detect a password change in a form submission, but we
   * don't know which existing login (username) it's for. Asks the user
   * to select a username and confirm the password change.
   *
   * Note: The caller doesn't know the username for aNewLogin, so this
   *       function fills in .username and .usernameField with the values
   *       from the login selected by the user.
   */
  async promptToChangePasswordWithUsernames(browser, logins, aNewLogin) {
    lazy.log.debug(
      `Prompting user to change passowrd for username with count: ${logins.length}.`
    );

    const noUsernamePlaceholder = lazy.l10n.formatValueSync(
      "password-manager-no-username-placeholder"
    );
    const usernames = logins.map(l => l.username || noUsernamePlaceholder);
    const dialogText = lazy.l10n.formatValueSync(
      "password-manager-select-username"
    );
    const dialogTitle = lazy.l10n.formatValueSync(
      "password-manager-confirm-password-change"
    );
    const selectedIndex = { value: null };

    // If user selects ok, outparam.value is set to the index
    // of the selected username.
    const ok = Services.prompt.select(
      browser.documentGlobal,
      dialogTitle,
      dialogText,
      usernames,
      selectedIndex
    );
    if (ok) {
      // Now that we know which login to use, modify its password.
      const selectedLogin = logins[selectedIndex.value];
      lazy.log.debug(`Updating password for origin: ${aNewLogin.origin}.`);
      const newLoginWithUsername = Cc[
        "@mozilla.org/login-manager/loginInfo;1"
      ].createInstance(Ci.nsILoginInfo);
      newLoginWithUsername.init(
        aNewLogin.origin,
        aNewLogin.formActionOrigin,
        aNewLogin.httpRealm,
        selectedLogin.username,
        aNewLogin.password,
        selectedLogin.usernameField,
        aNewLogin.passwordField
      );
      await LoginManagerPrompter._updateLogin(
        selectedLogin,
        newLoginWithUsername
      );
    }
  }

  /* ---------- Internal Methods ---------- */

  /**
   * Helper method to update and persist an existing nsILoginInfo object with new property values.
   */
  static async _updateLogin(login, aNewLogin) {
    const now = Date.now();
    const propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
      Ci.nsIWritablePropertyBag
    );
    propBag.setProperty("formActionOrigin", aNewLogin.formActionOrigin);
    propBag.setProperty("origin", aNewLogin.origin);
    propBag.setProperty("password", aNewLogin.password);
    propBag.setProperty("username", aNewLogin.username);
    // Explicitly set the password change time here (even though it would
    // be changed automatically), to ensure that it's exactly the same
    // value as timeLastUsed.
    propBag.setProperty("timePasswordChanged", now);
    propBag.setProperty("timeLastUsed", now);
    propBag.setProperty("timesUsedIncrement", 1);
    // Note that we don't call `recordPasswordUse` so telemetry won't record a
    // use in this case though that is normally correct since we would instead
    // record the save/update in a separate probe and recording it in both would
    // be wrong.

    await Services.logins.modifyLoginAsync(login, propBag);
  }

  /**
   * Retrieves the message of the given id from fluent
   * and extracts the label and accesskey
   *
   * @param {string} id message id
   * @returns label and accesskey
   */
  static getLabelAndAccessKey(id) {
    const msg = lazy.l10n.formatMessagesSync([id])[0];
    return {
      label: msg.attributes.find(x => x.name == "label").value,
      accessKey: msg.attributes.find(x => x.name == "accesskey").value,
    };
  }

  /**
   * Converts a login's origin field to a short string for
   * prompting purposes. Eg, "http://foo.com" --> "foo.com", or
   * "ftp://www.site.co.uk" --> "site.co.uk".
   */
  static _getShortDisplayHost(aURIString) {
    let displayHost;

    const idnService = Cc["@mozilla.org/network/idn-service;1"].getService(
      Ci.nsIIDNService
    );
    try {
      const uri = Services.io.newURI(aURIString);
      const baseDomain = Services.eTLD.getBaseDomain(uri);
      displayHost = idnService.convertToDisplayIDN(baseDomain);
    } catch (e) {
      lazy.log.warn(`Couldn't process supplied URIString: ${aURIString}`);
    }

    if (!displayHost) {
      displayHost = aURIString;
    }

    return displayHost;
  }

  /**
   * This function looks for existing logins that can be updated
   * to match a submitted login, instead of creating a new one.
   *
   * Given a login and a loginList, it filters the login list
   * to find every login with either:
   * - the same username as aLogin
   * - the same password as aLogin and an empty username
   *   so the user can add a username.
   * - the same guid as the given login when it has an empty username
   *
   * @param {nsILoginInfo} aLogin
   *                       login to use as filter.
   * @param {nsILoginInfo[]} aLoginList
   *                         Array of logins to filter.
   * @param {string} includeGUID
   *                 guid value for login that not be filtered out
   * @returns {nsILoginInfo[]} the filtered array of logins.
   */
  static _filterUpdatableLogins(aLogin, aLoginList, includeGUID) {
    return aLoginList.filter(
      l =>
        l.username == aLogin.username ||
        (l.password == aLogin.password && !l.username) ||
        (includeGUID && includeGUID == l.guid)
    );
  }

  /**
   * Set the values that will be used the next time the username autocomplete popup is opened.
   *
   * @param {object[]} usernames - ordered suggestions from _getUsernameSuggestions.
   */
  static _setUsernameAutocomplete(usernames) {
    const result = Cc[
      "@mozilla.org/autocomplete/simple-result;1"
    ].createInstance(Ci.nsIAutoCompleteSimpleResult);
    result.setDefaultIndex(0);

    for (const { text, style } of usernames) {
      const value = text;
      const comment = "";
      const image = "";
      const _style = style;
      result.appendMatch(value, comment, image, _style);
    }

    result.setSearchResult(
      usernames.length
        ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS
        : Ci.nsIAutoCompleteResult.RESULT_NOMATCH
    );

    lazy.usernameAutocompleteSearch.overrideNextResult(result);
  }

  /**
   * @param {nsILoginInfo} login - used only for its information about the current domain.
   * @param {Set<string>?} possibleUsernames - values that we believe may be new/changed login usernames.
   *
   * @returns {object[]} an ordered list of usernames to be used the next time the username autocomplete popup is opened.
   */
  static async _getUsernameSuggestions(login, possibleUsernames = new Set()) {
    if (!Services.prefs.getBoolPref("signon.capture.inputChanges.enabled")) {
      return [];
    }

    // Don't reprompt for Primary Password, as we already prompted at least once
    // to show the doorhanger if it is locked
    if (!Services.logins.isLoggedIn) {
      return [];
    }

    const baseDomainLogins = await Services.logins.searchLoginsAsync({
      origin: login.origin,
      schemeUpgrades: lazy.LoginHelper.schemeUpgrades,
      acceptDifferentSubdomains: true,
    });

    const saved = baseDomainLogins.map(savedLogin => {
      return { text: savedLogin.username, style: "login" };
    });
    const possible = [...possibleUsernames].map(username => {
      return { text: username, style: "possible-username" };
    });

    return possible
      .concat(saved)
      .reduce((acc, next) => {
        const alreadyInAcc =
          acc.findIndex(entry => entry.text == next.text) != -1;
        if (!alreadyInAcc) {
          acc.push(next);
        } else if (next.style == "possible-username") {
          const existingIndex = acc.findIndex(entry => entry.text == next.text);
          acc[existingIndex] = next;
        }
        return acc;
      }, [])
      .filter(suggestion => !!suggestion.text);
  }
}

// Add this observer once for the process.
Services.obs.addObserver(observer, "autocomplete-did-enter-text");

ChromeUtils.defineLazyGetter(lazy, "log", () => {
  return lazy.LoginHelper.createLogger("LoginManagerPrompter");
});
