// Copyright 2013 The Closure Library Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (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.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview Matcher which maintains a client-side cache on top of some
 * other matcher.
 * @author reinerp@google.com (Reiner Pope)
 */


goog.provide('goog.ui.ac.CachingMatcher');

goog.require('goog.array');
goog.require('goog.async.Throttle');
goog.require('goog.ui.ac.ArrayMatcher');
goog.require('goog.ui.ac.RenderOptions');



/**
 * A matcher which wraps another (typically slow) matcher and
 * keeps a client-side cache of the results. For instance, you can use this to
 * wrap a RemoteArrayMatcher to hide the latency of the underlying matcher
 * having to make ajax request.
 *
 * Objects in the cache are deduped on their stringified forms.
 *
 * Note - when the user types a character, they will instantly get a set of
 * local results, and then some time later, the results from the server will
 * show up.
 *
 * @constructor
 * @param {!Object} baseMatcher The underlying matcher to use. Must implement
 *     requestMatchingRows.
 * @final
 */
goog.ui.ac.CachingMatcher = function(baseMatcher) {
  /** @private {!Array<!Object>}} The cache. */
  this.rows_ = [];

  /**
   * Set of stringified rows, for fast deduping. Each element of this.rows_
   * is stored in rowStrings_ as (' ' + row) to ensure we avoid builtin
   * properties like 'toString'.
   * @private {Object<string, boolean>}
   */
  this.rowStrings_ = {};

  /**
   * Maximum number of rows in the cache. If the cache grows larger than this,
   * the entire cache will be emptied.
   * @private {number}
   */
  this.maxCacheSize_ = 1000;

  /** @private {!Object} The underlying matcher to use. */
  this.baseMatcher_ = baseMatcher;

  /**
   * Local matching function.
   * @private {function(string, number, !Array<!Object>): !Array<!Object>}
   */
  this.getMatchesForRows_ = goog.ui.ac.ArrayMatcher.getMatchesForRows;

  /** @private {number} Number of matches to request from the base matcher. */
  this.baseMatcherMaxMatches_ = 100;

  /** @private {goog.async.Throttle} */
  this.throttledTriggerBaseMatch_ =
      new goog.async.Throttle(this.triggerBaseMatch_, 150, this);

  /** @private {string} */
  this.mostRecentToken_ = '';

  /** @private {Function} */
  this.mostRecentMatchHandler_ = null;

  /** @private {number} */
  this.mostRecentMaxMatches_ = 10;

  /**
   * The set of rows which we last displayed.
   *
   * NOTE(reinerp): The need for this is subtle. When a server result comes
   * back, we don't want to suddenly change the list of results without the user
   * doing anything. So we make sure to add the new server results to the end of
   * the currently displayed list.
   *
   * We need to keep track of the last rows we displayed, because the "similar
   * matcher" we use locally might otherwise reorder results.
   *
   * @private {Array<!Object>}
   */
  this.mostRecentMatches_ = [];
};


/**
 * Sets the number of milliseconds with which to throttle the match requests
 * on the underlying matcher.
 *
 * Default value: 150.
 *
 * @param {number} throttleTime .
 */
goog.ui.ac.CachingMatcher.prototype.setThrottleTime = function(throttleTime) {
  this.throttledTriggerBaseMatch_ =
      new goog.async.Throttle(this.triggerBaseMatch_, throttleTime, this);
};


/**
 * Sets the maxMatches to use for the base matcher. If the base matcher makes
 * AJAX requests, it may help to make this a large number so that the local
 * cache gets populated quickly.
 *
 * Default value: 100.
 *
 * @param {number} maxMatches The value to set.
 */
goog.ui.ac.CachingMatcher.prototype.setBaseMatcherMaxMatches = function(
    maxMatches) {
  this.baseMatcherMaxMatches_ = maxMatches;
};


/**
 * Sets the maximum size of the local cache. If the local cache grows larger
 * than this size, it will be emptied.
 *
 * Default value: 1000.
 *
 * @param {number} maxCacheSize .
 */
goog.ui.ac.CachingMatcher.prototype.setMaxCacheSize = function(maxCacheSize) {
  this.maxCacheSize_ = maxCacheSize;
};


/**
 * Sets the local matcher to use.
 *
 * The local matcher should be a function with the same signature as
 * {@link goog.ui.ac.ArrayMatcher.getMatchesForRows}, i.e. its arguments are
 * searchToken, maxMatches, rowsToSearch; and it returns a list of matching
 * rows.
 *
 * Default value: {@link goog.ui.ac.ArrayMatcher.getMatchesForRows}.
 *
 * @param {function(string, number, !Array<!Object>): !Array<!Object>}
 *     localMatcher
 */
goog.ui.ac.CachingMatcher.prototype.setLocalMatcher = function(localMatcher) {
  this.getMatchesForRows_ = localMatcher;
};


/**
 * Function used to pass matches to the autocomplete.
 * @param {string} token Token to match.
 * @param {number} maxMatches Max number of matches to return.
 * @param {Function} matchHandler callback to execute after matching.
 */
goog.ui.ac.CachingMatcher.prototype.requestMatchingRows = function(
    token, maxMatches, matchHandler) {
  this.mostRecentMaxMatches_ = maxMatches;
  this.mostRecentToken_ = token;
  this.mostRecentMatchHandler_ = matchHandler;
  this.throttledTriggerBaseMatch_.fire();

  var matches = this.getMatchesForRows_(token, maxMatches, this.rows_);
  matchHandler(token, matches);
  this.mostRecentMatches_ = matches;
};


/**
 * Adds the specified rows to the cache.
 * @param {!Array<!Object>} rows .
 * @private
 */
goog.ui.ac.CachingMatcher.prototype.addRows_ = function(rows) {
  goog.array.forEach(rows, function(row) {
    // The ' ' prefix is to avoid colliding with builtins like toString.
    if (!this.rowStrings_[' ' + row]) {
      this.rows_.push(row);
      this.rowStrings_[' ' + row] = true;
    }
  }, this);
};


/**
 * Checks if the cache is larger than the maximum cache size. If so clears it.
 * @private
 */
goog.ui.ac.CachingMatcher.prototype.clearCacheIfTooLarge_ = function() {
  if (this.rows_.length > this.maxCacheSize_) {
    this.rows_ = [];
    this.rowStrings_ = {};
  }
};


/**
 * Triggers a match request against the base matcher. This function is
 * unthrottled, so don't call it directly; instead use
 * this.throttledTriggerBaseMatch_.
 * @private
 */
goog.ui.ac.CachingMatcher.prototype.triggerBaseMatch_ = function() {
  this.baseMatcher_.requestMatchingRows(
      this.mostRecentToken_, this.baseMatcherMaxMatches_,
      goog.bind(this.onBaseMatch_, this));
};


/**
 * Handles a match response from the base matcher.
 * @param {string} token The token against which the base match was called.
 * @param {!Array<!Object>} matches The matches returned by the base matcher.
 * @private
 */
goog.ui.ac.CachingMatcher.prototype.onBaseMatch_ = function(token, matches) {
  // NOTE(reinerp): The user might have typed some more characters since the
  // base matcher request was sent out, which manifests in that token might be
  // older than this.mostRecentToken_. We make sure to do our local matches
  // using this.mostRecentToken_ rather than token so that we display results
  // relevant to what the user is seeing right now.

  // NOTE(reinerp): We compute a diff between the currently displayed results
  // and the new results we would get now that the server results have come
  // back. Using this diff, we make sure the new results are only added to the
  // end of the list of results. See the documentation on
  // this.mostRecentMatches_ for details

  this.addRows_(matches);

  var oldMatchesSet = {};
  goog.array.forEach(this.mostRecentMatches_, function(match) {
    // The ' ' prefix is to avoid colliding with builtins like toString.
    oldMatchesSet[' ' + match] = true;
  });
  var newMatches = this.getMatchesForRows_(
      this.mostRecentToken_, this.mostRecentMaxMatches_, this.rows_);
  newMatches = goog.array.filter(
      newMatches, function(match) { return !(oldMatchesSet[' ' + match]); });
  newMatches = this.mostRecentMatches_.concat(newMatches)
                   .slice(0, this.mostRecentMaxMatches_);

  this.mostRecentMatches_ = newMatches;

  // We've gone to the effort of keeping the existing rows as before, so let's
  // make sure to keep them highlighted.
  var options = new goog.ui.ac.RenderOptions();
  options.setPreserveHilited(true);
  this.mostRecentMatchHandler_(this.mostRecentToken_, newMatches, options);

  // We clear the cache *after* running the local match, so we don't
  // suddenly remove results just because the remote match came back.
  this.clearCacheIfTooLarge_();
};
