/**
 * Function call cache implementation.
 *
 * Important data structure layouts for this class are documented here.
 *
 * Event Handlers and Data Tree:
 * -----------------------------
 *  this.events = Hash(
 *       'call_function_1' = Hash(
 *             0 = Hash(
 *                   'params' = Array(),   // parameters that were used in the function call
 *                   'events' = Hash(
 *                         'populate' = Array(
 *                              handler_1(),
 *                              handler_2(),
 *                              ...
 *                         ),
 *                         'update'       = Array( ... ),
 *                         'expire'       = Array( ... ),
 *                         'change'       = Array( ... )
 *                   ),
 *                   'data'    = Object    // data passed directly from the return of the function call
 *                   'expire'  = number    // timestamp that this data is no longer valid (UNIX Timestamp)
 *             ),
 *             1 = Hash(
 *                   ...
 *             ),
 *             ...
 *       ),
 *       'call_function_2' = Hash(
 *       ),
 *       ...
 *  )
 *
 * @author Justin DeMaris <justin@zentific.com>
 */
var CallCache = new Class({
	/**
	 * Constructor for the class. Sets up variables and prepares the cache for use.
	 *
	 * Required format for the call_function is a function that accepts these parameters in
	 * this order:
	 *
	 * - call_name      - Name of the function to call
	 * - call_params    - Array of the parameters to pass to the function being called
	 * - result_handler - Reference to the function to pass the results to. Function accepts a single Hash
	 * - error_handler  - Reference to the function to pass errors to if they occur. Function accepts a single Hash
	 *
	 * @param function call_function reference to the function to call when making the actual call.
	 * @param number   default_time  default cache life of an entry if none provided
	 * @param number   max_time      maximum cache life of any entry. If given > max, use max
	 */
	initialize: function(call_function, default_time, max_time) {
		this.call_function = call_function;
		this.default_time  = default_time;
		this.max_time      = max_time;
		this.cache         = new Hash();	// dictionary of stored data
	},

	/**
	 * If the call_params is null, then this event applies to all of the calls to the given 
	 * function name. Stores the references in the proper location in the event tree for
	 * later action.
	 *
	 * @param string   event_name   populate, update, expire, change
	 * @param string   call_name    Name of the function to call
	 * @param array    call_params  Parameters to pass to the function being called
	 * @param function handler      Reference to the function to call when the event happens 
	 */
	addEvent: function(event_name, call_name, call_params, handler) {
		var tag = this.findByTag(call_name, call_params, true, true);
		tag.get('events').get(event_name).extend([handler]);
	},

	/**
	 * Recursively compares the two arrays to check if they contain identical data.
	 *
	 * This should work fine because even the complex data types are reducable to hashes or arrays
	 * of primitive types.
	 *
	 * @param array    call_params_a The first array to check
	 * @param array    call_params_b The second array to check
	 */
	areParamsEqual: function(call_params_a, call_params_b) {
		if ( call_params_a == null && call_params_b == null )
			return true;
			
		if ( call_params_a == null && call_params_b != null )
			return false;
		if ( call_params_a != null && call_params_b == null )
			return false;

		if ( call_params_a.length != call_params_b.length )
			return false;

		var current_key = false;
		var is_match = true;
		call_params_a.each(function(value, key) {
			// if the types don't match for any element, they don't match
			if ( typeof value != typeof call_params_b[key] ) {
				is_match = false;
				return;
			}

			// if the types are more complicated, compare their sub-items
			if ( typeof value == 'object' && typeof value == typeof call_params_b[key] ) {
				var state = this.areParamsEqual(value, call_params_b[key]);
				if ( !state ) {
					is_match = false;
					return;
				}
			}

			// if the types are simple, just compare them
			else if ( call_params_b[key] != value ) {
				is_match = false;
				return;
			}
		});

		return is_match;
	},

	/**
	 * Calls the function and stores the results into cache before providing them to the actual handlers. If
	 * the result is already in the cache, then find it and return it instead.
	 *
	 * @param string   call_name     Name of the function to call
	 * @param array    call_params   Parameters to pass to the function being called
	 * @param function handler       Reference to the function to call when the data returns
	 * @param function error_handler Reference to the function to call when an error / exception occurs
	 * @param number   lifetime      OPTIONAL. How long the data can be stored before it expires (seconds)
	 */
	call: function(call_name, call_params, handler, error_handler, lifetime) {
		// check if the data is cached
		if ( lifetime == undefined ) lifetime = this.default_time;
		var currentTime = Math.round(new Date().getTime()/1000.0);
		var tag = this.findByTag(call_name, call_params, true);
		
		// if it is not yet cached, or the cache has expired...
		var is_error = false;
		if ( tag.get('expire') == null || tag.get('expire') < currentTime ) {
			var is_update = true;
			var original_data = tag.get('data');
			var is_change = true;
			var is_populate = true;

			// if it expired, trigger the expire event
			if ( tag.get('expire') != null ) {
				this.triggerEvent('expire', call_name, call_params);
				is_populate = false;
			}
			
			// either way, make the call and populate the entry
			var callSource = this.call_function;
			lifetime = Math.min(lifetime, this.max_time);
			var triggerEventPopulate = this.triggerEvent.bind(this, ['populate', call_name, call_params]);
			var triggerEventChange   = this.triggerEvent.bind(this, ['change', call_name, call_params]);
			var triggerEventUpdate   = this.triggerEvent.bind(this, ['update', call_name, call_params]);
			callSource(call_name, call_params, function(result) {
				tag.set('data', result);
				tag.set('expire', currentTime + lifetime);

				// trigger appropriate events
				if ( is_populate ) {
					triggerEventPopulate();
					triggerEventChange();
					triggerEventUpdate();
				} else if ( original_data != result ) {
					triggerEventChange();
					triggerEventUpdate();
				}

				// pass the data onward
				handler(result);
			}, function(error) {
				error_handler(error);
				is_error = true;
			});
		}

		// call the handler with the data if we got back valid data
		else {
			handler(tag.get('data'));
		}
	},

	/**
	 * Removes all of the handlers for the specified event. If call_params is null, then this will clear
	 * all events of the specified type that match the given call name.
	 *
	 * @param string    event_name   populate, update, expire, change. If null, then all events for given call.
	 * @param string    call_name    name of the function call that may have events tagged off of it
	 * @param array     call_params  parameters to match the cache tag with. If null, matches all with call_name and event_name
	 * @return number Number of events cleared
	 */
	clearEvents: function(event_name, call_name, call_params) {
		// find the match(es)
		var tags = this.findByTag(call_name, call_params, false);

		// no matches = no events to clear
		if ( tags == null )
			return 0;

		var event_count = 0;

		// if there are multiple entries, then clear events on each
		if ( call_params == null ) {
			tags.each(function(tag) {
				event_count++;
				if ( event_name == null ) {
					tag.get('events').get('populate').empty();
					tag.get('events').get('update').empty();
					tag.get('events').get('expire').empty();
					tag.get('events').get('change').empty();
				} else {
					tag.get('events').get(event_name).empty();
				}
			});
		}

		// or normal case = clear just one
		else {
			event_count++;
			if ( event_name == null ) {
					tags.get('events').get('populate').empty();
					tags.get('events').get('update').empty();
					tags.get('events').get('expire').empty();
					tags.get('events').get('change').empty();
			} else {
				tags.get('events').get(event_name).empty();
			}
		}

		return event_count;
	},

	/**
	 * Finds the tree entry corresponding to the given call_name and call_params and returns the reference
	 * to it.
	 *
	 * NOTE: If call_params is null, then this will usually return an array. However, if create_if_none is set to true,
	 * then this will return a single Hash with the call_params field set to null instead. This is to support the addEvent
	 * method.
	 *
	 * @param   string  call_name      Name of the function to search by
	 * @param   array   call_params    Parameters to search for tag by. If null, then returns all entries for call_name
	 * @param   boolean create_if_none Create a blank stub if none exists? Defaults to false.
	 * @param   boolean explicit       If true, then null matches an entry with params null, else null is wildcard
	 * @return  Array | Hash           The specific entry if it exists and call_params provided, null if none found, or array of Hashes if call_params = null
	 */
	findByTag: function(call_name, call_params, create_if_none, explicit) {
		if ( explicit == undefined ) explicit = false;

		// short circuit for base case
		if ( !this.cache.has(call_name) ) {
			if ( create_if_none ) {
				this.cache.set(call_name, new Hash());
			} else {
				return null;
			}
		}

		// find the tag
		var results = null;
		if ( call_params == null && !explicit )
			results = new Array();

		var param_set_index_ct = 0;
		var is_valid = false;
		var bindObj = this;

		var done = false;
		this.cache.get(call_name).each(function(param_set_index, key) {
			if ( !done) {
				var fBound = bindObj.areParamsEqual.bind(bindObj, [param_set_index.get('params'), call_params]);
				if ( call_params == null && !explicit ) {
					results.extend([param_set_index]);
					is_valid = true;
				} else if ( fBound() ) {
					is_valid = true;
					results = param_set_index;
					done = true;
				}
				param_set_index_ct++;
			}
		});

		if ( is_valid ) {
			return results;
		}

		// if the tag section does not yet exist and we are supposed to make it, then create the template for it
		if ( create_if_none ) {
			var baseLine = new Hash({
				'params': call_params,
				'events': new Hash({
					'populate':     new Array(),
					'update':       new Array(),
					'expire':       new Array(),
					'change':       new Array()
				}),
				'data': null,
				'expire': null
			});
			this.cache.get(call_name).set(param_set_index_ct, baseLine);

			// and return the template
			return baseLine;
		}

		// otherwise, we're supposed to inform the caller that it does not exist
		else {
			return null;
		}
	},

	/**
	 * Gets the current contents of the cache for the provided tag
	 *
	 * @param  string call_name    Name of the function being cached
	 * @param  array  call_params  Parameters to use as the specific tag for the event
	 * @return array  Hash of the data stored in the cache for this tag set
	 */
	getData: function(call_name, call_params) {
		var tag = this.findByTag(call_name, call_params, false);
		var data = null;
		if ( tag != null ) {
			if ( call_params != null ) {
				data = tag.get('data');
			} else {
				tag.each(function(t) {
					data = t.get('data');
				});
			}
		}
		return data;
	},

	/**
	 * Returns the array containing all of the handlers for a particular event
	 *
	 * @todo When implementing this, make sure to do a merge across all matching arrays in the tree for wildcards
	 *
	 * @param string    event_name   populate, update, expire, change. If null, then all events for given call.
	 * @param string    call_name    name of the function call that may have events tagged off of it
	 * @param array     call_params  parameters to match the cache tag with. If null, matches all with call_name and event_name
	 * @return array function Array of all of the handlers for the specified event
	 */
	getEvents: function(event_name, call_name, call_params) {
		var events = new Array();

		var tag = this.findByTag(call_name, call_params, false);
		if ( tag != null ) {
			events.extend(tag.get('events').get(event_name));
		}

		return events;
	},

	/**
	 * Checks if we have any data at all for the given tag.
	 * 
	 * call_params cannot be null here, since no wildcards are allowed. To indicate that no parameters
	 * were passed, pass an empty array (e.g. [])
	 *
	 * @param  string  call_name    Name of the function to check the cache for
	 * @param  array   call_params  Parameters to use as a tag when checking the cache
	 * @return boolean True if there is a cache entry for this tag, False otherwise
	 */
	isCached: function(call_name, call_params) {
		var tag = this.findByTag(call_name, call_params, false);
		
		if ( tag == null ) return false;
		
		if ( tag.get('expire') != null ) return true;

		return false;
	},

	/**
	 * Checks if the data for the given cache tag is expired or not. If it doesn't exist, then it returns true.
	 *
	 * call_params cannot be null here, since no wildcards are allowed. To indicate that no parameters
	 * were passed, pass an empty array (e.g. [])
	 *
	 * @param  string  call_name    Name of the function to check expiration time of
	 * @param  array   call_params  Parameters to use as the tag for identifying which cache entry
	 * @return boolean True if the data doesn't exist or is expired. False if it is valid.
	 */
	isExpired: function(call_name, call_params) {
		var tag = this.findByTag(call_name, call_params, false);
		var currentTime = Math.round(new Date().getTime()/1000.0);
		return (tag.get('expire') == null || tag.get('expire') < currentTime );
	},

	/**
	 * Forces the data in the given section to expire even if the timeout hasn't ended for it
	 * yet. If call_params is not provided, then it will force an expiration on all of the data
	 * for the given function.
	 *
	 * @param string call_name    Name of the function to force an expiry on
	 * @param array  call_params  Parameters to use as the tag for the expiration force
	 */
	manualExpire: function(call_name, call_params) {
		var expire_count = 0;
		var tag = this.findByTag(call_name, call_params, false);
		if ( call_params != null ) {
			tag.set('expire', null);
			expire_count++;
		} else {
			tag.each(function(t) {
				t.set('expire', null);
				expire_count++;
			});
		}
		return expire_count;
	},

	/**
	 * This is called internally to call handlers when an event happens
	 *
	 * @param string event_name   populate, update, expire, change
	 * @param string call_name    Name of the function to trigger the event for
	 * @param array  call_params  Parameters to use as a tag for which event gets triggered
	 */
	triggerEvent: function(event_name, call_name, call_params) {
		var events = this.getEvents(event_name, call_name, call_params);
		events.each(function(handler) {
			handler();
		});
	}
});
