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

/**
 * LoginManagerStorage implementation for the JSON back-end.
 */

const lazy = {};

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

const SYNCABLE_LOGIN_FIELDS = [
  // `nsILoginInfo` fields.
  "hostname",
  "formSubmitURL",
  "httpRealm",
  "username",
  "password",
  "usernameField",
  "passwordField",

  // `nsILoginMetaInfo` fields.
  "timeCreated",
  "timePasswordChanged",
];

// Compares two logins to determine if their syncable fields changed. The login
// manager fires `modifyLogin` for changes to all fields, including ones we
// don't sync. In particular, `timeLastUsed` changes shouldn't mark the login
// for upload; otherwise, we might overwrite changed passwords before they're
// downloaded (bug 973166).
function isSyncableChange(oldLogin, newLogin) {
  oldLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo);
  newLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo);
  return SYNCABLE_LOGIN_FIELDS.some(prop => oldLogin[prop] != newLogin[prop]);
}

// Returns true if the argument is for the FxA login.
function isFXAHost(login) {
  return login.hostname == lazy.FXA_PWDMGR_HOST;
}

export class LoginManagerStorage_json {
  constructor() {
    this.__crypto = null; // nsILoginManagerCrypto service
    this.__decryptedPotentiallyVulnerablePasswords = null;
  }

  get _crypto() {
    if (!this.__crypto) {
      this.__crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService(
        Ci.nsILoginManagerCrypto
      );
    }
    return this.__crypto;
  }

  get _decryptedPotentiallyVulnerablePasswords() {
    if (!this.__decryptedPotentiallyVulnerablePasswords) {
      this._store.ensureDataReady();
      this.__decryptedPotentiallyVulnerablePasswords = [];
      for (const potentiallyVulnerablePassword of this._store.data
        .potentiallyVulnerablePasswords) {
        const decryptedPotentiallyVulnerablePassword = this._crypto.decrypt(
          potentiallyVulnerablePassword.encryptedPassword
        );
        this.__decryptedPotentiallyVulnerablePasswords.push(
          decryptedPotentiallyVulnerablePassword
        );
      }
    }
    return this.__decryptedPotentiallyVulnerablePasswords;
  }

  initialize() {
    try {
      // Force initialization of the crypto module.
      // See bug 717490 comment 17.
      this._crypto;

      let profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;

      // Set the reference to LoginStore synchronously.
      let jsonPath = PathUtils.join(profileDir, "logins.json");
      let backupPath = "";
      let loginsBackupEnabled = Services.prefs.getBoolPref(
        "signon.backup.enabled"
      );
      if (loginsBackupEnabled) {
        backupPath = PathUtils.join(profileDir, "logins-backup.json");
      }
      // Note that LoginStore is based on JSONFile which brings its own
      // shutdown blocker to finalize properly, so we do not need one here.
      this._store = new lazy.LoginStore(jsonPath, backupPath);

      // The ProfileDataUpgrader can possibly set this pref. As we don't know
      // whether that has already happened, or will still happen, we need to add
      // a pref observer.
      Services.prefs.addObserver(
        "signon.reencryptionNeeded",
        this.#observeReencryptionNeeded.bind(this)
      );
      this.#observeReencryptionNeeded();

      return (async () => {
        // Load the data asynchronously.
        this.log(`Opening database at ${this._store.path}.`);
        await this._store.load();
      })().catch(console.error);
    } catch (e) {
      this.log(`Initialization failed ${e.name}.`);
      throw new Error("Initialization failed");
    }
  }

  /**
   * Internal method used by tests only. It is called before replacing
   * this storage module with a new instance. It avoids to finalize the
   * underlying DeferredTask as it is still needed for the next tests.
   */
  testSaveForReplace() {
    this._store._saver.disarm();
    return this._store._save();
  }

  /**
   * Returns the "sync id" used by Sync to know whether the store is current with
   * respect to the sync servers. It is stored encrypted, but only so we
   * can detect failure to decrypt (for example, a "reset" of the primary
   * password will leave all logins alone, but they will fail to decrypt. We
   * also want this metadata to be unavailable in that scenario)
   *
   * Returns null if the data doesn't exist or if the data can't be
   * decrypted (including if the primary-password prompt is cancelled). This is
   * OK for Sync as it can't even begin syncing if the primary-password is
   * locked as the sync encrytion keys are stored in this login manager.
   */
  async getSyncID() {
    await this._store.load();
    if (!this._store.data.sync) {
      return null;
    }
    let raw = this._store.data.sync.syncID;
    try {
      return raw ? this._crypto.decrypt(raw) : null;
    } catch (e) {
      if (e.result == Cr.NS_ERROR_FAILURE) {
        this.log("Could not decrypt the syncID - returning null.");
        return null;
      }
      // any other errors get re-thrown.
      throw e;
    }
  }

  async setSyncID(syncID) {
    await this._store.load();
    if (!this._store.data.sync) {
      this._store.data.sync = {};
    }
    this._store.data.sync.syncID = syncID ? this._crypto.encrypt(syncID) : null;
    this._store.saveSoon();
  }

  async getLastSync() {
    await this._store.load();
    if (!this._store.data.sync) {
      return 0;
    }
    return this._store.data.sync.lastSync || 0.0;
  }

  async setLastSync(timestamp) {
    await this._store.load();
    if (!this._store.data.sync) {
      this._store.data.sync = {};
    }
    this._store.data.sync.lastSync = timestamp;
    this._store.saveSoon();
  }

  #incrementSyncCounter(login) {
    login.syncCounter++;
  }

  async resetSyncCounter(guid, value) {
    this._store.ensureDataReady();

    // This will also find deleted items.
    let login = this._store.data.logins.find(login => login.guid == guid);
    if (login?.syncCounter > 0) {
      login.syncCounter = Math.max(0, login.syncCounter - value);
      login.everSynced = true;
    }

    this._store.saveSoon();
  }

  // Returns false if the login has marked as deleted or doesn't exist.
  #loginIsDeleted(guid) {
    let login = this._store.data.logins.find(l => l.guid == guid);
    return !!login?.deleted;
  }

  async loginIsDeletedAsync(guid) {
    let result = this.#loginIsDeleted(guid);
    // Emulate being async:
    return Promise.resolve(result);
  }

  // Synchronuously stores encrypted login, returns login clone with upserted
  // uuid and updated timestamps
  #addLogin(login) {
    this._store.ensureDataReady();

    // Throws if there are bogus values.
    lazy.LoginHelper.checkLoginValues(login);

    // Clone the login, so we don't modify the caller's object.
    let loginClone = login.clone();

    // Initialize the nsILoginMetaInfo fields, unless the caller gave us values
    loginClone.QueryInterface(Ci.nsILoginMetaInfo);
    if (loginClone.guid) {
      let guid = loginClone.guid;
      if (!this._isGuidUnique(guid)) {
        // We have an existing GUID, but it's possible that entry is unable
        // to be decrypted - if that's the case we remove the existing one
        // and allow this one to be added.
        let existing = this._searchLogins({ guid })[0];
        if (this._decryptLogins(existing).length) {
          // Existing item is good, so it's an error to try and re-add it.
          throw new Error("specified GUID already exists");
        }
        // find and remove the existing bad entry.
        let foundIndex = this._store.data.logins.findIndex(l => l.guid == guid);
        if (foundIndex == -1) {
          throw new Error("can't find a matching GUID to remove");
        }
        this._store.data.logins.splice(foundIndex, 1);
      }
    } else {
      loginClone.guid = Services.uuid.generateUUID().toString();
    }

    // Set timestamps
    let currentTime = Date.now();
    if (!loginClone.timeCreated) {
      loginClone.timeCreated = currentTime;
    }
    if (!loginClone.timeLastUsed) {
      loginClone.timeLastUsed = currentTime;
    }
    if (!loginClone.timePasswordChanged) {
      loginClone.timePasswordChanged = currentTime;
    }
    if (!loginClone.timesUsed) {
      loginClone.timesUsed = 1;
    }

    // If the everSynced is already set, then this login is an incoming
    // sync record, so there is no need to mark this as needed to be synced.
    if (!loginClone.everSynced && !isFXAHost(loginClone)) {
      this.#incrementSyncCounter(loginClone);
    }

    this._store.data.logins.push({
      id: this._store.data.nextId++,
      hostname: loginClone.origin,
      httpRealm: loginClone.httpRealm,
      formSubmitURL: loginClone.formActionOrigin,
      usernameField: loginClone.usernameField,
      passwordField: loginClone.passwordField,
      encryptedUsername: loginClone.username,
      encryptedPassword: loginClone.password,
      guid: loginClone.guid,
      encType: this._crypto.defaultEncType,
      timeCreated: loginClone.timeCreated,
      timeLastUsed: loginClone.timeLastUsed,
      timePasswordChanged: loginClone.timePasswordChanged,
      timesUsed: loginClone.timesUsed,
      syncCounter: loginClone.syncCounter,
      everSynced: loginClone.everSynced,
      encryptedUnknownFields: loginClone.unknownFields,
    });
    this._store.saveSoon();

    Glean.pwmgr.numSavedPasswords.set(this.countLogins("", "", ""));
    return loginClone;
  }

  async addLoginsAsync(logins, continueOnDuplicates = false) {
    if (logins.length === 0) {
      return logins;
    }

    const encryptedLogins = await this.#encryptLogins(logins);

    const resultLogins = [];
    for (const [login, encryptedLogin] of encryptedLogins) {
      // check for duplicates
      let loginData = {
        origin: login.origin,
        formActionOrigin: login.formActionOrigin,
        httpRealm: login.httpRealm,
      };
      const existingLogins = await Services.logins.searchLoginsAsync(loginData);

      const matchingLogin = existingLogins.find(l => login.matches(l, true));
      if (matchingLogin) {
        if (continueOnDuplicates) {
          continue;
        } else {
          throw lazy.LoginHelper.createLoginAlreadyExistsError(
            matchingLogin.guid
          );
        }
      }

      const resultLogin = this.#addLogin(encryptedLogin);

      // restore unencrypted username and password for use in `addLogin` event
      // and return value
      resultLogin.username = login.username;
      resultLogin.password = login.password;

      // Send a notification that a login was added.
      lazy.LoginHelper.notifyStorageChanged("addLogin", resultLogin);

      resultLogins.push(resultLogin);
    }

    return resultLogins;
  }

  removeLogin(login, fromSync) {
    this._store.ensureDataReady();

    let [idToDelete, storedLogin] = this._getIdForLogin(login);
    if (!idToDelete) {
      throw new Error("No matching logins");
    }

    let foundIndex = this._store.data.logins.findIndex(l => l.id == idToDelete);
    if (foundIndex != -1) {
      let login = this._store.data.logins[foundIndex];
      if (!login.deleted) {
        if (fromSync) {
          this.#replaceLoginWithTombstone(login);
        } else if (login.everSynced) {
          // The login has been synced, so mark it as deleted.
          this.#incrementSyncCounter(login);
          this.#replaceLoginWithTombstone(login);
        } else {
          // The login was never synced, so just remove it from the data.
          this._store.data.logins.splice(foundIndex, 1);
        }

        this._store.saveSoon();
      }
    }

    Glean.pwmgr.numSavedPasswords.set(this.countLogins("", "", ""));
    lazy.LoginHelper.notifyStorageChanged("removeLogin", storedLogin);
  }

  async removeLoginAsync(login, fromSync) {
    let result = this.removeLogin(login, fromSync);
    // Emulate being async:
    return Promise.resolve(result);
  }

  modifyLogin(oldLogin, newLoginData, fromSync) {
    this._store.ensureDataReady();

    let [idToModify, oldStoredLogin] = this._getIdForLogin(oldLogin);
    if (!idToModify) {
      throw new Error("No matching logins");
    }

    let newLogin = lazy.LoginHelper.buildModifiedLogin(
      oldStoredLogin,
      newLoginData
    );

    // Check if the new GUID is duplicate.
    if (
      newLogin.guid != oldStoredLogin.guid &&
      !this._isGuidUnique(newLogin.guid)
    ) {
      throw new Error("specified GUID already exists");
    }

    // Look for an existing entry in case key properties changed.
    if (!newLogin.matches(oldLogin, true)) {
      let loginData = {
        origin: newLogin.origin,
        formActionOrigin: newLogin.formActionOrigin,
        httpRealm: newLogin.httpRealm,
      };

      let logins = this.searchLogins(
        lazy.LoginHelper.newPropertyBag(loginData)
      );

      let matchingLogin = logins.find(login => newLogin.matches(login, true));
      if (matchingLogin) {
        throw lazy.LoginHelper.createLoginAlreadyExistsError(
          matchingLogin.guid
        );
      }
    }

    // Don't sync changes to the accounts password or when changes were only
    // made to fields that should not be synced.
    if (
      !fromSync &&
      !isFXAHost(newLogin) &&
      isSyncableChange(oldLogin, newLogin)
    ) {
      this.#incrementSyncCounter(newLogin);
    }

    // Get the encrypted value of the username and password.
    let [encUsername, encPassword, encType, encUnknownFields] =
      this._encryptLogin(newLogin);

    for (let loginItem of this._store.data.logins) {
      if (loginItem.id == idToModify && !loginItem.deleted) {
        loginItem.hostname = newLogin.origin;
        loginItem.httpRealm = newLogin.httpRealm;
        loginItem.formSubmitURL = newLogin.formActionOrigin;
        loginItem.usernameField = newLogin.usernameField;
        loginItem.passwordField = newLogin.passwordField;
        loginItem.encryptedUsername = encUsername;
        loginItem.encryptedPassword = encPassword;
        loginItem.guid = newLogin.guid;
        loginItem.encType = encType;
        loginItem.timeCreated = newLogin.timeCreated;
        loginItem.timeLastUsed = newLogin.timeLastUsed;
        loginItem.timePasswordChanged = newLogin.timePasswordChanged;
        loginItem.timesUsed = newLogin.timesUsed;
        loginItem.encryptedUnknownFields = encUnknownFields;
        loginItem.syncCounter = newLogin.syncCounter;
        this._store.saveSoon();
        break;
      }
    }

    lazy.LoginHelper.notifyStorageChanged("modifyLogin", [
      oldStoredLogin,
      newLogin,
    ]);
  }

  async modifyLoginAsync(oldLogin, newLoginData, fromSync) {
    let result = this.modifyLogin(oldLogin, newLoginData, fromSync);
    // Emulate being async:
    return Promise.resolve(result);
  }

  // Replace the login with a tombstone. It has a guid and sync-related properties,
  // but does not contain the login or password information.
  #replaceLoginWithTombstone(login) {
    login.deleted = true;

    // Delete all fields except guid, timePasswordChanged, syncCounter
    // and everSynced;
    delete login.hostname;
    delete login.httpRealm;
    delete login.formSubmitURL;
    delete login.usernameField;
    delete login.passwordField;
    delete login.encryptedUsername;
    delete login.encryptedPassword;
    delete login.encType;
    delete login.timeCreated;
    delete login.timeLastUsed;
    delete login.timesUsed;
    delete login.encryptedUnknownFields;
  }

  recordPasswordUse(login) {
    // Update the lastUsed timestamp and increment the use count.
    let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
      Ci.nsIWritablePropertyBag
    );
    propBag.setProperty("timeLastUsed", Date.now());
    propBag.setProperty("timesUsedIncrement", 1);
    this.modifyLogin(login, propBag);
  }

  async recordPasswordUseAsync(login) {
    let result = this.recordPasswordUse(login);
    // Emulate being async:
    return Promise.resolve(result);
  }

  async recordBreachAlertDismissal(loginGUID) {
    this._store.ensureDataReady();
    const dismissedBreachAlertsByLoginGUID =
      this._store._data.dismissedBreachAlertsByLoginGUID;

    dismissedBreachAlertsByLoginGUID[loginGUID] = {
      timeBreachAlertDismissed: new Date().getTime(),
    };

    return this._store.saveSoon();
  }

  getBreachAlertDismissalsByLoginGUID() {
    this._store.ensureDataReady();
    return this._store._data.dismissedBreachAlertsByLoginGUID;
  }

  /**
   * Returns an array of nsILoginInfo. If decryption of a login
   * fails due to a corrupt entry, the login is not included in
   * the resulting array.
   *
   * @returns {Promise<nsILoginInfo[]>}
   */
  async getAllLogins(includeDeleted) {
    this._store.ensureDataReady();

    let [logins] = this._searchLogins({}, includeDeleted);
    if (!logins.length) {
      return [];
    }

    return this.#decryptLogins(logins);
  }

  async searchLoginsAsync(matchData, includeDeleted) {
    this.log(`Searching for matching logins for origin ${matchData.origin}.`);
    let result = this.searchLogins(
      lazy.LoginHelper.newPropertyBag(matchData),
      includeDeleted
    );
    // Emulate being async:
    return Promise.resolve(result);
  }

  /**
   * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
   * JavaScript object and decrypt the results.
   *
   * @return {nsILoginInfo[]} which are decrypted.
   */
  searchLogins(matchData, includeDeleted) {
    this._store.ensureDataReady();

    let realMatchData = {};
    let options = {};

    matchData.QueryInterface(Ci.nsIPropertyBag2);
    if (matchData.hasKey("guid")) {
      // Enforce GUID-based filtering when available, since the origin of the
      // login may not match the origin of the form in the case of scheme
      // upgrades.
      realMatchData = { guid: matchData.getProperty("guid") };
    } else {
      // Convert nsIPropertyBag to normal JS object.
      for (let prop of matchData.enumerator) {
        switch (prop.name) {
          // Some property names aren't field names but are special options to
          // affect the search.
          case "acceptDifferentSubdomains":
          case "schemeUpgrades":
          case "acceptRelatedRealms":
          case "relatedRealms": {
            options[prop.name] = prop.value;
            break;
          }
          default: {
            realMatchData[prop.name] = prop.value;
            break;
          }
        }
      }
    }

    let [logins] = this._searchLogins(realMatchData, includeDeleted, options);

    // Decrypt entries found for the caller.
    logins = this._decryptLogins(logins);

    return logins;
  }

  /**
   * Private method to perform arbitrary searches on any field. Decryption is
   * left to the caller.
   *
   * formActionOrigin is handled specially for compatibility. If a null string
   * is passed and other match fields are present, it is treated as if it was
   * not present.
   *
   * Returns [logins, ids] for logins that match the arguments, where logins
   * is an array of encrypted nsLoginInfo and ids is an array of associated
   * ids in the database.
   */
  _searchLogins(
    matchData,
    includeDeleted = false,
    aOptions = {
      schemeUpgrades: false,
      acceptDifferentSubdomains: false,
      acceptRelatedRealms: false,
      relatedRealms: [],
    },
    candidateLogins = this._store.data.logins
  ) {
    let foundLogins = [],
      foundIds = [];
    for (let loginItem of candidateLogins) {
      if (loginItem.deleted && !includeDeleted) {
        continue; // skip deleted items
      }

      if (this.#matchLogin(loginItem, matchData, aOptions)) {
        // Create the new nsLoginInfo object, push to array
        let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
          Ci.nsILoginInfo
        );
        login.init(
          loginItem.hostname,
          loginItem.formSubmitURL,
          loginItem.httpRealm,
          loginItem.encryptedUsername,
          loginItem.encryptedPassword,
          loginItem.usernameField,
          loginItem.passwordField
        );
        // set nsILoginMetaInfo values
        login.QueryInterface(Ci.nsILoginMetaInfo);
        login.guid = loginItem.guid;
        login.timeCreated = loginItem.timeCreated;
        login.timeLastUsed = loginItem.timeLastUsed;
        login.timePasswordChanged = loginItem.timePasswordChanged;
        login.timesUsed = loginItem.timesUsed;
        login.syncCounter = loginItem.syncCounter;
        login.everSynced = loginItem.everSynced;

        // Any unknown fields along for the ride
        login.unknownFields = loginItem.encryptedUnknownFields;
        foundLogins.push(login);
        foundIds.push(loginItem.id);
      }
    }

    this.log(
      `Returning ${foundLogins.length} logins for specified origin with options ${aOptions}`
    );
    return [foundLogins, foundIds];
  }

  /**
   * Removes all logins from local storage, including FxA Sync key.
   *
   * NOTE: You probably want removeAllUserFacingLogins instead of this function.
   *
   */
  removeAllLogins() {
    this.#removeLogins(false, true);
  }

  async removeAllLoginsAsync() {
    this.removeAllLogins();
  }

  /**
   * Removes all user facing logins from storage. e.g. all logins except the FxA Sync key
   *
   * If you need to remove the FxA key, use `removeAllLogins` instead
   *
   * @param fullyRemove remove the logins rather than mark them deleted.
   */
  removeAllUserFacingLogins(fullyRemove) {
    this.#removeLogins(fullyRemove, false);
  }

  async removeAllUserFacingLoginsAsync(fullyRemove) {
    this.removeAllUserFacingLogins(fullyRemove);
  }

  /**
   * Removes all logins from storage. If removeFXALogin is true, then the FxA Sync
   * key is also removed.
   *
   * @param fullyRemove remove the logins rather than mark them deleted.
   * @param removeFXALogin also remove the FxA Sync key.
   */
  #removeLogins(fullyRemove, removeFXALogin = false) {
    this._store.ensureDataReady();
    this.log("Removing all logins.");

    let removedLogins = [];
    let remainingLogins = [];
    for (let login of this._store.data.logins) {
      if (
        !removeFXALogin &&
        isFXAHost(login) &&
        login.httpRealm == lazy.FXA_PWDMGR_REALM
      ) {
        remainingLogins.push(login);
      } else {
        // Create the nsLoginInfo object which to emit
        const loginInfo = Cc[
          "@mozilla.org/login-manager/loginInfo;1"
        ].createInstance(Ci.nsILoginInfo);
        loginInfo.init(
          login.hostname,
          login.formSubmitURL,
          login.httpRealm,
          login.encryptedUsername,
          login.encryptedPassword,
          login.usernameField,
          login.passwordField
        );
        // set nsILoginMetaInfo values
        loginInfo.QueryInterface(Ci.nsILoginMetaInfo);
        loginInfo.guid = login.guid;
        loginInfo.timeCreated = login.timeCreated;
        loginInfo.timeLastUsed = login.timeLastUsed;
        loginInfo.timePasswordChanged = login.timePasswordChanged;
        loginInfo.timesUsed = login.timesUsed;
        loginInfo.syncCounter = login.syncCounter;
        loginInfo.everSynced = login.everSynced;

        // Any unknown fields along for the ride
        loginInfo.unknownFields = login.encryptedUnknownFields;

        removedLogins.push(loginInfo);
        if (!fullyRemove && login?.everSynced) {
          // The login has been synced, so mark it as deleted.
          this.#incrementSyncCounter(login);
          this.#replaceLoginWithTombstone(login);
          remainingLogins.push(login);
        }
      }
    }
    this._store.data.logins = remainingLogins;

    this._store.data.potentiallyVulnerablePasswords = [];
    this.__decryptedPotentiallyVulnerablePasswords = null;
    this._store.data.dismissedBreachAlertsByLoginGUID = {};
    this._store.saveSoon();

    lazy.LoginHelper.notifyStorageChanged("removeAllLogins", removedLogins);
  }

  findLogins(origin, formActionOrigin, httpRealm) {
    this._store.ensureDataReady();

    let loginData = {
      origin,
      formActionOrigin,
      httpRealm,
    };
    let matchData = {};
    for (let field of ["origin", "formActionOrigin", "httpRealm"]) {
      if (loginData[field] != "") {
        matchData[field] = loginData[field];
      }
    }
    let [logins] = this._searchLogins(matchData);

    // Decrypt entries found for the caller.
    logins = this._decryptLogins(logins);

    this.log(`Returning ${logins.length} logins.`);
    return logins;
  }

  /**
   * Checks if the given login item matches the specified matchData.
   *
   * @param {object} aLoginItem The login item to check.
   * @param {object} aMatchData The match data to compare against. keyed by
   * @param {object} [aOptions] Additional options for matching
   *
   * @returns {boolean} - Returns true if the login item matches the match data,
   */
  #matchLogin(
    aLoginItem,
    aMatchData,
    aOptions = {
      schemeUpgrades: false,
      acceptDifferentSubdomains: false,
      acceptRelatedRealms: false,
      relatedRealms: [],
    }
  ) {
    for (let field in aMatchData) {
      let wantedValue = aMatchData[field];

      // Override the storage field name for some fields due to backwards
      // compatibility with Sync/storage.
      let storageFieldName = field;
      switch (field) {
        case "formActionOrigin": {
          storageFieldName = "formSubmitURL";
          break;
        }
        case "origin": {
          storageFieldName = "hostname";
          break;
        }
      }

      switch (field) {
        case "formActionOrigin":
          if (wantedValue != null) {
            // Historical compatibility requires this special case
            if (
              aLoginItem.formSubmitURL == "" ||
              (wantedValue == "" && Object.keys(aMatchData).length != 1)
            ) {
              break;
            }
            if (
              !lazy.LoginHelper.isOriginMatching(
                aLoginItem[storageFieldName],
                wantedValue,
                aOptions
              )
            ) {
              return false;
            }
            break;
          }
        // fall through
        case "origin":
          if (wantedValue != null) {
            // needed for formActionOrigin fall through
            if (
              !lazy.LoginHelper.isOriginMatching(
                aLoginItem[storageFieldName],
                wantedValue,
                aOptions
              )
            ) {
              return false;
            }
            break;
          }
        // Normal cases.
        // fall through
        case "httpRealm":
        case "id":
        case "usernameField":
        case "passwordField":
        case "encryptedUsername":
        case "encryptedPassword":
        case "guid":
        case "encType":
        case "timeCreated":
        case "timeLastUsed":
        case "timePasswordChanged":
        case "timesUsed":
        case "syncCounter":
        case "everSynced":
          if (wantedValue == null && aLoginItem[storageFieldName]) {
            return false;
          } else if (aLoginItem[storageFieldName] != wantedValue) {
            return false;
          }
          break;
        // Fail if caller requests an unknown property.
        default:
          throw new Error("Unexpected field: " + field);
      }
    }
    return true;
  }

  countLogins(origin, formActionOrigin, httpRealm) {
    this._store.ensureDataReady();

    let loginData = {
      origin,
      formActionOrigin,
      httpRealm,
    };
    let matchData = {};
    for (let field of ["origin", "formActionOrigin", "httpRealm"]) {
      if (loginData[field] != "") {
        matchData[field] = loginData[field];
      }
    }

    const foundLogins = this._store.data.logins.filter(
      loginItem => !loginItem.deleted && this.#matchLogin(loginItem, matchData)
    );

    this.log(`Counted ${foundLogins.length} logins.`);
    return foundLogins.length;
  }

  addPotentiallyVulnerablePassword(login) {
    this._store.ensureDataReady();
    // this breached password is already stored
    if (this.isPotentiallyVulnerablePassword(login)) {
      return;
    }
    this.__decryptedPotentiallyVulnerablePasswords.push(login.password);

    this._store.data.potentiallyVulnerablePasswords.push({
      encryptedPassword: this._crypto.encrypt(login.password),
    });
    this._store.saveSoon();
  }

  isPotentiallyVulnerablePassword(login) {
    return this._decryptedPotentiallyVulnerablePasswords.includes(
      login.password
    );
  }

  clearAllPotentiallyVulnerablePasswords() {
    this._store.ensureDataReady();
    if (!this._store.data.potentiallyVulnerablePasswords.length) {
      // No need to write to disk
      return;
    }
    this._store.data.potentiallyVulnerablePasswords = [];
    this._store.saveSoon();
    this.__decryptedPotentiallyVulnerablePasswords = null;
  }

  get uiBusy() {
    return this._crypto.uiBusy;
  }

  get isLoggedIn() {
    return this._crypto.isLoggedIn;
  }

  /**
   * Returns an array with two items: [id, login]. If the login was not
   * found, both items will be null. The returned login contains the actual
   * stored login (useful for looking at the actual nsILoginMetaInfo values).
   */
  _getIdForLogin(login) {
    this._store.ensureDataReady();

    let matchData = {};
    for (let field of ["origin", "formActionOrigin", "httpRealm"]) {
      if (login[field] != "") {
        matchData[field] = login[field];
      }
    }
    let [logins, ids] = this._searchLogins(matchData);

    let id = null;
    let foundLogin = null;

    // The specified login isn't encrypted, so we need to ensure
    // the logins we're comparing with are decrypted. We decrypt one entry
    // at a time, lest _decryptLogins return fewer entries and screw up
    // indices between the two.
    for (let i = 0; i < logins.length; i++) {
      let [decryptedLogin] = this._decryptLogins([logins[i]]);

      if (!decryptedLogin || !decryptedLogin.equals(login)) {
        continue;
      }

      // We've found a match, set id and break
      foundLogin = decryptedLogin;
      id = ids[i];
      break;
    }

    return [id, foundLogin];
  }

  /**
   * Checks to see if the specified GUID already exists.
   */
  _isGuidUnique(guid) {
    this._store.ensureDataReady();

    return this._store.data.logins.every(l => l.guid != guid);
  }

  /*
   * Asynchronously encrypt multiple logins.
   * Returns a promise resolving to an array of arrays containing two entries:
   * the original login and a clone with encrypted properties.
   */
  async #encryptLogins(logins) {
    if (logins.length === 0) {
      return logins;
    }

    const plaintexts = logins.reduce(
      (memo, { username, password, unknownFields }) =>
        memo.concat([username, password, unknownFields]),
      []
    );
    const ciphertexts = await this._crypto.encryptMany(plaintexts);

    return logins.map((login, i) => {
      const [encryptedUsername, encryptedPassword, encryptedUnknownFields] =
        ciphertexts.slice(3 * i, 3 * i + 3);

      const encryptedLogin = login.clone();
      encryptedLogin.username = encryptedUsername;
      encryptedLogin.password = encryptedPassword;
      encryptedLogin.unknownFields = encryptedUnknownFields;

      return [login, encryptedLogin];
    });
  }

  /*
   * Asynchronously decrypt multiple logins.
   * Returns a promise resolving to an array of clones with decrypted properties.
   */
  async #decryptLogins(logins) {
    if (logins.length === 0) {
      return logins;
    }

    const ciphertexts = logins.reduce(
      (memo, { username, password, unknownFields }) =>
        memo.concat([username, password, unknownFields]),
      []
    );
    const plaintexts = await this._crypto.decryptMany(ciphertexts);

    return logins
      .map((login, i) => {
        // Deleted logins don't have any info to decrypt.
        const decryptedLogin = login.clone();
        if (this.#loginIsDeleted(login.guid)) {
          return decryptedLogin;
        }

        const [username, password, unknownFields] = plaintexts.slice(
          3 * i,
          3 * i + 3
        );

        // If the username or password is blank it means that decryption may have
        // failed during decryptMany but we can't differentiate an empty string
        // value from a failure so we attempt to decrypt again and check the
        // result.
        if (!username || !password) {
          try {
            this._crypto.decrypt(login.username);
            this._crypto.decrypt(login.password);
          } catch (e) {
            // If decryption failed (corrupt entry?), just return it as it is.
            // Rethrow other errors (like canceling entry of a primary pw)
            if (e.result == Cr.NS_ERROR_FAILURE) {
              this.log(
                `Could not decrypt login: ${
                  login.QueryInterface(Ci.nsILoginMetaInfo).guid
                }.`
              );
              return null;
            }
            throw e;
          }
        }

        decryptedLogin.username = username;
        decryptedLogin.password = password;
        decryptedLogin.unknownFields = unknownFields;

        return decryptedLogin;
      })
      .filter(Boolean);
  }

  /**
   * Returns the encrypted username, password, and encrypton type for the specified
   * login. Can throw if the user cancels a primary password entry.
   */
  _encryptLogin(login) {
    let encUsername = this._crypto.encrypt(login.username);
    let encPassword = this._crypto.encrypt(login.password);

    // Unknown fields should be encrypted since we can't know whether new fields
    // from other clients will contain sensitive data or not
    let encUnknownFields = null;
    if (login.unknownFields) {
      encUnknownFields = this._crypto.encrypt(login.unknownFields);
    }
    let encType = this._crypto.defaultEncType;

    return [encUsername, encPassword, encType, encUnknownFields];
  }

  /**
   * Decrypts username and password fields in the provided array of
   * logins.
   *
   * The entries specified by the array will be decrypted, if possible.
   * An array of successfully decrypted logins will be returned. The return
   * value should be given to external callers (since still-encrypted
   * entries are useless), whereas internal callers generally don't want
   * to lose unencrypted entries (eg, because the user clicked Cancel
   * instead of entering their primary password)
   */
  _decryptLogins(logins) {
    let result = [];

    for (let login of logins) {
      if (this.#loginIsDeleted(login.guid)) {
        result.push(login);
        continue;
      }

      try {
        login.username = this._crypto.decrypt(login.username);
        login.password = this._crypto.decrypt(login.password);
        // Verify unknownFields actually has a value
        if (login.unknownFields) {
          login.unknownFields = this._crypto.decrypt(login.unknownFields);
        }
      } catch (e) {
        // If decryption failed (corrupt entry?), just skip it.
        // Rethrow other errors (like canceling entry of a primary pw)
        if (e.result == Cr.NS_ERROR_FAILURE) {
          continue;
        }
        throw e;
      }
      result.push(login);
    }

    return result;
  }

  reencryptionInProgress = false;

  /**
   * For migration purposes, asynchronously reencrypt all logins in the
   * background.
   */
  async reencryptAllLogins() {
    if (this.reencryptionInProgress) {
      return;
    }
    this.reencryptionInProgress = true;
    this._store.ensureDataReady();

    const encryptedLogins = structuredClone(
      this._store.data.logins.filter(login => !this.#loginIsDeleted(login.guid))
    );
    let encrypted = encryptedLogins.flatMap(
      ({ encryptedUsername, encryptedPassword, encryptedUnknownFields }) => [
        encryptedUsername,
        encryptedPassword,
        encryptedUnknownFields,
      ]
    );

    // Calling decryptMany / encryptMany with an empty array would throw an
    // error, so just don't do it if there are no logins.
    if (encryptedLogins.length) {
      const decrypted = await this._crypto
        .decryptMany(encrypted)
        .catch(error => {
          this.reencryptionInProgress = false;
          throw error;
        });
      encrypted = await this._crypto.encryptMany(decrypted).catch(error => {
        this.reencryptionInProgress = false;
        throw error;
      });
    }

    for (let oldIndex = 0; oldIndex < encryptedLogins.length; oldIndex++) {
      const oldLogin = encryptedLogins[oldIndex];
      const newLogin = this._store.data.logins.find(
        login => login.id === oldLogin.id
      );

      if (
        !newLogin ||
        newLogin.encryptedUsername != oldLogin.encryptedUsername ||
        newLogin.encryptedPassword != oldLogin.encryptedPassword ||
        newLogin.encryptedUnknownFields != oldLogin.encryptedUnknownFields
      ) {
        // This login has been changed or got deleted while we were
        // asynchronously reencrypting the logins. As we shoudn't overwrite it
        // and potentially loose the update, we will just skip it.
        this.log(
          `Login ${
            oldLogin.guid
          } changed during migration and doesn't need to be updated.`
        );
        continue;
      }

      newLogin.encryptedUsername = encrypted[oldIndex * 3];
      newLogin.encryptedPassword = encrypted[oldIndex * 3 + 1];
      newLogin.encryptedUnknownFields = encrypted[oldIndex * 3 + 2];
    }

    // Save the logins changed by us to disk if there are any
    if (encryptedLogins.length) {
      // This could throw if we are in shutdown phase and the store is already
      // finalized. Thus, it is important we call this before clearing
      // signon.reencryptionNeeded below, to make sure we will retry the
      // reencryption on the next restart in case this fails.
      this._store.saveSoon();
    }

    Services.prefs.clearUserPref("signon.reencryptionNeeded");
    this.reencryptionInProgress = false;
    if (this.addedLoginObserver) {
      Services.obs.removeObserver(this, "passwordmgr-crypto-login");
    }
  }

  /**
   * Pref observer for signon.reencryptionNeeded
   */
  #observeReencryptionNeeded() {
    if (Services.prefs.getBoolPref("signon.reencryptionNeeded", false)) {
      // Only reencrypt if user is logged in. Else, wait until the login has
      // happened.
      if (this.isLoggedIn) {
        this.reencryptAllLogins();
      } else if (!this.addedLoginObserver) {
        Services.obs.addObserver(this, "passwordmgr-crypto-login");
        this.addedLoginObserver = true;
      }
    }
  }

  observe(_, topic) {
    // If we need to reencrypt, and weren't able to do so on startup because a
    // primary password is set, we can retry doing so now.
    if (
      topic === "passwordmgr-crypto-login" &&
      Services.prefs.getBoolPref("signon.reencryptionNeeded", false)
    ) {
      this.reencryptAllLogins();
    }
  }
}

ChromeUtils.defineLazyGetter(LoginManagerStorage_json.prototype, "log", () => {
  let logger = lazy.LoginHelper.createLogger("Login storage");
  return logger.log.bind(logger);
});
