// 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 Provides a convenient API for data persistence with data
 * expiration and number of items limit.
 *
 * Setting and removing values keeps a max number of items invariant.
 * Collecting values can be user initiated. If oversize, first removes
 * expired items, if still oversize than removes the oldest items until a size
 * constraint is fulfilled.
 *
 */

goog.provide('goog.labs.storage.BoundedCollectableStorage');

goog.require('goog.array');
goog.require('goog.asserts');
goog.require('goog.iter');
goog.require('goog.storage.CollectableStorage');
goog.require('goog.storage.ErrorCode');
goog.require('goog.storage.ExpiringStorage');


goog.scope(function() {
var storage = goog.labs.storage;



/**
 * Provides a storage with bounded number of elements, expiring keys and
 * a collection method.
 *
 * @param {!goog.storage.mechanism.IterableMechanism} mechanism The underlying
 *     storage mechanism.
 * @param {number} maxItems Maximum number of items in storage.
 * @constructor
 * @extends {goog.storage.CollectableStorage}
 * @final
 */
storage.BoundedCollectableStorage = function(mechanism, maxItems) {
  storage.BoundedCollectableStorage.base(this, 'constructor', mechanism);

  /**
   * A maximum number of items that should be stored.
   * @private {number}
   */
  this.maxItems_ = maxItems;
};
goog.inherits(storage.BoundedCollectableStorage,
              goog.storage.CollectableStorage);


/**
 * An item key used to store a list of keys.
 * @const
 * @private
 */
storage.BoundedCollectableStorage.KEY_LIST_KEY_ = 'bounded-collectable-storage';


/**
 * Recreates a list of keys in order of creation.
 *
 * @return {!Array<string>} a list of unexpired keys.
 * @private
 */
storage.BoundedCollectableStorage.prototype.rebuildIndex_ = function() {
  var keys = [];
  goog.iter.forEach(this.mechanism.__iterator__(true), function(key) {
    if (storage.BoundedCollectableStorage.KEY_LIST_KEY_ == key) {
      return;
    }

    var wrapper;
    /** @preserveTry */
    try {
      wrapper = this.getWrapper(key, true);
    } catch (ex) {
      if (ex == goog.storage.ErrorCode.INVALID_VALUE) {
        // Skip over bad wrappers and continue.
        return;
      }
      // Unknown error, escalate.
      throw ex;
    }
    goog.asserts.assert(wrapper);

    var creationTime = goog.storage.ExpiringStorage.getCreationTime(wrapper);
    keys.push({key: key, created: creationTime});
  }, this);

  goog.array.sort(keys, function(a, b) {
    return a.created - b.created;
  });

  return goog.array.map(keys, function(v) {
    return v.key;
  });
};


/**
 * Gets key list from a local storage. If an item does not exist,
 * may recreate it.
 *
 * @param {boolean} rebuild Whether to rebuild a index if no index item exists.
 * @return {!Array<string>} a list of keys if index exist, otherwise undefined.
 * @private
 */
storage.BoundedCollectableStorage.prototype.getKeys_ = function(rebuild) {
  var keys = storage.BoundedCollectableStorage.superClass_.get.call(this,
      storage.BoundedCollectableStorage.KEY_LIST_KEY_) || null;
  if (!keys || !goog.isArray(keys)) {
    if (rebuild) {
      keys = this.rebuildIndex_();
    } else {
      keys = [];
    }
  }
  return /** @type {!Array<string>} */ (keys);
};


/**
 * Saves a list of keys in a local storage.
 *
 * @param {Array<string>} keys a list of keys to save.
 * @private
 */
storage.BoundedCollectableStorage.prototype.setKeys_ = function(keys) {
  storage.BoundedCollectableStorage.superClass_.set.call(this,
      storage.BoundedCollectableStorage.KEY_LIST_KEY_, keys);
};


/**
 * Remove subsequence from a sequence.
 *
 * @param {!Array<string>} keys is a sequence.
 * @param {!Array<string>} keysToRemove subsequence of keys, the order must
 *     be kept.
 * @return {!Array<string>} a keys sequence after removing keysToRemove.
 * @private
 */
storage.BoundedCollectableStorage.removeSubsequence_ =
    function(keys, keysToRemove) {
  if (keysToRemove.length == 0) {
    return goog.array.clone(keys);
  }
  var keysToKeep = [];
  var keysIdx = 0;
  var keysToRemoveIdx = 0;

  while (keysToRemoveIdx < keysToRemove.length && keysIdx < keys.length) {
    var key = keysToRemove[keysToRemoveIdx];
    while (keysIdx < keys.length && keys[keysIdx] != key) {
      keysToKeep.push(keys[keysIdx]);
      ++keysIdx;
    }
    ++keysToRemoveIdx;
  }

  goog.asserts.assert(keysToRemoveIdx == keysToRemove.length);
  goog.asserts.assert(keysIdx < keys.length);
  return goog.array.concat(keysToKeep, goog.array.slice(keys, keysIdx + 1));
};


/**
 * Keeps the number of items in storage under maxItems. Removes elements in the
 * order of creation.
 *
 * @param {!Array<string>} keys a list of keys in order of creation.
 * @param {number} maxSize a number of items to keep.
 * @return {!Array<string>} keys left after removing oversize data.
 * @private
 */
storage.BoundedCollectableStorage.prototype.collectOversize_ =
    function(keys, maxSize) {
  if (keys.length <= maxSize) {
    return goog.array.clone(keys);
  }
  var keysToRemove = goog.array.slice(keys, 0, keys.length - maxSize);
  goog.array.forEach(keysToRemove, function(key) {
    storage.BoundedCollectableStorage.superClass_.remove.call(this, key);
  }, this);
  return storage.BoundedCollectableStorage.removeSubsequence_(
      keys, keysToRemove);
};


/**
 * Cleans up the storage by removing expired keys.
 *
 * @param {boolean=} opt_strict Also remove invalid keys.
 * @override
 */
storage.BoundedCollectableStorage.prototype.collect =
    function(opt_strict) {
  var keys = this.getKeys_(true);
  var keysToRemove = this.collectInternal(keys, opt_strict);
  keys = storage.BoundedCollectableStorage.removeSubsequence_(
      keys, keysToRemove);
  this.setKeys_(keys);
};


/**
 * Ensures that we keep only maxItems number of items in a local storage.
 * @param {boolean=} opt_skipExpired skip removing expired items first.
 * @param {boolean=} opt_strict Also remove invalid keys.
 */
storage.BoundedCollectableStorage.prototype.collectOversize =
    function(opt_skipExpired, opt_strict) {
  var keys = this.getKeys_(true);
  if (!opt_skipExpired) {
    var keysToRemove = this.collectInternal(keys, opt_strict);
    keys = storage.BoundedCollectableStorage.removeSubsequence_(
        keys, keysToRemove);
  }
  keys = this.collectOversize_(keys, this.maxItems_);
  this.setKeys_(keys);
};


/**
 * Set an item in the storage.
 *
 * @param {string} key The key to set.
 * @param {*} value The value to serialize to a string and save.
 * @param {number=} opt_expiration The number of miliseconds since epoch
 *     (as in goog.now()) when the value is to expire. If the expiration
 *     time is not provided, the value will persist as long as possible.
 * @override
 */
storage.BoundedCollectableStorage.prototype.set =
    function(key, value, opt_expiration) {
  storage.BoundedCollectableStorage.base(
      this, 'set', key, value, opt_expiration);
  var keys = this.getKeys_(true);
  goog.array.remove(keys, key);

  if (goog.isDef(value)) {
    keys.push(key);
    if (keys.length >= this.maxItems_) {
      var keysToRemove = this.collectInternal(keys);
      keys = storage.BoundedCollectableStorage.removeSubsequence_(
          keys, keysToRemove);
      keys = this.collectOversize_(keys, this.maxItems_);
    }
  }
  this.setKeys_(keys);
};


/**
 * Remove an item from the data storage.
 *
 * @param {string} key The key to remove.
 * @override
 */
storage.BoundedCollectableStorage.prototype.remove = function(key) {
  storage.BoundedCollectableStorage.base(this, 'remove', key);

  var keys = this.getKeys_(false);
  if (goog.isDef(keys)) {
    goog.array.remove(keys, key);
    this.setKeys_(keys);
  }
};

});  // goog.scope
