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

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

"use strict";

const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;

Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
                                  "resource://gre/modules/LoginHelper.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginImport",
                                  "resource://gre/modules/LoginImport.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "LoginStore",
                                  "resource://gre/modules/LoginStore.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                  "resource://gre/modules/osfile.jsm");

XPCOMUtils.defineLazyServiceGetter(this, "gUUIDGenerator",
                                   "@mozilla.org/uuid-generator;1",
                                   "nsIUUIDGenerator");

this.LoginManagerStorage_json = function () {};

this.LoginManagerStorage_json.prototype = {
  classID: Components.ID("{c00c432d-a0c9-46d7-bef6-9c45b4d07341}"),
  QueryInterface: XPCOMUtils.generateQI([Ci.nsILoginManagerStorage]),

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

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

      // Set the reference to LoginStore synchronously.
      let jsonPath = OS.Path.join(OS.Constants.Path.profileDir,
                                  "logins.json");
      this._store = new LoginStore(jsonPath);

      return Task.spawn(function () {
        // Load the data asynchronously.
        this.log("Opening database at", this._store.path);
        yield this._store.load();

        // The import from previous versions operates the first time
        // that this built-in storage back-end is used.  This may be
        // later than expected, in case add-ons have registered an
        // alternate storage that disabled the default one.
        try {
          if (Services.prefs.getBoolPref("signon.importedFromSqlite")) {
            return;
          }
        } catch (ex) {
          // If the preference does not exist, we need to import.
        }

        // Import only happens asynchronously.
        let sqlitePath = OS.Path.join(OS.Constants.Path.profileDir,
                                      "signons.sqlite");
        if (yield OS.File.exists(sqlitePath)) {
          let loginImport = new LoginImport(this._store, sqlitePath);
          // Failures during import, for example due to a corrupt
          // file or a schema version that is too old, will not
          // prevent us from marking the operation as completed.
          // At the next startup, we will not try the import again.
          yield loginImport.import().catch(Cu.reportError);
          this._store.saveSoon();
        }

        // We won't attempt import again on next startup.
        Services.prefs.setBoolPref("signon.importedFromSqlite", true);
      }.bind(this)).catch(Cu.reportError);
    } catch (e) {
      this.log("Initialization failed:", e);
      throw new Error("Initialization failed");
    }
  },

  /**
   * Internal method used by regression tests only.  It is called before
   * replacing this storage module with a new instance.
   */
  terminate() {
    this._store._saver.disarm();
    return this._store.save();
  },

  addLogin(login) {
    this._store.ensureDataReady();

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

    let [encUsername, encPassword, encType] = this._encryptLogin(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) {
      if (!this._isGuidUnique(loginClone.guid))
        throw new Error("specified GUID already exists");
    } else {
      loginClone.guid = gUUIDGenerator.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;

    this._store.data.logins.push({
      id:                  this._store.data.nextId++,
      hostname:            loginClone.hostname,
      httpRealm:           loginClone.httpRealm,
      formSubmitURL:       loginClone.formSubmitURL,
      usernameField:       loginClone.usernameField,
      passwordField:       loginClone.passwordField,
      encryptedUsername:   encUsername,
      encryptedPassword:   encPassword,
      guid:                loginClone.guid,
      encType:             encType,
      timeCreated:         loginClone.timeCreated,
      timeLastUsed:        loginClone.timeLastUsed,
      timePasswordChanged: loginClone.timePasswordChanged,
      timesUsed:           loginClone.timesUsed
    });
    this._store.saveSoon();

    // Send a notification that a login was added.
    this._sendNotification("addLogin", loginClone);
  },

  removeLogin(login) {
    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) {
      this._store.data.logins.splice(foundIndex, 1);
      this._store.saveSoon();
    }

    this._sendNotification("removeLogin", storedLogin);
  },

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

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

    let newLogin = 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 logins = this.findLogins({}, newLogin.hostname,
                                   newLogin.formSubmitURL,
                                   newLogin.httpRealm);

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

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

    for (let loginItem of this._store.data.logins) {
      if (loginItem.id == idToModify) {
        loginItem.hostname = newLogin.hostname;
        loginItem.httpRealm = newLogin.httpRealm;
        loginItem.formSubmitURL = newLogin.formSubmitURL;
        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;
        this._store.saveSoon();
        break;
      }
    }

    this._sendNotification("modifyLogin", [oldStoredLogin, newLogin]);
  },

  /**
   * @return {nsILoginInfo[]}
   */
  getAllLogins(count) {
    let [logins, ids] = this._searchLogins({});

    // decrypt entries for caller.
    logins = this._decryptLogins(logins);

    this.log("_getAllLogins: returning", logins.length, "logins.");
    if (count)
      count.value = logins.length; // needed for XPCOM
    return logins;
  },

  /**
   * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
   * JavaScript object and decrypt the results.
   *
   * @return {nsILoginInfo[]} which are decrypted.
   */
  searchLogins(count, matchData) {
    let realMatchData = {};
    // Convert nsIPropertyBag to normal JS object
    let propEnum = matchData.enumerator;
    while (propEnum.hasMoreElements()) {
      let prop = propEnum.getNext().QueryInterface(Ci.nsIProperty);
      realMatchData[prop.name] = prop.value;
    }

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

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

    count.value = logins.length; // needed for XPCOM
    return logins;
  },

  /**
   * Private method to perform arbitrary searches on any field. Decryption is
   * left to the caller.
   *
   * 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) {
    this._store.ensureDataReady();

    let conditions = [];

    function match(aLogin) {
      let returnValue = {
        match: false,
        strictMatch: true
      };

      for (let field in matchData) {
        let value = matchData[field];
        switch (field) {
          // Historical compatibility requires this special case
          case "formSubmitURL":
            if (value != null) {
              if (aLogin.formSubmitURL != "" && aLogin.formSubmitURL != value) {
                // Check for cases that don't have fallback matches.
                if (value == "" || value == "javascript:" ||
                    aLogin.formSubmitURL == "javascript:" ||
                    aLogin.formSubmitURL == null) {
                  return returnValue;
                }

                // Check if it matches with a different scheme.
                let loginURI = Services.io.newURI(aLogin.formSubmitURL, null, null);
                let matchURI = Services.io.newURI(value, null, null);

                if (loginURI.hostPort != matchURI.hostPort) {
                  return returnValue; // not a match at all
                }

                if ((loginURI.scheme != "http" && loginURI.scheme != "https") ||
                    (matchURI.scheme != "http" && matchURI.scheme != "https")) {
                  // Not a match at all since we only fallback HTTP <=> HTTPS.
                  return returnValue;
                }

                returnValue.strictMatch = false; // not a strict match
              }
              break;
            }
          // Normal cases.
          case "hostname":
          case "httpRealm":
          case "id":
          case "usernameField":
          case "passwordField":
          case "encryptedUsername":
          case "encryptedPassword":
          case "guid":
          case "encType":
          case "timeCreated":
          case "timeLastUsed":
          case "timePasswordChanged":
          case "timesUsed":
            if (value == null && aLogin[field]) {
              return returnValue;
            } else if (aLogin[field] != value) {
              return returnValue;
            }
            break;
          // Fail if caller requests an unknown property.
          default:
            throw new Error("Unexpected field: " + field);
        }
      }
      returnValue.match = true;
      return returnValue;
    }

    let foundLogins = [], foundIds = [], fallbackLogins = [], fallbackIds = [];
    for (let loginItem of this._store.data.logins) {
      let result = match(loginItem);
      if (result.match) {
        // 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;
        // If protocol does not match, use as a fallback login
        if (result.strictMatch) {
          foundLogins.push(login);
          foundIds.push(loginItem.id);
        } else {
          fallbackLogins.push(login);
          fallbackIds.push(loginItem.id);
        }
      }
    }

    if (!foundLogins.length && fallbackLogins.length) {
      this.log("_searchLogins: returning", fallbackLogins.length, "fallback logins");
      return [fallbackLogins, fallbackIds];
    }
    this.log("_searchLogins: returning", foundLogins.length, "logins");
    return [foundLogins, foundIds];
  },

  /**
   * Removes all logins from storage.
   *
   * Disabled hosts are kept, as one presumably doesn't want to erase those.
   */
  removeAllLogins() {
    this._store.ensureDataReady();

    this.log("Removing all logins");
    this._store.data.logins = [];
    this._store.saveSoon();

    this._sendNotification("removeAllLogins", null);
  },

  getAllDisabledHosts(count) {
    this._store.ensureDataReady();

    let disabledHosts = this._store.data.disabledHosts.slice(0);

    this.log("_getAllDisabledHosts: returning", disabledHosts.length, "disabled hosts.");
    if (count)
      count.value = disabledHosts.length; // needed for XPCOM
    return disabledHosts;
  },

  getLoginSavingEnabled(hostname) {
    this._store.ensureDataReady();

    this.log("Getting login saving is enabled for", hostname);
    return this._store.data.disabledHosts.indexOf(hostname) == -1;
  },

  setLoginSavingEnabled(hostname, enabled) {
    this._store.ensureDataReady();

    // Throws if there are bogus values.
    LoginHelper.checkHostnameValue(hostname);

    this.log("Setting login saving enabled for", hostname, "to", enabled);
    let foundIndex = this._store.data.disabledHosts.indexOf(hostname);
    if (enabled) {
      if (foundIndex != -1) {
        this._store.data.disabledHosts.splice(foundIndex, 1);
        this._store.saveSoon();
      }
    } else {
      if (foundIndex == -1) {
        this._store.data.disabledHosts.push(hostname);
        this._store.saveSoon();
      }
    }

    this._sendNotification(enabled ? "hostSavingEnabled" : "hostSavingDisabled", hostname);
  },

  findLogins(count, hostname, formSubmitURL, httpRealm) {
    let loginData = {
      hostname: hostname,
      formSubmitURL: formSubmitURL,
      httpRealm: httpRealm
    };
    let matchData = { };
    for each (let field in ["hostname", "formSubmitURL", "httpRealm"])
      if (loginData[field] != '')
        matchData[field] = loginData[field];
    let [logins, ids] = this._searchLogins(matchData);

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

    this.log("_findLogins: returning", logins.length, "logins");
    count.value = logins.length; // needed for XPCOM
    return logins;
  },

  countLogins(hostname, formSubmitURL, httpRealm) {
    let count = {};
    let loginData = {
      hostname: hostname,
      formSubmitURL: formSubmitURL,
      httpRealm: httpRealm
    };
    let matchData = { };
    for each (let field in ["hostname", "formSubmitURL", "httpRealm"])
      if (loginData[field] != '')
        matchData[field] = loginData[field];
    let [logins, ids] = this._searchLogins(matchData);

    this.log("_countLogins: counted logins:", logins.length);
    return logins.length;
  },

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

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

  /**
   * Send a notification when stored data is changed.
   */
  _sendNotification(changeType, data) {
    let dataObject = data;
    // Can't pass a raw JS string or array though notifyObservers(). :-(
    if (data instanceof Array) {
      dataObject = Cc["@mozilla.org/array;1"].
                   createInstance(Ci.nsIMutableArray);
      for (let i = 0; i < data.length; i++)
        dataObject.appendElement(data[i], false);
    } else if (typeof(data) == "string") {
      dataObject = Cc["@mozilla.org/supports-string;1"].
                   createInstance(Ci.nsISupportsString);
      dataObject.data = data;
    }
    Services.obs.notifyObservers(dataObject, "passwordmgr-storage-changed", changeType);
  },

  /**
   * 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) {
    let matchData = { };
    for each (let field in ["hostname", "formSubmitURL", "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);
  },

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

    return [encUsername, encPassword, encType];
  },

  /**
   * 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 master password)
   */
  _decryptLogins(logins) {
    let result = [];

    for each (let login in logins) {
      try {
        login.username = this._crypto.decrypt(login.username);
        login.password = this._crypto.decrypt(login.password);
      } catch (e) {
        // If decryption failed (corrupt entry?), just skip it.
        // Rethrow other errors (like canceling entry of a master pw)
        if (e.result == Cr.NS_ERROR_FAILURE)
          continue;
        throw e;
      }
      result.push(login);
    }

    return result;
  },
};

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

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