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


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

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

XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
                                  "resource://gre/modules/BrowserUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
                                  "resource://gre/modules/Deprecated.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "FormHistory",
                                  "resource://gre/modules/FormHistory.jsm");

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

/**
 * FormAutoComplete
 *
 * Implements the nsIFormAutoComplete interface in the main process.
 */
FormAutoComplete.prototype = {
    classID          : Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"),
    QueryInterface   : XPCOMUtils.generateQI([Ci.nsIFormAutoComplete, Ci.nsISupportsWeakReference]),

    _prefBranch         : null,
    _debug              : true, // mirrors browser.formfill.debug
    _enabled            : true, // mirrors browser.formfill.enable preference
    _agedWeight         : 2,
    _bucketSize         : 1,
    _maxTimeGroupings   : 25,
    _timeGroupingSize   : 7 * 24 * 60 * 60 * 1000 * 1000,
    _expireDays         : null,
    _boundaryWeight     : 25,
    _prefixWeight       : 5,

    // Only one query is performed at a time, which will be stored in _pendingQuery
    // while the query is being performed. It will be cleared when the query finishes,
    // is cancelled, or an error occurs. If a new query occurs while one is already
    // pending, the existing one is cancelled. The pending query will be an
    // mozIStoragePendingStatement object.
    _pendingQuery       : null,

    init : function() {
        // Preferences. Add observer so we get notified of changes.
        this._prefBranch = Services.prefs.getBranch("browser.formfill.");
        this._prefBranch.addObserver("", this.observer, true);
        this.observer._self = this;

        this._debug            = this._prefBranch.getBoolPref("debug");
        this._enabled          = this._prefBranch.getBoolPref("enable");
        this._agedWeight       = this._prefBranch.getIntPref("agedWeight");
        this._bucketSize       = this._prefBranch.getIntPref("bucketSize");
        this._maxTimeGroupings = this._prefBranch.getIntPref("maxTimeGroupings");
        this._timeGroupingSize = this._prefBranch.getIntPref("timeGroupingSize") * 1000 * 1000;
        this._expireDays       = this._prefBranch.getIntPref("expire_days");
    },

    observer : {
        _self : null,

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

        observe : function (subject, topic, data) {
            let self = this._self;
            if (topic == "nsPref:changed") {
                let prefName = data;
                self.log("got change to " + prefName + " preference");

                switch (prefName) {
                    case "agedWeight":
                        self._agedWeight = self._prefBranch.getIntPref(prefName);
                        break;
                    case "debug":
                        self._debug = self._prefBranch.getBoolPref(prefName);
                        break;
                    case "enable":
                        self._enabled = self._prefBranch.getBoolPref(prefName);
                        break;
                    case "maxTimeGroupings":
                        self._maxTimeGroupings = self._prefBranch.getIntPref(prefName);
                        break;
                    case "timeGroupingSize":
                        self._timeGroupingSize = self._prefBranch.getIntPref(prefName) * 1000 * 1000;
                        break;
                    case "bucketSize":
                        self._bucketSize = self._prefBranch.getIntPref(prefName);
                        break;
                    case "boundaryWeight":
                        self._boundaryWeight = self._prefBranch.getIntPref(prefName);
                        break;
                    case "prefixWeight":
                        self._prefixWeight = self._prefBranch.getIntPref(prefName);
                        break;
                    default:
                        self.log("Oops! Pref not handled, change ignored.");
                }
            }
        }
    },


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


    autoCompleteSearch : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult) {
      Deprecated.warning("nsIFormAutoComplete::autoCompleteSearch is deprecated", "https://bugzilla.mozilla.org/show_bug.cgi?id=697377");

      let result = null;
      let listener = {
        onSearchCompletion: function (r) result = r
      };
      this._autoCompleteSearchShared(aInputName, aUntrimmedSearchString, aField, aPreviousResult, listener);

      // Just wait for the result to to be available.
      let thread = Components.classes["@mozilla.org/thread-manager;1"].getService().currentThread;
      while (!result && this._pendingQuery) {
        thread.processNextEvent(true);
      }

      return result;
    },

    autoCompleteSearchAsync : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult, aListener) {
      this._autoCompleteSearchShared(aInputName, aUntrimmedSearchString, aField, aPreviousResult, aListener);
    },

    /*
     * autoCompleteSearchShared
     *
     * aInputName    -- |name| attribute from the form input being autocompleted.
     * aUntrimmedSearchString -- current value of the input
     * aField -- nsIDOMHTMLInputElement being autocompleted (may be null if from chrome)
     * aPreviousResult -- previous search result, if any.
     * aListener -- nsIFormAutoCompleteObserver that listens for the nsIAutoCompleteResult
     *              that may be returned asynchronously.
     */
    _autoCompleteSearchShared : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult, aListener) {
        function sortBytotalScore (a, b) {
            return b.totalScore - a.totalScore;
        }

        let result = null;
        if (!this._enabled) {
            result = new FormAutoCompleteResult(FormHistory, [], aInputName, aUntrimmedSearchString);
            if (aListener) {
              aListener.onSearchCompletion(result);
            }
            return;
        }

        // don't allow form inputs (aField != null) to get results from search bar history
        if (aInputName == 'searchbar-history' && aField) {
            this.log('autoCompleteSearch for input name "' + aInputName + '" is denied');
            result = new FormAutoCompleteResult(FormHistory, [], aInputName, aUntrimmedSearchString);
            if (aListener) {
              aListener.onSearchCompletion(result);
            }
            return;
        }

        this.log("AutoCompleteSearch invoked. Search is: " + aUntrimmedSearchString);
        let searchString = aUntrimmedSearchString.trim().toLowerCase();

        // reuse previous results if:
        // a) length greater than one character (others searches are special cases) AND
        // b) the the new results will be a subset of the previous results
        if (aPreviousResult && aPreviousResult.searchString.trim().length > 1 &&
            searchString.indexOf(aPreviousResult.searchString.trim().toLowerCase()) >= 0) {
            this.log("Using previous autocomplete result");
            result = aPreviousResult;
            result.wrappedJSObject.searchString = aUntrimmedSearchString;

            let searchTokens = searchString.split(/\s+/);
            // We have a list of results for a shorter search string, so just
            // filter them further based on the new search string and add to a new array.
            let entries = result.wrappedJSObject.entries;
            let filteredEntries = [];
            for (let i = 0; i < entries.length; i++) {
                let entry = entries[i];
                // Remove results that do not contain the token
                // XXX bug 394604 -- .toLowerCase can be wrong for some intl chars
                if(searchTokens.some(function (tok) entry.textLowerCase.indexOf(tok) < 0))
                    continue;
                this._calculateScore(entry, searchString, searchTokens);
                this.log("Reusing autocomplete entry '" + entry.text +
                         "' (" + entry.frecency +" / " + entry.totalScore + ")");
                filteredEntries.push(entry);
            }
            filteredEntries.sort(sortBytotalScore);
            result.wrappedJSObject.entries = filteredEntries;

            if (aListener) {
              aListener.onSearchCompletion(result);
            }
        } else {
            this.log("Creating new autocomplete search result.");

            // Start with an empty list.
            result = new FormAutoCompleteResult(FormHistory, [], aInputName, aUntrimmedSearchString);

            let processEntry = function(aEntries) {
              if (aField && aField.maxLength > -1) {
                result.entries =
                  aEntries.filter(function (el) { return el.text.length <= aField.maxLength; });
              } else {
                result.entries = aEntries;
              }

              if (aListener) {
                aListener.onSearchCompletion(result);
              }
            }

            this.getAutoCompleteValues(aInputName, searchString, processEntry);
        }
    },

    stopAutoCompleteSearch : function () {
        if (this._pendingQuery) {
            this._pendingQuery.cancel();
            this._pendingQuery = null;
        }
    },

    /*
     * Get the values for an autocomplete list given a search string.
     *
     *  fieldName - fieldname field within form history (the form input name)
     *  searchString - string to search for
     *  callback - called when the values are available. Passed an array of objects,
     *             containing properties for each result. The callback is only called
     *             when successful.
     */
    getAutoCompleteValues : function (fieldName, searchString, callback) {
        let params = {
            agedWeight:         this._agedWeight,
            bucketSize:         this._bucketSize,
            expiryDate:         1000 * (Date.now() - this._expireDays * 24 * 60 * 60 * 1000),
            fieldname:          fieldName,
            maxTimeGroupings:   this._maxTimeGroupings,
            timeGroupingSize:   this._timeGroupingSize,
            prefixWeight:       this._prefixWeight,
            boundaryWeight:     this._boundaryWeight
        }

        this.stopAutoCompleteSearch();

        let results = [];
        let processResults = {
          handleResult: aResult => {
            results.push(aResult);
          },
          handleError: aError => {
            this.log("getAutocompleteValues failed: " + aError.message);
          },
          handleCompletion: aReason => {
            // Check that the current query is still the one we created. Our
            // query might have been canceled shortly before completing, in
            // that case we don't want to call the callback anymore.
            if (query == this._pendingQuery) {
              this._pendingQuery = null;
              if (!aReason) {
                callback(results);
              }
            }
          }
        };

        let query = FormHistory.getAutoCompleteResults(searchString, params, processResults);
        this._pendingQuery = query;
    },

    /*
     * _calculateScore
     *
     * entry    -- an nsIAutoCompleteResult entry
     * aSearchString -- current value of the input (lowercase)
     * searchTokens -- array of tokens of the search string
     *
     * Returns: an int
     */
    _calculateScore : function (entry, aSearchString, searchTokens) {
        let boundaryCalc = 0;
        // for each word, calculate word boundary weights
        for each (let token in searchTokens) {
            boundaryCalc += (entry.textLowerCase.indexOf(token) == 0);
            boundaryCalc += (entry.textLowerCase.indexOf(" " + token) >= 0);
        }
        boundaryCalc = boundaryCalc * this._boundaryWeight;
        // now add more weight if we have a traditional prefix match and
        // multiply boundary bonuses by boundary weight
        boundaryCalc += this._prefixWeight *
                        (entry.textLowerCase.
                         indexOf(aSearchString) == 0);
        entry.totalScore = Math.round(entry.frecency * Math.max(1, boundaryCalc));
    }

}; // end of FormAutoComplete implementation

/**
 * FormAutoCompleteChild
 *
 * Implements the nsIFormAutoComplete interface in a child content process,
 * and forwards the auto-complete requests to the parent process which
 * also implements a nsIFormAutoComplete interface and has
 * direct access to the FormHistory database.
 */
function FormAutoCompleteChild() {
  this.init();
}

FormAutoCompleteChild.prototype = {
    classID          : Components.ID("{c11c21b2-71c9-4f87-a0f8-5e13f50495fd}"),
    QueryInterface   : XPCOMUtils.generateQI([Ci.nsIFormAutoComplete, Ci.nsISupportsWeakReference]),

    _debug: false,
    _enabled: true,
    _pendingSearch: null,

    /*
     * init
     *
     * Initializes the content-process side of the FormAutoComplete component,
     * and add a listener for the message that the parent process sends when
     * a result is produced.
     */
    init: function() {
      this._debug    = Services.prefs.getBoolPref("browser.formfill.debug");
      this._enabled  = Services.prefs.getBoolPref("browser.formfill.enable");
      this.log("init");
    },

    /*
     * log
     *
     * Internal function for logging debug messages
     */
    log : function (message) {
      if (!this._debug)
        return;
      dump("FormAutoCompleteChild: " + message + "\n");
    },

    autoCompleteSearch : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult) {
      // This function is deprecated
    },

    autoCompleteSearchAsync : function (aInputName, aUntrimmedSearchString, aField, aPreviousResult, aListener) {
      this.log("autoCompleteSearchAsync");

      if (this._pendingSearch) {
        this.stopAutoCompleteSearch();
      }

      let rect = BrowserUtils.getElementBoundingScreenRect(aField);

      let window = aField.ownerDocument.defaultView;
      let topLevelDocshell = window.QueryInterface(Ci.nsIInterfaceRequestor)
                                   .getInterface(Ci.nsIDocShell)
                                   .sameTypeRootTreeItem
                                   .QueryInterface(Ci.nsIDocShell);

      let mm = topLevelDocshell.QueryInterface(Ci.nsIInterfaceRequestor)
                               .getInterface(Ci.nsIContentFrameMessageManager);

      mm.sendAsyncMessage("FormHistory:AutoCompleteSearchAsync", {
        inputName: aInputName,
        untrimmedSearchString: aUntrimmedSearchString,
        left: rect.left,
        top: rect.top,
        width: rect.width,
        height: rect.height
      });

      let search = this._pendingSearch = {};
      let searchFinished = message => {
        mm.removeMessageListener("FormAutoComplete:AutoCompleteSearchAsyncResult", searchFinished);

        // Check whether stopAutoCompleteSearch() was called, i.e. the search
        // was cancelled, while waiting for a result.
        if (search != this._pendingSearch) {
          return;
        }
        this._pendingSearch = null;

        let result = new FormAutoCompleteResult(
          null,
          [for (res of message.data.results) {text: res}],
          null,
          null
        );
        if (aListener) {
          aListener.onSearchCompletion(result);
        }
      }

      mm.addMessageListener("FormAutoComplete:AutoCompleteSearchAsyncResult", searchFinished);
      this.log("autoCompleteSearchAsync message was sent");
    },

    stopAutoCompleteSearch : function () {
      this.log("stopAutoCompleteSearch");
      this._pendingSearch = null;
    },
}; // end of FormAutoCompleteChild implementation

// nsIAutoCompleteResult implementation
function FormAutoCompleteResult (formHistory, entries, fieldName, searchString) {
    this.formHistory = formHistory;
    this.entries = entries;
    this.fieldName = fieldName;
    this.searchString = searchString;
}

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

    // private
    formHistory : null,
    entries : null,
    fieldName : null,

    _checkIndexBounds : function (index) {
        if (index < 0 || index >= this.entries.length)
            throw Components.Exception("Index out of range.", Cr.NS_ERROR_ILLEGAL_VALUE);
    },

    // 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,
    errorDescription : "",
    get defaultIndex() {
        if (this.entries.length == 0)
            return -1;
        else
            return 0;
    },
    get searchResult() {
        if (this.entries.length == 0)
            return Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
        return Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
    },
    get matchCount() {
        return this.entries.length;
    },

    getValueAt : function (index) {
        this._checkIndexBounds(index);
        return this.entries[index].text;
    },

    getLabelAt: function(index) {
        return getValueAt(index);
    },

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

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

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

    getFinalCompleteValueAt : function (index) {
        return this.getValueAt(index);
    },

    removeValueAt : function (index, removeFromDB) {
        this._checkIndexBounds(index);

        let [removedEntry] = this.entries.splice(index, 1);

        if (removeFromDB) {
          this.formHistory.update({ op: "remove",
                                    fieldname: this.fieldName,
                                    value: removedEntry.text });
        }
    }
};


if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT &&
    Services.prefs.getBoolPref("browser.tabs.remote.desktopbehavior", false)) {
  // Register the stub FormAutoComplete module in the child which will
  // forward messages to the parent through the process message manager.
  let component = [FormAutoCompleteChild];
  this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);
} else {
  let component = [FormAutoComplete];
  this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);
}
