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

// _AboutLogins is only exported for testing
import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs",
  LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs",
  LoginExport: "resource://gre/modules/LoginExport.sys.mjs",
  LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
  MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
  OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
  UIState: "resource://services-sync/UIState.sys.mjs",
});

XPCOMUtils.defineLazyGetter(lazy, "log", () => {
  return lazy.LoginHelper.createLogger("AboutLoginsParent");
});
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "BREACH_ALERTS_ENABLED",
  "signon.management.page.breach-alerts.enabled",
  false
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "FXA_ENABLED",
  "identity.fxaccounts.enabled",
  false
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "OS_AUTH_ENABLED",
  "signon.management.page.os-auth.enabled",
  true
);
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "VULNERABLE_PASSWORDS_ENABLED",
  "signon.management.page.vulnerable-passwords.enabled",
  false
);
XPCOMUtils.defineLazyGetter(lazy, "AboutLoginsL10n", () => {
  return new Localization(["branding/brand.ftl", "browser/aboutLogins.ftl"]);
});

const ABOUT_LOGINS_ORIGIN = "about:logins";
const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const PRIMARY_PASSWORD_NOTIFICATION_ID = "primary-password-login-required";

// about:logins will always use the privileged content process,
// even if it is disabled for other consumers such as about:newtab.
const EXPECTED_ABOUTLOGINS_REMOTE_TYPE = E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE;
let _gPasswordRemaskTimeout = null;
const convertSubjectToLogin = subject => {
  subject.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo);
  const login = lazy.LoginHelper.loginToVanillaObject(subject);
  if (!lazy.LoginHelper.isUserFacingLogin(login)) {
    return null;
  }
  return augmentVanillaLoginObject(login);
};

const SUBDOMAIN_REGEX = new RegExp(/^www\d*\./);
const augmentVanillaLoginObject = login => {
  // Note that `displayOrigin` can also include a httpRealm.
  let title = login.displayOrigin.replace(SUBDOMAIN_REGEX, "");
  return Object.assign({}, login, {
    title,
  });
};

export class AboutLoginsParent extends JSWindowActorParent {
  async receiveMessage(message) {
    if (!this.browsingContext.embedderElement) {
      return;
    }

    // Only respond to messages sent from a privlegedabout process. Ideally
    // we would also check the contentPrincipal.originNoSuffix but this
    // check has been removed due to bug 1576722.
    if (
      this.browsingContext.embedderElement.remoteType !=
      EXPECTED_ABOUTLOGINS_REMOTE_TYPE
    ) {
      throw new Error(
        `AboutLoginsParent: Received ${message.name} message the remote type didn't match expectations: ${this.browsingContext.embedderElement.remoteType} == ${EXPECTED_ABOUTLOGINS_REMOTE_TYPE}`
      );
    }

    AboutLogins.subscribers.add(this.browsingContext);

    switch (message.name) {
      case "AboutLogins:CreateLogin": {
        await this.#createLogin(message.data.login);
        break;
      }
      case "AboutLogins:DeleteLogin": {
        this.#deleteLogin(message.data.login);
        break;
      }
      case "AboutLogins:SortChanged": {
        this.#sortChanged(message.data);
        break;
      }
      case "AboutLogins:SyncEnable": {
        this.#syncEnable();
        break;
      }
      case "AboutLogins:SyncOptions": {
        this.#syncOptions();
        break;
      }
      case "AboutLogins:ImportFromBrowser": {
        this.#importFromBrowser();
        break;
      }
      case "AboutLogins:ImportReportInit": {
        this.#importReportInit();
        break;
      }
      case "AboutLogins:GetHelp": {
        this.#getHelp();
        break;
      }
      case "AboutLogins:OpenPreferences": {
        this.#openPreferences();
        break;
      }
      case "AboutLogins:PrimaryPasswordRequest": {
        await this.#primaryPasswordRequest(message.data);
        break;
      }
      case "AboutLogins:Subscribe": {
        await this.#subscribe();
        break;
      }
      case "AboutLogins:UpdateLogin": {
        this.#updateLogin(message.data.login);
        break;
      }
      case "AboutLogins:ExportPasswords": {
        await this.#exportPasswords();
        break;
      }
      case "AboutLogins:ImportFromFile": {
        await this.#importFromFile();
        break;
      }
      case "AboutLogins:RemoveAllLogins": {
        this.#removeAllLogins();
        break;
      }
    }
  }

  get #ownerGlobal() {
    return this.browsingContext.embedderElement.ownerGlobal;
  }

  async #createLogin(newLogin) {
    if (!Services.policies.isAllowed("removeMasterPassword")) {
      if (!lazy.LoginHelper.isPrimaryPasswordSet()) {
        this.#ownerGlobal.openDialog(
          "chrome://mozapps/content/preferences/changemp.xhtml",
          "",
          "centerscreen,chrome,modal,titlebar"
        );
        if (!lazy.LoginHelper.isPrimaryPasswordSet()) {
          return;
        }
      }
    }
    // Remove the path from the origin, if it was provided.
    let origin = lazy.LoginHelper.getLoginOrigin(newLogin.origin);
    if (!origin) {
      console.error(
        "AboutLogins:CreateLogin: Unable to get an origin from the login details."
      );
      return;
    }
    newLogin.origin = origin;
    Object.assign(newLogin, {
      formActionOrigin: "",
      usernameField: "",
      passwordField: "",
    });
    newLogin = lazy.LoginHelper.vanillaObjectToLogin(newLogin);
    try {
      await Services.logins.addLoginAsync(newLogin);
    } catch (error) {
      this.#handleLoginStorageErrors(newLogin, error);
    }
  }

  #deleteLogin(loginObject) {
    let login = lazy.LoginHelper.vanillaObjectToLogin(loginObject);
    Services.logins.removeLogin(login);
  }

  #sortChanged(sort) {
    Services.prefs.setCharPref("signon.management.page.sort", sort);
  }

  #syncEnable() {
    this.#ownerGlobal.gSync.openFxAEmailFirstPage("password-manager");
  }

  #syncOptions() {
    this.#ownerGlobal.gSync.openFxAManagePage("password-manager");
  }

  #importFromBrowser() {
    try {
      lazy.MigrationUtils.showMigrationWizard(this.#ownerGlobal, {
        entrypoint: lazy.MigrationUtils.MIGRATION_ENTRYPOINTS.PASSWORDS,
      });
    } catch (ex) {
      console.error(ex);
    }
  }

  #importReportInit() {
    let reportData = lazy.LoginCSVImport.lastImportReport;
    this.sendAsyncMessage("AboutLogins:ImportReportData", reportData);
  }

  #getHelp() {
    const SUPPORT_URL =
      Services.urlFormatter.formatURLPref("app.support.baseURL") +
      "password-manager-remember-delete-edit-logins";
    this.#ownerGlobal.openWebLinkIn(SUPPORT_URL, "tab", {
      relatedToCurrent: true,
    });
  }

  #openPreferences() {
    this.#ownerGlobal.openPreferences("privacy-logins");
  }

  async #primaryPasswordRequest(messageId) {
    if (!messageId) {
      throw new Error("AboutLogins:PrimaryPasswordRequest: no messageId.");
    }
    let messageText = { value: "NOT SUPPORTED" };
    let captionText = { value: "" };

    // This feature is only supported on Windows and macOS
    // but we still call in to OSKeyStore on Linux to get
    // the proper auth_details for Telemetry.
    // See bug 1614874 for Linux support.
    if (lazy.OS_AUTH_ENABLED && lazy.OSKeyStore.canReauth()) {
      messageId += "-" + AppConstants.platform;
      [messageText, captionText] = await lazy.AboutLoginsL10n.formatMessages([
        {
          id: messageId,
        },
        {
          id: "about-logins-os-auth-dialog-caption",
        },
      ]);
    }

    let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth(
      this.browsingContext.embedderElement,
      lazy.OS_AUTH_ENABLED,
      AboutLogins._authExpirationTime,
      messageText.value,
      captionText.value
    );
    this.sendAsyncMessage("AboutLogins:PrimaryPasswordResponse", {
      result: isAuthorized,
      telemetryEvent,
    });
    if (isAuthorized) {
      AboutLogins._authExpirationTime = Date.now() + AUTH_TIMEOUT_MS;
      const remaskPasswords = () => {
        this.sendAsyncMessage("AboutLogins:RemaskPassword");
      };
      clearTimeout(_gPasswordRemaskTimeout);
      _gPasswordRemaskTimeout = setTimeout(remaskPasswords, AUTH_TIMEOUT_MS);
    }
  }

  async #subscribe() {
    AboutLogins._authExpirationTime = Number.NEGATIVE_INFINITY;
    AboutLogins.addObservers();

    const logins = await AboutLogins.getAllLogins();
    try {
      let syncState = AboutLogins.getSyncState();

      let selectedSort = Services.prefs.getCharPref(
        "signon.management.page.sort",
        "name"
      );
      if (selectedSort == "breached") {
        // The "breached" value was used since Firefox 70 and
        // replaced with "alerts" in Firefox 76.
        selectedSort = "alerts";
      }
      this.sendAsyncMessage("AboutLogins:Setup", {
        logins,
        selectedSort,
        syncState,
        primaryPasswordEnabled: lazy.LoginHelper.isPrimaryPasswordSet(),
        passwordRevealVisible: Services.policies.isAllowed("passwordReveal"),
        importVisible:
          Services.policies.isAllowed("profileImport") &&
          AppConstants.platform != "linux",
      });

      await AboutLogins.sendAllLoginRelatedObjects(
        logins,
        this.browsingContext
      );
    } catch (ex) {
      if (ex.result != Cr.NS_ERROR_NOT_INITIALIZED) {
        throw ex;
      }

      // The message manager may be destroyed before the replies can be sent.
      lazy.log.debug(
        "AboutLogins:Subscribe: exception when replying with logins",
        ex
      );
    }
  }

  #updateLogin(loginUpdates) {
    let logins = lazy.LoginHelper.searchLoginsWithObject({
      guid: loginUpdates.guid,
    });
    if (logins.length != 1) {
      lazy.log.warn(
        `AboutLogins:UpdateLogin: expected to find a login for guid: ${loginUpdates.guid} but found ${logins.length}`
      );
      return;
    }

    let modifiedLogin = logins[0].clone();
    if (loginUpdates.hasOwnProperty("username")) {
      modifiedLogin.username = loginUpdates.username;
    }
    if (loginUpdates.hasOwnProperty("password")) {
      modifiedLogin.password = loginUpdates.password;
    }
    try {
      Services.logins.modifyLogin(logins[0], modifiedLogin);
    } catch (error) {
      this.#handleLoginStorageErrors(modifiedLogin, error);
    }
  }

  async #exportPasswords() {
    let messageText = { value: "NOT SUPPORTED" };
    let captionText = { value: "" };

    // This feature is only supported on Windows and macOS
    // but we still call in to OSKeyStore on Linux to get
    // the proper auth_details for Telemetry.
    // See bug 1614874 for Linux support.
    if (lazy.OSKeyStore.canReauth()) {
      let messageId =
        "about-logins-export-password-os-auth-dialog-message-" +
        AppConstants.platform;
      [messageText, captionText] = await lazy.AboutLoginsL10n.formatMessages([
        {
          id: messageId,
        },
        {
          id: "about-logins-os-auth-dialog-caption",
        },
      ]);
    }

    let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth(
      this.browsingContext.embedderElement,
      true,
      null, // Prompt regardless of a recent prompt
      messageText.value,
      captionText.value
    );

    let { method, object, extra = {}, value = null } = telemetryEvent;
    Services.telemetry.recordEvent("pwmgr", method, object, value, extra);

    if (!isAuthorized) {
      return;
    }

    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
    function fpCallback(aResult) {
      if (aResult != Ci.nsIFilePicker.returnCancel) {
        lazy.LoginExport.exportAsCSV(fp.file.path);
        Services.telemetry.recordEvent(
          "pwmgr",
          "mgmt_menu_item_used",
          "export_complete"
        );
      }
    }
    let [title, defaultFilename, okButtonLabel, csvFilterTitle] =
      await lazy.AboutLoginsL10n.formatValues([
        {
          id: "about-logins-export-file-picker-title",
        },
        {
          id: "about-logins-export-file-picker-default-filename",
        },
        {
          id: "about-logins-export-file-picker-export-button",
        },
        {
          id: "about-logins-export-file-picker-csv-filter-title",
        },
      ]);

    fp.init(this.#ownerGlobal, title, Ci.nsIFilePicker.modeSave);
    fp.appendFilter(csvFilterTitle, "*.csv");
    fp.appendFilters(Ci.nsIFilePicker.filterAll);
    fp.defaultString = defaultFilename;
    fp.defaultExtension = "csv";
    fp.okButtonLabel = okButtonLabel;
    fp.open(fpCallback);
  }

  async #importFromFile() {
    let [title, okButtonLabel, csvFilterTitle, tsvFilterTitle] =
      await lazy.AboutLoginsL10n.formatValues([
        {
          id: "about-logins-import-file-picker-title",
        },
        {
          id: "about-logins-import-file-picker-import-button",
        },
        {
          id: "about-logins-import-file-picker-csv-filter-title",
        },
        {
          id: "about-logins-import-file-picker-tsv-filter-title",
        },
      ]);
    let { result, path } = await this.openFilePickerDialog(
      title,
      okButtonLabel,
      [
        {
          title: csvFilterTitle,
          extensionPattern: "*.csv",
        },
        {
          title: tsvFilterTitle,
          extensionPattern: "*.tsv",
        },
      ],
      this.#ownerGlobal
    );

    if (result != Ci.nsIFilePicker.returnCancel) {
      let summary;
      try {
        summary = await lazy.LoginCSVImport.importFromCSV(path);
      } catch (e) {
        console.error(e);
        this.sendAsyncMessage(
          "AboutLogins:ImportPasswordsErrorDialog",
          e.errorType
        );
      }
      if (summary) {
        this.sendAsyncMessage("AboutLogins:ImportPasswordsDialog", summary);
        Services.telemetry.recordEvent(
          "pwmgr",
          "mgmt_menu_item_used",
          "import_csv_complete"
        );
      }
    }
  }

  #removeAllLogins() {
    Services.logins.removeAllUserFacingLogins();
  }

  #handleLoginStorageErrors(login, error) {
    let messageObject = {
      login: augmentVanillaLoginObject(
        lazy.LoginHelper.loginToVanillaObject(login)
      ),
      errorMessage: error.message,
    };

    if (error.message.includes("This login already exists")) {
      // See comment in LoginHelper.createLoginAlreadyExistsError as to
      // why we need to call .toString() on the nsISupportsString.
      messageObject.existingLoginGuid = error.data.toString();
    }

    this.sendAsyncMessage("AboutLogins:ShowLoginItemError", messageObject);
  }

  async openFilePickerDialog(title, okButtonLabel, appendFilters, ownerGlobal) {
    return new Promise(resolve => {
      let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
      fp.init(ownerGlobal, title, Ci.nsIFilePicker.modeOpen);
      for (const appendFilter of appendFilters) {
        fp.appendFilter(appendFilter.title, appendFilter.extensionPattern);
      }
      fp.appendFilters(Ci.nsIFilePicker.filterAll);
      fp.okButtonLabel = okButtonLabel;
      fp.open(async result => {
        resolve({ result, path: fp.file.path });
      });
    });
  }
}

class AboutLoginsInternal {
  subscribers = new WeakSet();
  #observersAdded = false;
  authExpirationTime = Number.NEGATIVE_INFINITY;

  async observe(subject, topic, type) {
    if (!ChromeUtils.nondeterministicGetWeakSetKeys(this.subscribers).length) {
      this.#removeObservers();
      return;
    }

    switch (topic) {
      case "passwordmgr-reload-all": {
        await this.#reloadAllLogins();
        break;
      }
      case "passwordmgr-crypto-login": {
        this.#removeNotifications(PRIMARY_PASSWORD_NOTIFICATION_ID);
        await this.#reloadAllLogins();
        break;
      }
      case "passwordmgr-crypto-loginCanceled": {
        this.#showPrimaryPasswordLoginNotifications();
        break;
      }
      case lazy.UIState.ON_UPDATE: {
        this.#messageSubscribers("AboutLogins:SyncState", this.getSyncState());
        break;
      }
      case "passwordmgr-storage-changed": {
        switch (type) {
          case "addLogin": {
            await this.#addLogin(subject);
            break;
          }
          case "modifyLogin": {
            this.#modifyLogin(subject);
            break;
          }
          case "removeLogin": {
            this.#removeLogin(subject);
            break;
          }
          case "removeAllLogins": {
            this.#removeAllLogins();
            break;
          }
        }
      }
    }
  }

  async #addLogin(subject) {
    const login = convertSubjectToLogin(subject);
    if (!login) {
      return;
    }

    if (lazy.BREACH_ALERTS_ENABLED) {
      this.#messageSubscribers(
        "AboutLogins:UpdateBreaches",
        await lazy.LoginBreaches.getPotentialBreachesByLoginGUID([login])
      );
      if (lazy.VULNERABLE_PASSWORDS_ENABLED) {
        this.#messageSubscribers(
          "AboutLogins:UpdateVulnerableLogins",
          await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID(
            [login]
          )
        );
      }
    }

    this.#messageSubscribers("AboutLogins:LoginAdded", login);
  }

  async #modifyLogin(subject) {
    subject.QueryInterface(Ci.nsIArrayExtensions);
    const login = convertSubjectToLogin(subject.GetElementAt(1));
    if (!login) {
      return;
    }

    if (lazy.BREACH_ALERTS_ENABLED) {
      let breachesForThisLogin =
        await lazy.LoginBreaches.getPotentialBreachesByLoginGUID([login]);
      let breachData = breachesForThisLogin.size
        ? breachesForThisLogin.get(login.guid)
        : false;
      this.#messageSubscribers(
        "AboutLogins:UpdateBreaches",
        new Map([[login.guid, breachData]])
      );
      if (lazy.VULNERABLE_PASSWORDS_ENABLED) {
        let vulnerablePasswordsForThisLogin =
          await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID(
            [login]
          );
        let isLoginVulnerable = !!vulnerablePasswordsForThisLogin.size;
        this.#messageSubscribers(
          "AboutLogins:UpdateVulnerableLogins",
          new Map([[login.guid, isLoginVulnerable]])
        );
      }
    }

    this.#messageSubscribers("AboutLogins:LoginModified", login);
  }

  #removeLogin(subject) {
    const login = convertSubjectToLogin(subject);
    if (!login) {
      return;
    }
    this.#messageSubscribers("AboutLogins:LoginRemoved", login);
  }

  #removeAllLogins() {
    this.#messageSubscribers("AboutLogins:RemoveAllLogins", []);
  }

  async #reloadAllLogins() {
    let logins = await this.getAllLogins();
    this.#messageSubscribers("AboutLogins:AllLogins", logins);
    await this.sendAllLoginRelatedObjects(logins);
  }

  #showPrimaryPasswordLoginNotifications() {
    this.#showNotifications({
      id: PRIMARY_PASSWORD_NOTIFICATION_ID,
      priority: "PRIORITY_WARNING_MEDIUM",
      iconURL: "chrome://browser/skin/login.svg",
      messageId: "about-logins-primary-password-notification-message",
      buttonIds: ["master-password-reload-button"],
      onClicks: [
        function onReloadClick(browser) {
          browser.reload();
        },
      ],
    });
    this.#messageSubscribers("AboutLogins:PrimaryPasswordAuthRequired");
  }

  #showNotifications({
    id,
    priority,
    iconURL,
    messageId,
    buttonIds,
    onClicks,
    extraFtl = [],
  } = {}) {
    for (let subscriber of this.#subscriberIterator()) {
      let browser = subscriber.embedderElement;
      let MozXULElement = browser.ownerGlobal.MozXULElement;
      MozXULElement.insertFTLIfNeeded("browser/aboutLogins.ftl");
      for (let ftl of extraFtl) {
        MozXULElement.insertFTLIfNeeded(ftl);
      }

      // If there's already an existing notification bar, don't do anything.
      let { gBrowser } = browser.ownerGlobal;
      let notificationBox = gBrowser.getNotificationBox(browser);
      let notification = notificationBox.getNotificationWithValue(id);
      if (notification) {
        continue;
      }

      let buttons = [];
      for (let i = 0; i < buttonIds.length; i++) {
        buttons[i] = {
          "l10n-id": buttonIds[i],
          popup: null,
          callback: () => {
            onClicks[i](browser);
          },
        };
      }

      notification = notificationBox.appendNotification(
        id,
        {
          label: { "l10n-id": messageId },
          image: iconURL,
          priority: notificationBox[priority],
        },
        buttons
      );
    }
  }

  #removeNotifications(notificationId) {
    for (let subscriber of this.#subscriberIterator()) {
      let browser = subscriber.embedderElement;
      let { gBrowser } = browser.ownerGlobal;
      let notificationBox = gBrowser.getNotificationBox(browser);
      let notification =
        notificationBox.getNotificationWithValue(notificationId);
      if (!notification) {
        continue;
      }
      notificationBox.removeNotification(notification);
    }
  }

  *#subscriberIterator() {
    let subscribers = ChromeUtils.nondeterministicGetWeakSetKeys(
      this.subscribers
    );
    for (let subscriber of subscribers) {
      let browser = subscriber.embedderElement;
      if (
        browser?.remoteType != EXPECTED_ABOUTLOGINS_REMOTE_TYPE ||
        browser?.contentPrincipal?.originNoSuffix != ABOUT_LOGINS_ORIGIN
      ) {
        this.subscribers.delete(subscriber);
        continue;
      }
      yield subscriber;
    }
  }

  #messageSubscribers(name, details) {
    for (let subscriber of this.#subscriberIterator()) {
      try {
        if (subscriber.currentWindowGlobal) {
          let actor = subscriber.currentWindowGlobal.getActor("AboutLogins");
          actor.sendAsyncMessage(name, details);
        }
      } catch (ex) {
        if (ex.result == Cr.NS_ERROR_NOT_INITIALIZED) {
          // The actor may be destroyed before the message is sent.
          lazy.log.debug(
            "messageSubscribers: exception when calling sendAsyncMessage",
            ex
          );
        } else {
          throw ex;
        }
      }
    }
  }

  async getAllLogins() {
    try {
      let logins = await lazy.LoginHelper.getAllUserFacingLogins();
      return logins
        .map(lazy.LoginHelper.loginToVanillaObject)
        .map(augmentVanillaLoginObject);
    } catch (e) {
      if (e.result == Cr.NS_ERROR_ABORT) {
        // If the user cancels the MP prompt then return no logins.
        return [];
      }
      throw e;
    }
  }

  async sendAllLoginRelatedObjects(logins, browsingContext) {
    let sendMessageFn = (name, details) => {
      if (browsingContext?.currentWindowGlobal) {
        let actor = browsingContext.currentWindowGlobal.getActor("AboutLogins");
        actor.sendAsyncMessage(name, details);
      } else {
        this.#messageSubscribers(name, details);
      }
    };

    if (lazy.BREACH_ALERTS_ENABLED) {
      sendMessageFn(
        "AboutLogins:SetBreaches",
        await lazy.LoginBreaches.getPotentialBreachesByLoginGUID(logins)
      );
      if (lazy.VULNERABLE_PASSWORDS_ENABLED) {
        sendMessageFn(
          "AboutLogins:SetVulnerableLogins",
          await lazy.LoginBreaches.getPotentiallyVulnerablePasswordsByLoginGUID(
            logins
          )
        );
      }
    }
  }

  getSyncState() {
    const state = lazy.UIState.get();
    // As long as Sync is configured, about:logins will treat it as
    // authenticated. More diagnostics and error states can be handled
    // by other more Sync-specific pages.
    const loggedIn = state.status != lazy.UIState.STATUS_NOT_CONFIGURED;
    const passwordSyncEnabled = state.syncEnabled && lazy.PASSWORD_SYNC_ENABLED;

    return {
      loggedIn,
      email: state.email,
      avatarURL: state.avatarURL,
      fxAccountsEnabled: lazy.FXA_ENABLED,
      passwordSyncEnabled,
    };
  }

  onPasswordSyncEnabledPreferenceChange(data, previous, latest) {
    this.#messageSubscribers("AboutLogins:SyncState", this.getSyncState());
  }

  #observedTopics = [
    "passwordmgr-crypto-login",
    "passwordmgr-crypto-loginCanceled",
    "passwordmgr-storage-changed",
    "passwordmgr-reload-all",
    lazy.UIState.ON_UPDATE,
  ];

  addObservers() {
    if (!this.#observersAdded) {
      for (const topic of this.#observedTopics) {
        Services.obs.addObserver(this, topic);
      }
      this.#observersAdded = true;
    }
  }

  #removeObservers() {
    for (const topic of this.#observedTopics) {
      Services.obs.removeObserver(this, topic);
    }
    this.#observersAdded = false;
  }
}

let AboutLogins = new AboutLoginsInternal();
export var _AboutLogins = AboutLogins;

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "PASSWORD_SYNC_ENABLED",
  "services.sync.engine.passwords",
  false,
  AboutLogins.onPasswordSyncEnabledPreferenceChange
);
