/* ***** BEGIN LICENSE BLOCK *****
 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is mozilla.org code.
 *
 * The Initial Developer of the Original Code is Mozilla Foundation.
 * Portions created by the Initial Developer are Copyright (C) 2007
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s):
 *  Justin Dolske <dolske@mozilla.com> (original author)
 *  Ehsan Akhgari <ehsan.akhgari@gmail.com>
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either the GNU General Public License Version 2 or later (the "GPL"), or
 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the MPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the MPL, the GPL or the LGPL.
 *
 * ***** END LICENSE BLOCK ***** */


const Cc = Components.classes;
const Ci = Components.interfaces;

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
Components.utils.import("resource://gre/modules/Services.jsm");

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

LoginManager.prototype = {

    classID: Components.ID("{f9a0edde-2a8d-4bfd-a08c-3f9333213a85}"),
    QueryInterface : XPCOMUtils.generateQI([Ci.nsILoginManager,
                                            Ci.nsIObserver,
                                            Ci.nsISupportsWeakReference]),


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


    __storage : null, // Storage component which contains the saved logins
    get _storage() {
        if (!this.__storage) {

            var contractID = "@mozilla.org/login-manager/storage/mozStorage;1";
            try {
                var catMan = Cc["@mozilla.org/categorymanager;1"].
                             getService(Ci.nsICategoryManager);
                contractID = catMan.getCategoryEntry("login-manager-storage",
                                                     "nsILoginManagerStorage");
                this.log("Found alternate nsILoginManagerStorage with " +
                         "contract ID: " + contractID);
            } catch (e) {
                this.log("No alternate nsILoginManagerStorage registered");
            }

            this.__storage = Cc[contractID].
                             createInstance(Ci.nsILoginManagerStorage);
            try {
                this.__storage.init();
            } catch (e) {
                this.log("Initialization of storage component failed: " + e);
                this.__storage = null;
            }
        }

        return this.__storage;
    },


    _nsLoginInfo : null, // Constructor for nsILoginInfo implementation
    _debug    : false, // mirrors signon.debug
    _remember : true,  // mirrors signon.rememberSignons preference


    /*
     * init
     *
     * Initialize the Login Manager. Automatically called when service
     * is created.
     *
     * Note: Service created in /browser/base/content/browser.js,
     *       delayedStartup()
     */
    init : function () {
        // Add content listener.
        var messageManager = Cc["@mozilla.org/globalmessagemanager;1"].
                             getService(Ci.nsIChromeFrameMessageManager);
        messageManager.loadFrameScript("chrome://browser/content/LoginManagerChild.js", true);
        messageManager.addMessageListener("PasswordMgr:FormSubmitted", this);
        messageManager.addMessageListener("PasswordMgr:GetPasswords", this);

        // Get constructor for nsILoginInfo
        this._nsLoginInfo = new Components.Constructor(
            "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo);

        // Preferences. Add observer so we get notified of changes.
        Services.prefs.addObserver("signon.", this, false);

        // Get current preference values.
        this._debug = Services.prefs.getBoolPref("signon.debug");
        this._remember = Services.prefs.getBoolPref("signon.rememberSignons");

        // Listen for shutdown to clean up
        Services.obs.addObserver(this, "xpcom-shutdown", false);
    },

    /*
     * log
     *
     * Internal function for logging debug messages to the Error Console window
     */
    log : function (message) {
        if (!this._debug)
            return;
        dump("PasswordUtils: " + message + "\n");
        Services.console.logStringMessage("PasswordUtils: " + message);
    },

    /*
     * observe
     *
     * Implements nsIObserver for preferences and shutdown.
     */
    observe : function (subject, topic, data) {
        if (topic == "nsPref:changed") {
            this._pwmgr._debug    = Services.prefs.getBoolPref("signon.debug");
            this._pwmgr._remember = Services.prefs.getBoolPref("signon.rememberSignons");
        } else if (topic == "xpcom-shutdown") {
            // Circular reference forms when we mark an input field as managed
            // by the password manager
            this._formFillService = null;
        } else {
            this._pwmgr.log("Oops! Unexpected notification: " + topic);
        }
    },

    /*
     * receiveMessage
     *
     * Receives messages from content process.
     */
    receiveMessage: function (message) {
        // local helper function
        function getPrompter(aWindow) {
            var prompterSvc = Cc["@mozilla.org/login-manager/prompter;1"].
                              createInstance(Ci.nsILoginManagerPrompter);
            prompterSvc.init(aWindow);
            return prompterSvc;
        }

        switch (message.name) {
            case "PasswordMgr:GetPasswords":
                // If there are no logins for this site, bail out now.
                if (!this.countLogins(message.json.formOrigin, "", null))
                    return {};
                var foundLogins = {};
                
                if (!this.uiBusy) {
                    for (var i = 0; i < message.json.actionOrigins.length; i++) {
                        var actionOrigin = message.json.actionOrigins[i];
                        var logins = this.findLogins({}, message.json.formOrigin, actionOrigin, null);
                        if (logins.length) {
                            foundLogins[actionOrigin] = logins;
                        }
                    }
                }

                return {
                    uiBusy: this.uiBusy,
                    foundLogins: foundLogins
                };

            case "PasswordMgr:FormSubmitted":
                var json = message.json;
                var hostname = json.hostname;
                var formSubmitURL = json.formSubmitURL;

                if (!this.getLoginSavingEnabled(hostname)) {
                    this.log("(form submission ignored -- saving is " +
                             "disabled for: " + hostname + ")");
                    return {};
                }

                // Since prompt services ask for window, we try to get the document
                // window.  Prompt service needs to take browser elements.
                var win = message.target.contentWindow || null;

                var formLogin = new this._nsLoginInfo();

                formLogin.init(hostname, formSubmitURL, null,
                            json.usernameValue,
                            json.passwordValue,
                            json.usernameField,
                            json.passwordField);

                // If we didn't find a username field, but seem to be changing a
                // password, allow the user to select from a list of applicable
                // logins to update the password for.
                if (!json.usernameField && json.hasOldPasswordField) {

                    var logins = this.findLogins({}, hostname, formSubmitURL, null);

                    if (logins.length == 0) {
                        // Could prompt to save this as a new password-only login.
                        // This seems uncommon, and might be wrong, so ignore.
                        this.log("(no logins for this host -- pwchange ignored)");
                        return {};
                    }

                    var prompter = getPrompter(win);

                    if (logins.length == 1) {
                        var oldLogin = logins[0];
                        formLogin.username      = oldLogin.username;
                        formLogin.usernameField = oldLogin.usernameField;

                        prompter.promptToChangePassword(oldLogin, formLogin);
                    } else {
                        prompter.promptToChangePasswordWithUsernames(
                                            logins, logins.length, formLogin);
                    }

                } else {

                    // Look for an existing login that matches the form login.
                    var existingLogin = null;
                    var logins = this.findLogins({}, hostname, formSubmitURL, null);

                    for (var i = 0; i < logins.length; i++) {
                        var same, login = logins[i];

                        // If one login has a username but the other doesn't, ignore
                        // the username when comparing and only match if they have the
                        // same password. Otherwise, compare the logins and match even
                        // if the passwords differ.
                        if (!login.username && formLogin.username) {
                            var restoreMe = formLogin.username;
                            formLogin.username = ""; 
                            same = formLogin.matches(login, false);
                            formLogin.username = restoreMe;
                        } else if (!formLogin.username && login.username) {
                            formLogin.username = login.username;
                            same = formLogin.matches(login, false);
                            formLogin.username = ""; // we know it's always blank.
                        } else {
                            same = formLogin.matches(login, true);
                        }

                        if (same) {
                            existingLogin = login;
                            break;
                        }
                    }

                    if (existingLogin) {
                        this.log("Found an existing login matching this form submission");

                        // Change password if needed.
                        if (existingLogin.password != formLogin.password) {
                            this.log("...passwords differ, prompting to change.");
                            prompter = getPrompter(win);
                            prompter.promptToChangePassword(existingLogin, formLogin);
                        } else {
                            // Update the lastUsed timestamp.
                            var propBag = Cc["@mozilla.org/hash-property-bag;1"].
                                          createInstance(Ci.nsIWritablePropertyBag);
                            propBag.setProperty("timeLastUsed", Date.now());
                            propBag.setProperty("timesUsedIncrement", 1);
                            this.modifyLogin(existingLogin, propBag);
                        }

                        return {};
                    }


                    // Prompt user to save login (via dialog or notification bar)
                    prompter = getPrompter(win);
                    prompter.promptToSavePassword(formLogin);
                }
                return {};

            default:
                throw "Unexpected message " + message.name;
        }
    },


    /*
     * _getPasswordOrigin
     *
     * Get the parts of the URL we want for identification.
     */
    _getPasswordOrigin : function (uriString, allowJS) {
        var realm = "";
        try {
            var uri = Services.io.newURI(uriString, null, null);

            if (allowJS && uri.scheme == "javascript")
                return "javascript:"

            realm = uri.scheme + "://" + uri.host;

            // If the URI explicitly specified a port, only include it when
            // it's not the default. (We never want "http://foo.com:80")
            var port = uri.port;
            if (port != -1) {
                var handler = Services.io.getProtocolHandler(uri.scheme);
                if (port != handler.defaultPort)
                    realm += ":" + port;
            }

        } catch (e) {
            // bug 159484 - disallow url types that don't support a hostPort.
            // (although we handle "javascript:..." as a special case above.)
            this.log("Couldn't parse origin for " + uriString);
            realm = null;
        }

        return realm;
    },


    _getActionOrigin : function (form) {
        var uriString = form.action;

        // A blank or mission action submits to where it came from.
        if (uriString == "")
            uriString = form.baseURI; // ala bug 297761

        return this._getPasswordOrigin(uriString, true);
    },


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


    /*
     * fillForm
     *
     * Fill the form with login information if we can find it.
     */
    fillForm : function (form) {
        // XXX figure out what to do about fillForm
        return false;
    },


    /*
     * addLogin
     *
     * Add a new login to login storage.
     */
    addLogin : function (login) {
        // Sanity check the login
        if (login.hostname == null || login.hostname.length == 0)
            throw "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 "Can't add a login with a null username.";

        if (login.password == null || login.password.length == 0)
            throw "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 "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 "Can't add a login with both a httpRealm and formSubmitURL.";
        } else {
            // Need one or the other!
            throw "Can't add a login without a httpRealm or formSubmitURL.";
        }


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

        if (logins.some(function(l) login.matches(l, true)))
            throw "This login already exists.";

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


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


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


    /*
     * getAllLogins
     *
     * Get a dump of all stored logins. Used by the login manager UI.
     *
     * |count| is only needed for XPCOM.
     *
     * Returns an array of logins. If there are no logins, the array is empty.
     */
    getAllLogins : function (count) {
        this.log("Getting a list of all logins");
        return this._storage.getAllLogins(count);
    },


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

    /*
     * getAllDisabledHosts
     *
     * Get a list of all hosts for which logins are disabled.
     *
     * |count| is only needed for XPCOM.
     *
     * Returns an array of disabled logins. If there are no disabled logins,
     * the array is empty.
     */
    getAllDisabledHosts : function (count) {
        this.log("Getting a list of all disabled hosts");
        return this._storage.getAllDisabledHosts(count);
    },


    /*
     * findLogins
     *
     * Search for the known logins for entries matching the specified criteria.
     */
    findLogins : function (count, hostname, formSubmitURL, httpRealm) {
        this.log("Searching for logins matching host: " + hostname +
            ", formSubmitURL: " + formSubmitURL + ", httpRealm: " + httpRealm);

        return this._storage.findLogins(count, hostname, formSubmitURL,
                                        httpRealm);
    },


    /*
     * searchLogins
     *
     * Public wrapper around _searchLogins to convert the nsIPropertyBag to a
     * JavaScript object and decrypt the results.
     *
     * Returns an array of decrypted nsILoginInfo.
     */
    searchLogins : function(count, matchData) {
       this.log("Searching for logins");

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


    /*
     * countLogins
     *
     * Search for the known logins for entries matching the specified criteria,
     * returns only the count.
     */
    countLogins : function (hostname, formSubmitURL, httpRealm) {
        this.log("Counting logins matching host: " + hostname +
            ", formSubmitURL: " + formSubmitURL + ", httpRealm: " + httpRealm);

        return this._storage.countLogins(hostname, formSubmitURL, httpRealm);
    },


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


    /*
     * getLoginSavingEnabled
     *
     * Check to see if user has disabled saving logins for the host.
     */
    getLoginSavingEnabled : function (host) {
        this.log("Checking if logins to " + host + " can be saved.");
        if (!this._remember)
            return false;

        return this._storage.getLoginSavingEnabled(host);
    },


    /*
     * setLoginSavingEnabled
     *
     * Enable or disable storing logins for the specified host.
     */
    setLoginSavingEnabled : function (hostname, enabled) {
        // Nulls won't round-trip with getAllDisabledHosts().
        if (hostname.indexOf("\0") != -1)
            throw "Invalid hostname";

        this.log("Saving logins for " + hostname + " enabled? " + enabled);
        return this._storage.setLoginSavingEnabled(hostname, enabled);
    },


    /*
     * autoCompleteSearch
     *
     * Yuck. This is called directly by satchel:
     * nsFormFillController::StartSearch()
     * [toolkit/components/satchel/src/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.
     */
    autoCompleteSearch : function (aSearchString, aPreviousResult, aElement) {
        // aPreviousResult & aResult are nsIAutoCompleteResult,
        // aElement is nsIDOMHTMLInputElement

        if (!this._remember)
            return null;

        this.log("AutoCompleteSearch invoked. Search is: " + aSearchString);

        var result = null;

        if (aPreviousResult &&
                aSearchString.substr(0, aPreviousResult.searchString.length) == aPreviousResult.searchString) {
            this.log("Using previous autocomplete result");
            result = aPreviousResult;
            result.wrappedJSObject.searchString = aSearchString;

            // We have a list of results for a shorter search string, so just
            // filter them further based on the new search string.
            // Count backwards, because result.matchCount is decremented
            // when we remove an entry.
            for (var i = result.matchCount - 1; i >= 0; i--) {
                var match = result.getValueAt(i);

                // Remove results that are too short, or have different prefix.
                if (aSearchString.length > match.length ||
                    aSearchString.toLowerCase() !=
                        match.substr(0, aSearchString.length).toLowerCase())
                {
                    this.log("Removing autocomplete entry '" + match + "'");
                    result.removeValueAt(i, false);
                }
            }
        } else {
            this.log("Creating new autocomplete search result.");

            var doc = aElement.ownerDocument;
            var origin = this._getPasswordOrigin(doc.documentURI);
            var actionOrigin = this._getActionOrigin(aElement.form);

            // This shouldn't trigger a master password prompt, because we
            // don't attach to the input until after we successfully obtain
            // logins for the form.
            var logins = this.findLogins({}, origin, actionOrigin, null);
            var matchingLogins = [];

            // Filter out logins that don't match the search prefix. Also
            // filter logins without a username, since that's confusing to see
            // in the dropdown and we can't autocomplete them anyway.
            for (i = 0; i < logins.length; i++) {
                var username = logins[i].username.toLowerCase();
                if (username &&
                    aSearchString.length <= username.length &&
                    aSearchString.toLowerCase() ==
                        username.substr(0, aSearchString.length))
                {
                    matchingLogins.push(logins[i]);
                }
            }
            this.log(matchingLogins.length + " autocomplete logins avail.");
            result = new UserAutoCompleteResult(aSearchString, matchingLogins);
        }

        return result;
    }
}; // end of LoginManager implementation




// nsIAutoCompleteResult implementation
function UserAutoCompleteResult (aSearchString, matchingLogins) {
    function loginSort(a,b) {
        var userA = a.username.toLowerCase();
        var userB = b.username.toLowerCase();

        if (userA < userB)
            return -1;

        if (userB > userA)
            return  1;

        return 0;
    };

    this.searchString = aSearchString;
    this.logins = matchingLogins.sort(loginSort);
    this.matchCount = matchingLogins.length;

    if (this.matchCount > 0) {
        this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
        this.defaultIndex = 0;
    }
}

UserAutoCompleteResult.prototype = {
    QueryInterface : XPCOMUtils.generateQI([Ci.nsIAutoCompleteResult,
                                            Ci.nsISupportsWeakReference]),

    // private
    logins : null,

    // Allow autoCompleteSearch to get at the JS object so it can
    // modify some readonly properties for internal use.
    get wrappedJSObject() {
        return this;
    },

    // Interfaces from idl...
    searchString : null,
    searchResult : Ci.nsIAutoCompleteResult.RESULT_NOMATCH,
    defaultIndex : -1,
    errorDescription : "",
    matchCount : 0,

    getValueAt : function (index) {
        if (index < 0 || index >= this.logins.length)
            throw "Index out of range.";

        return this.logins[index].username;
    },

    getCommentAt : function (index) {
        return "";
    },

    getStyleAt : function (index) {
        return "";
    },

    getImageAt : function (index) {
        return "";
    },

    removeValueAt : function (index, removeFromDB) {
        if (index < 0 || index >= this.logins.length)
            throw "Index out of range.";

        var [removedLogin] = this.logins.splice(index, 1);

        this.matchCount--;
        if (this.defaultIndex > this.logins.length)
            this.defaultIndex--;

        if (removeFromDB) {
            var pwmgr = Cc["@mozilla.org/login-manager;1"].
                        getService(Ci.nsILoginManager);
            pwmgr.removeLogin(removedLogin);
        }
    }
};

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