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

"use strict";

const PERMISSION_SAVE_LOGINS = "login-saving";

ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/Timer.jsm");
ChromeUtils.import("resource://gre/modules/LoginManagerContent.jsm");

ChromeUtils.defineModuleGetter(this, "BrowserUtils",
                               "resource://gre/modules/BrowserUtils.jsm");
ChromeUtils.defineModuleGetter(this, "LoginHelper",
                               "resource://gre/modules/LoginHelper.jsm");
ChromeUtils.defineModuleGetter(this, "LoginFormFactory",
                               "resource://gre/modules/LoginManagerContent.jsm");
ChromeUtils.defineModuleGetter(this, "InsecurePasswordUtils",
                               "resource://gre/modules/InsecurePasswordUtils.jsm");

XPCOMUtils.defineLazyGetter(this, "log", () => {
  let logger = LoginHelper.createLogger("nsLoginManager");
  return logger;
});

const MS_PER_DAY = 24 * 60 * 60 * 1000;

function LoginManager() {
  this.init();
}

LoginManager.prototype = {

  classID: Components.ID("{cb9e0de8-3598-4ed7-857b-827f011ad5d8}"),
  QueryInterface: ChromeUtils.generateQI([Ci.nsILoginManager,
                                          Ci.nsISupportsWeakReference,
                                          Ci.nsIInterfaceRequestor]),
  getInterface(aIID) {
    if (aIID.equals(Ci.mozIStorageConnection) && this._storage) {
      let ir = this._storage.QueryInterface(Ci.nsIInterfaceRequestor);
      return ir.getInterface(aIID);
    }

    if (aIID.equals(Ci.nsIVariant)) {
      // Allows unwrapping the JavaScript object for regression tests.
      return this;
    }

    throw new Components.Exception("Interface not available", Cr.NS_ERROR_NO_INTERFACE);
  },


  /* ---------- private members ---------- */


  __formFillService: null, // FormFillController, for username autocompleting
  get _formFillService() {
    if (!this.__formFillService) {
      this.__formFillService = Cc["@mozilla.org/satchel/form-fill-controller;1"].
                               getService(Ci.nsIFormFillController);
    }
    return this.__formFillService;
  },


  _storage: null, // Storage component which contains the saved logins
  _prefBranch: null, // Preferences service
  _remember: true,  // mirrors signon.rememberSignons preference


  /**
   * Initialize the Login Manager. Automatically called when service
   * is created.
   *
   * Note: Service created in /browser/base/content/browser.js,
   *       delayedStartup()
   */
  init() {

    // Cache references to current |this| in utility objects
    this._observer._pwmgr            = this;

    // Preferences. Add observer so we get notified of changes.
    this._prefBranch = Services.prefs.getBranch("signon.");
    this._prefBranch.addObserver("rememberSignons", this._observer);

    this._remember = this._prefBranch.getBoolPref("rememberSignons");
    this._autoCompleteLookupPromise = null;

    // Form submit observer checks forms for new logins and pw changes.
    Services.obs.addObserver(this._observer, "xpcom-shutdown");

    if (Services.appinfo.processType ===
        Services.appinfo.PROCESS_TYPE_DEFAULT) {
      Services.obs.addObserver(this._observer, "passwordmgr-storage-replace");

      // Initialize storage so that asynchronous data loading can start.
      this._initStorage();
    }

    Services.obs.addObserver(this._observer, "gather-telemetry");
  },


  _initStorage() {
    let contractID;
    if (AppConstants.platform == "android") {
      contractID = "@mozilla.org/login-manager/storage/mozStorage;1";
    } else {
      contractID = "@mozilla.org/login-manager/storage/json;1";
    }
    try {
      let catMan = Cc["@mozilla.org/categorymanager;1"].
                   getService(Ci.nsICategoryManager);
      contractID = catMan.getCategoryEntry("login-manager-storage",
                                           "nsILoginManagerStorage");
      log.debug("Found alternate nsILoginManagerStorage with contract ID:", contractID);
    } catch (e) {
      log.debug("No alternate nsILoginManagerStorage registered");
    }

    this._storage = Cc[contractID].
                    createInstance(Ci.nsILoginManagerStorage);
    this.initializationPromise = this._storage.initialize();
  },


  /* ---------- Utility objects ---------- */


  /**
   * Internal utility object, implements the nsIObserver interface.
   * Used to receive notification for: form submission, preference changes.
   */
  _observer: {
    _pwmgr: null,

    QueryInterface: ChromeUtils.generateQI([Ci.nsIObserver,
                                            Ci.nsISupportsWeakReference]),

    // nsIObserver
    observe(subject, topic, data) {
      if (topic == "nsPref:changed") {
        var prefName = data;
        log.debug("got change to", prefName, "preference");

        if (prefName == "rememberSignons") {
          this._pwmgr._remember =
              this._pwmgr._prefBranch.getBoolPref("rememberSignons");
        } else {
          log.debug("Oops! Pref not handled, change ignored.");
        }
      } else if (topic == "xpcom-shutdown") {
        delete this._pwmgr.__formFillService;
        delete this._pwmgr._storage;
        delete this._pwmgr._prefBranch;
        this._pwmgr = null;
      } else if (topic == "passwordmgr-storage-replace") {
        (async () => {
          await this._pwmgr._storage.terminate();
          this._pwmgr._initStorage();
          await this._pwmgr.initializationPromise;
          Services.obs.notifyObservers(null,
                       "passwordmgr-storage-replace-complete");
        })();
      } else if (topic == "gather-telemetry") {
        // When testing, the "data" parameter is a string containing the
        // reference time in milliseconds for time-based statistics.
        this._pwmgr._gatherTelemetry(data ? parseInt(data)
                                          : new Date().getTime());
      } else {
        log.debug("Oops! Unexpected notification:", topic);
      }
    }
  },

  /**
   * Collects statistics about the current logins and settings. The telemetry
   * histograms used here are not accumulated, but are reset each time this
   * function is called, since it can be called multiple times in a session.
   *
   * This function might also not be called at all in the current session.
   *
   * @param referenceTimeMs
   *        Current time used to calculate time-based statistics, expressed as
   *        the number of milliseconds since January 1, 1970, 00:00:00 UTC.
   *        This is set to a fake value during unit testing.
   */
  _gatherTelemetry(referenceTimeMs) {
    function clearAndGetHistogram(histogramId) {
      let histogram = Services.telemetry.getHistogramById(histogramId);
      histogram.clear();
      return histogram;
    }

    clearAndGetHistogram("PWMGR_BLOCKLIST_NUM_SITES").add(
      this.getAllDisabledHosts({}).length
    );
    clearAndGetHistogram("PWMGR_NUM_SAVED_PASSWORDS").add(
      this.countLogins("", "", "")
    );
    clearAndGetHistogram("PWMGR_NUM_HTTPAUTH_PASSWORDS").add(
      this.countLogins("", null, "")
    );

    // This is a boolean histogram, and not a flag, because we don't want to
    // record any value if _gatherTelemetry is not called.
    clearAndGetHistogram("PWMGR_SAVING_ENABLED").add(this._remember);

    // Don't try to get logins if MP is enabled, since we don't want to show a MP prompt.
    if (!this.isLoggedIn) {
      return;
    }

    let logins = this.getAllLogins({});

    let usernamePresentHistogram = clearAndGetHistogram("PWMGR_USERNAME_PRESENT");
    let loginLastUsedDaysHistogram = clearAndGetHistogram("PWMGR_LOGIN_LAST_USED_DAYS");

    let hostnameCount = new Map();
    for (let login of logins) {
      usernamePresentHistogram.add(!!login.username);

      let hostname = login.hostname;
      hostnameCount.set(hostname, (hostnameCount.get(hostname) || 0 ) + 1);

      login.QueryInterface(Ci.nsILoginMetaInfo);
      let timeLastUsedAgeMs = referenceTimeMs - login.timeLastUsed;
      if (timeLastUsedAgeMs > 0) {
        loginLastUsedDaysHistogram.add(
          Math.floor(timeLastUsedAgeMs / MS_PER_DAY)
        );
      }
    }

    let passwordsCountHistogram = clearAndGetHistogram("PWMGR_NUM_PASSWORDS_PER_HOSTNAME");
    for (let count of hostnameCount.values()) {
      passwordsCountHistogram.add(count);
    }
  },


  /**
   * Ensures that a login isn't missing any necessary fields.
   *
   * @param login
   *        The login to check.
   */
  _checkLogin(login) {
    // Sanity check the login
    if (login.hostname == null || login.hostname.length == 0) {
      throw new Error("Can't add a login with a null or empty hostname.");
    }

    // For logins w/o a username, set to "", not null.
    if (login.username == null) {
      throw new Error("Can't add a login with a null username.");
    }

    if (login.password == null || login.password.length == 0) {
      throw new Error("Can't add a login with a null or empty password.");
    }

    if (login.formSubmitURL || login.formSubmitURL == "") {
      // We have a form submit URL. Can't have a HTTP realm.
      if (login.httpRealm != null) {
        throw new Error("Can't add a login with both a httpRealm and formSubmitURL.");
      }
    } else if (login.httpRealm) {
      // We have a HTTP realm. Can't have a form submit URL.
      if (login.formSubmitURL != null) {
        throw new Error("Can't add a login with both a httpRealm and formSubmitURL.");
      }
    } else {
      // Need one or the other!
      throw new Error("Can't add a login without a httpRealm or formSubmitURL.");
    }
  },




  /* ---------- Primary Public interfaces ---------- */




  /**
   * @type Promise
   * This promise is resolved when initialization is complete, and is rejected
   * in case the asynchronous part of initialization failed.
   */
  initializationPromise: null,


  /**
   * Add a new login to login storage.
   */
  addLogin(login) {
    this._checkLogin(login);

    // Look for an existing entry.
    var logins = this.findLogins({}, login.hostname, login.formSubmitURL,
                                 login.httpRealm);

    if (logins.some(l => login.matches(l, true))) {
      throw new Error("This login already exists.");
    }

    log.debug("Adding login");
    return this._storage.addLogin(login);
  },

  async addLogins(logins) {
    let crypto = Cc["@mozilla.org/login-manager/crypto/SDR;1"].
                 getService(Ci.nsILoginManagerCrypto);
    let plaintexts = logins.map(l => l.username).concat(logins.map(l => l.password));
    let ciphertexts = await crypto.encryptMany(plaintexts);
    let usernames = ciphertexts.slice(0, logins.length);
    let passwords = ciphertexts.slice(logins.length);
    let resultLogins = [];
    for (let i = 0; i < logins.length; i++) {
      try {
        this._checkLogin(logins[i]);
      } catch (e) {
        Cu.reportError(e);
        continue;
      }

      let plaintextUsername = logins[i].username;
      let plaintextPassword = logins[i].password;
      logins[i].username = usernames[i];
      logins[i].password = passwords[i];
      log.debug("Adding login");
      let resultLogin = this._storage.addLogin(logins[i], true);
      // Reset the username and password to keep the same guarantees as addLogin
      logins[i].username = plaintextUsername;
      logins[i].password = plaintextPassword;

      resultLogin.username = plaintextUsername;
      resultLogin.password = plaintextPassword;
      resultLogins.push(resultLogin);
    }
    return resultLogins;
  },

  /**
   * Remove the specified login from the stored logins.
   */
  removeLogin(login) {
    log.debug("Removing login");
    return this._storage.removeLogin(login);
  },


  /**
   * Change the specified login to match the new login.
   */
  modifyLogin(oldLogin, newLogin) {
    log.debug("Modifying login");
    return this._storage.modifyLogin(oldLogin, newLogin);
  },


  /**
   * Get a dump of all stored logins. Used by the login manager UI.
   *
   * @param count - only needed for XPCOM.
   * @return {nsILoginInfo[]} - If there are no logins, the array is empty.
   */
  getAllLogins(count) {
    log.debug("Getting a list of all logins");
    return this._storage.getAllLogins(count);
  },


  /**
   * Remove all stored logins.
   */
  removeAllLogins() {
    log.debug("Removing all logins");
    this._storage.removeAllLogins();
  },

  /**
   * Get a list of all origins for which logins are disabled.
   *
   * @param {Number} count - only needed for XPCOM.
   *
   * @return {String[]} of disabled origins. If there are no disabled origins,
   *                    the array is empty.
   */
  getAllDisabledHosts(count) {
    log.debug("Getting a list of all disabled origins");

    let disabledHosts = [];
    let enumerator = Services.perms.enumerator;

    while (enumerator.hasMoreElements()) {
      let perm = enumerator.getNext();
      if (perm.type == PERMISSION_SAVE_LOGINS && perm.capability == Services.perms.DENY_ACTION) {
        disabledHosts.push(perm.principal.URI.displayPrePath);
      }
    }

    if (count)
      count.value = disabledHosts.length; // needed for XPCOM

    log.debug("getAllDisabledHosts: returning", disabledHosts.length, "disabled hosts.");
    return disabledHosts;
  },


  /**
   * Search for the known logins for entries matching the specified criteria.
   */
  findLogins(count, origin, formActionOrigin, httpRealm) {
    log.debug("Searching for logins matching origin:", origin,
              "formActionOrigin:", formActionOrigin, "httpRealm:", httpRealm);

    return this._storage.findLogins(count, origin, formActionOrigin,
                                    httpRealm);
  },


  /**
   * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
   * JavaScript object and decrypt the results.
   *
   * @return {nsILoginInfo[]} which are decrypted.
   */
  searchLogins(count, matchData) {
    log.debug("Searching for logins");

    matchData.QueryInterface(Ci.nsIPropertyBag2);
    if (!matchData.hasKey("guid")) {
      if (!matchData.hasKey("hostname")) {
        log.warn("searchLogins: A `hostname` is recommended");
      }

      if (!matchData.hasKey("formSubmitURL") && !matchData.hasKey("httpRealm")) {
        log.warn("searchLogins: `formSubmitURL` or `httpRealm` is recommended");
      }
    }

    return this._storage.searchLogins(count, matchData);
  },


  /**
   * Search for the known logins for entries matching the specified criteria,
   * returns only the count.
   */
  countLogins(origin, formActionOrigin, httpRealm) {
    log.debug("Counting logins matching origin:", origin,
              "formActionOrigin:", formActionOrigin, "httpRealm:", httpRealm);

    return this._storage.countLogins(origin, formActionOrigin, httpRealm);
  },


  get uiBusy() {
    return this._storage.uiBusy;
  },


  get isLoggedIn() {
    return this._storage.isLoggedIn;
  },


  /**
   * Check to see if user has disabled saving logins for the origin.
   */
  getLoginSavingEnabled(origin) {
    log.debug("Checking if logins to", origin, "can be saved.");
    if (!this._remember) {
      return false;
    }

    let uri = Services.io.newURI(origin);
    return Services.perms.testPermission(uri, PERMISSION_SAVE_LOGINS) != Services.perms.DENY_ACTION;
  },


  /**
   * Enable or disable storing logins for the specified origin.
   */
  setLoginSavingEnabled(origin, enabled) {
    // Throws if there are bogus values.
    LoginHelper.checkHostnameValue(origin);

    let uri = Services.io.newURI(origin);
    if (enabled) {
      Services.perms.remove(uri, PERMISSION_SAVE_LOGINS);
    } else {
      Services.perms.add(uri, PERMISSION_SAVE_LOGINS, Services.perms.DENY_ACTION);
    }

    log.debug("Login saving for", origin, "now enabled?", enabled);
    LoginHelper.notifyStorageChanged(enabled ? "hostSavingEnabled" : "hostSavingDisabled", origin);
  },

  /**
   * Yuck. This is called directly by satchel:
   * nsFormFillController::StartSearch()
   * [toolkit/components/satchel/nsFormFillController.cpp]
   *
   * We really ought to have a simple way for code to register an
   * auto-complete provider, and not have satchel calling pwmgr directly.
   */
  autoCompleteSearchAsync(aSearchString, aPreviousResult,
                          aElement, aCallback) {
    // aPreviousResult is an nsIAutoCompleteResult, aElement is
    // HTMLInputElement

    let form = LoginFormFactory.createFromField(aElement);
    let isSecure = InsecurePasswordUtils.isFormSecure(form);
    let isPasswordField = aElement.type == "password";

    let completeSearch = (autoCompleteLookupPromise, { logins, messageManager }) => {
      // If the search was canceled before we got our
      // results, don't bother reporting them.
      if (this._autoCompleteLookupPromise !== autoCompleteLookupPromise) {
        return;
      }

      this._autoCompleteLookupPromise = null;
      let results = new UserAutoCompleteResult(aSearchString, logins, {
        messageManager,
        isSecure,
        isPasswordField,
      });
      aCallback.onSearchCompletion(results);
    };

    if (isPasswordField && aSearchString) {
      // Return empty result on password fields with password already filled.
      let acLookupPromise = this._autoCompleteLookupPromise = Promise.resolve({ logins: [] });
      acLookupPromise.then(completeSearch.bind(this, acLookupPromise));
      return;
    }

    if (!this._remember) {
      let acLookupPromise = this._autoCompleteLookupPromise = Promise.resolve({ logins: [] });
      acLookupPromise.then(completeSearch.bind(this, acLookupPromise));
      return;
    }

    log.debug("AutoCompleteSearch invoked. Search is:", aSearchString);

    let previousResult;
    if (aPreviousResult) {
      previousResult = { searchString: aPreviousResult.searchString,
                         logins: aPreviousResult.wrappedJSObject.logins };
    } else {
      previousResult = null;
    }

    let rect = BrowserUtils.getElementBoundingScreenRect(aElement);
    let acLookupPromise = this._autoCompleteLookupPromise =
      LoginManagerContent._autoCompleteSearchAsync(aSearchString, previousResult,
                                                   aElement, rect);
    acLookupPromise.then(completeSearch.bind(this, acLookupPromise))
                             .catch(Cu.reportError);
  },

  stopSearch() {
    this._autoCompleteLookupPromise = null;
  },
}; // end of LoginManager implementation

this.NSGetFactory = XPCOMUtils.generateNSGetFactory([LoginManager]);
