/**
 * @file Manages asynchronous method response cache
 * @author Shinichi Tomita <shinichi.tomita@gmail.com>
 */

'use strict';

var events = require('events'),
    inherits = require('inherits'),
    _      = require('lodash/core');

/**
 * Class for managing cache entry
 *
 * @private
 * @class
 * @constructor
 * @template T
 */
var CacheEntry = function() {
  this.fetching = false;
};

inherits(CacheEntry, events.EventEmitter);

/**
 * Get value in the cache entry
 *
 * @param {Callback.<T>} [callback] - Callback function callbacked the cache entry updated
 * @returns {T|undefined}
 */
CacheEntry.prototype.get = function(callback) {
  if (!callback) {
    return this._value;
  } else {
    this.once('value', callback);
    if (!_.isUndefined(this._value)) {
      this.emit('value', this._value);
    }
  }
};

/**
 * Set value in the cache entry
 *
 * @param {T} [value] - A value for caching
 */
CacheEntry.prototype.set = function(value) {
  this._value = value;
  this.emit('value', this._value);
};

/**
 * Clear cached value
 */
CacheEntry.prototype.clear = function() {
  this.fetching = false;
  delete this._value;
};


/**
 * Caching manager for async methods
 *
 * @class
 * @constructor
 */
var Cache = function() {
  this._entries = {};
};

/**
 * retrive cache entry, or create if not exists.
 *
 * @param {String} [key] - Key of cache entry
 * @returns {CacheEntry}
 */
Cache.prototype.get = function(key) {
  if (key && this._entries[key]) {
    return this._entries[key];
  } else {
    var entry = new CacheEntry();
    this._entries[key] = entry;
    return entry;
  }
};

/**
 * clear cache entries prefix matching given key
 * @param {String} [key] - Key prefix of cache entry to clear
 */
Cache.prototype.clear = function(key) {
  for (var k in this._entries) {
    if (!key || k.indexOf(key) === 0) {
      this._entries[k].clear();
    }
  }
};

/**
 * create and return cache key from namespace and serialized arguments.
 * @private
 */
function createCacheKey(namespace, args) {
  args = Array.prototype.slice.apply(args);
  return namespace + '(' + _.map(args, function(a){ return JSON.stringify(a); }).join(',') + ')';
}

/**
 * Enable caching for async call fn to intercept the response and store it to cache.
 * The original async calll fn is always invoked.
 *
 * @protected
 * @param {Function} fn - Function to covert cacheable
 * @param {Object} [scope] - Scope of function call
 * @param {Object} [options] - Options
 * @return {Function} - Cached version of function
 */
Cache.prototype.makeResponseCacheable = function(fn, scope, options) {
  var cache = this;
  options = options || {};
  return function() {
    var args = Array.prototype.slice.apply(arguments);
    var callback = args.pop();
    if (!_.isFunction(callback)) {
      args.push(callback);
      callback = null;
    }
    var key = _.isString(options.key) ? options.key :
              _.isFunction(options.key) ? options.key.apply(scope, args) :
              createCacheKey(options.namespace, args);
    var entry = cache.get(key);
    entry.fetching = true;
    if (callback) {
      args.push(function(err, result) {
        entry.set({ error: err, result: result });
        callback(err, result);
      });
    }
    var ret, error;
    try {
      ret = fn.apply(scope || this, args);
    } catch(e) {
      error = e;
    }
    if (ret && _.isFunction(ret.then)) { // if the returned value is promise
      if (!callback) {
        return ret.then(function(result) {
          entry.set({ error: undefined, result: result });
          return result;
        }, function(err) {
          entry.set({ error: err, result: undefined });
          throw err;
        });
      } else {
        return ret;
      }
    } else {
      entry.set({ error: error, result: ret });
      if (error) { throw error; }
      return ret;
    }
  };
};

/**
 * Enable caching for async call fn to lookup the response cache first, then invoke original if no cached value.
 *
 * @protected
 * @param {Function} fn - Function to covert cacheable
 * @param {Object} [scope] - Scope of function call
 * @param {Object} [options] - Options
 * @return {Function} - Cached version of function
 */
Cache.prototype.makeCacheable = function(fn, scope, options) {
  var cache = this;
  options = options || {};
  var $fn = function() {
    var args = Array.prototype.slice.apply(arguments);
    var callback = args.pop();
    if (!_.isFunction(callback)) {
      args.push(callback);
    }
    var key = _.isString(options.key) ? options.key :
              _.isFunction(options.key) ? options.key.apply(scope, args) :
              createCacheKey(options.namespace, args);
    var entry = cache.get(key);
    if (!_.isFunction(callback)) { // if callback is not given in last arg, return cached result (immediate).
      var value = entry.get();
      if (!value) { throw new Error('Function call result is not cached yet.'); }
      if (value.error) { throw value.error; }
      return value.result;
    }
    entry.get(function(value) {
      callback(value.error, value.result);
    });
    if (!entry.fetching) { // only when no other client is calling function
      entry.fetching = true;
      args.push(function(err, result) {
        entry.set({ error: err, result: result });
      });
      fn.apply(scope || this, args);
    }
  };
  $fn.clear = function() {
    var key = _.isString(options.key) ? options.key :
              _.isFunction(options.key) ? options.key.apply(scope, arguments) :
              createCacheKey(options.namespace, arguments);
    cache.clear(key);
  };
  return $fn;
};


module.exports = Cache;
