/*! dym.js ("Did you mean?")
 * Author: Gabe Berke-Williams (2008)
 * Description: dym.js is a drop-in (mostly) system for giving suggestions to
 *  users based on their input. It does not do auto-completion; rather it
 *  displays options that they can choose or click through.
 *  Auto-completion may be added at a later date.
 *  The callback should return data as a JSON array.
 * Dependencies: Requires Prototype (prototypejs.org).
 * For a non-minified version with comments, see: /js/dym.js
 */

// Options() holds options in a basic array with some helper functions.
// It implements an array pointer, curIndex. We can reuse
// an Options object by resetting curIndex.
function Options(option_arr){
    this.opts = option_arr;
    this.no_more_options = (this.opts.length === 0);
    this.curIndex = 0; // current option's index in opts[] 
}

Options.prototype.getOpt = function(){
    var retVal = null; // separate so we can increase curIndex before returning
    // return the next option
    if(! this.no_more_options) {
	retVal = this.opts[this.curIndex];
	// increase AFTER we get the option, not before.
	this.curIndex++;
	// after increasing curIndex, check if we have more options.
	if(this.curIndex >= this.opts.length) {
	    // we reached the end of the opts array. Set for next access.
	    this.no_more_options = true;
	}
    }
    return retVal;
};

// Only one DymCache exists at once.
// Each Dym instance uses it, thus if we have a lot of elements they can all
// be updated superfast because they share a cache.
// DymCache tracks which keys are being used by all Dym instances
// so we don't get rid of them. If we were to get rid of them,
// Dym.reset and Dym.getOpt for that instance would break.
//
// DymCache uses a Least-Recently-Used algorithm: new objects are pushed on
// until it reaches max_size (default 50) and after that each time
// an obj is pushed on, one is shifted off.
var DymCache = {
    max_size: 50,
    // DymCache.cache is an associative array of key-obj pairs.
    cache: { },
    // cache_keys keeps DymCache.cache's keys in chronological order.
    // It also provides a way to track the size of the cache, since
    // Array.length doesn't work with associative arrays.
    cache_keys: [ ],
    cache_keys_in_use: [],
    set_cache_size: function(max_size){
	      // if arg is supplied, set to that otherwise keep default
	      // Note that "if(max_size)" evaluates to false if max_size === 0.
	      // This is good: why have a cache at all if it's zero-length?
	      if(max_size){
		  DymCache.max_size = max_size;
	      }
	  },
    get: function(key){
	     var mykey;
	     if(mykey = DymCache.makeCacheKey(key)){
		 return DymCache.cache[key];
	     }
	 },
    // Tries to remove oldest non-in-use keys until cache size == max_size.
    trim_cache: function(){
		    var i,
			myCache; // current key when iterating on DC.cache_keys
		    if(DymCache.cache_keys.length <= DymCache.max_size){
			// We're under or at the limit. No need to trim.
			return;
		    }
		    if(DymCache.cache_keys_in_use.length == DymCache.cache_keys.length){
			// All keys are in use. :(
			return;
		    }
		    for(i=0; i < DymCache.cache_keys.length; i++){
			myKey = DymCache.cache_keys[i];
			if(DymCache.cache_keys_in_use.indexOf(myKey) == -1){
			    DymCache.cache_keys.splice(i, 1);
			}
			if(DymCache.cache_keys.length <= DymCache.max_size){
			    // We've trimmed the cache to max_size.
			    return;
			}
		    }
		},
    // Add a key-value to the cache. Automagically handles removing old keys.
    add: function(key, obj, dontMakeCache){
	     var mykey;
	     if(dontMakeCache !== true){
		 if( mykey !== DymCache.makeCacheKey(key) ){
		     return false;
		 }
	     }
	     if(DymCache.cache[mykey] === obj){
		 // If your callback always returns the same data for a
		 // given cachekey, then you can just check if
		 // cache_keys[key] !== undefined. It might give
		 // a performance improvement. I haven't tested it.
		 return;
	     }
	     if(DymCache.cache_keys.length >= DymCache.max_size){
		 // We don't have room to add a new key; try to remove old keys.
		 DymCache.trim_cache();
	     }
	     if(DymCache.cache_keys.length + 1 <= DymCache.max_size){
		 DymCache.cache_keys.push(mykey);
		 DymCache.cache[mykey] = obj;
	     } else {
		 // Reached the max cache size.
		 // Remove the oldest item that isn't in use, then add our item.
		 // We can't remove a key if it's in DymCache.cache_keys_in_use.
		 for(var i=0; i < DymCache.cache_keys.length; i++){
		     var x = DymCache.cache_keys[i];
		     if(! (DymCache.cache_keys[i] in DymCache.cache_keys_in_use)) {
			 var k = DymCache.cache_keys.splice(i, 1)[0];
			 delete DymCache.cache[ k ];
			 DymCache.add(mykey, obj, false); // pass true here if your makeCacheKey function doesn't return its output when fed its output;
			 return;
		     }
		 }
		 // Uh-oh, all cache keys are in use.
		 // This occurs if the number of *unique* inputs > max_size.
		 // We will add it to the cache anyway,
		 // and the next time add() is called it will try to clean up
		 // old items, hopefully trimming the cache down to max_size.
		 // The cache will thus expand to fit demand but if
		 // any cache keys aren't in use after we've
		 // expanded over max_size, they will be deleted by trim_cache()
		 // on the next call to add().
		 DymCache.cache_keys.push(mykey);
		 DymCache.cache[mykey] = obj;
	     }
	 },
    unregisterKey: function(key){
		       var mykey;
		       if(mykey = DymCache.makeCacheKey(key)){
			   if(DymCache.cache_keys_in_use.indexOf(mykey) !== -1){
			       // Only unregister if it's in use.
			       DymCache.cache_keys_in_use.splice(DymCache.cache_keys_in_use.indexOf(mykey), 1);
			   }
		       }
		   },
    registerKey: function(key){
		     var mykey = DymCache.makeCacheKey(key);
		     if(mykey !== false){
			 if( DymCache.cache_keys_in_use.indexOf(mykey) == -1 ){
			     DymCache.cache_keys_in_use.push(mykey);
			 }
		     }
		 },
    makeCacheKey: function(val){
		      /* This function is performed before adding, getting,
		       * registering, or unregistering a cachekey.
		       * Since add() is recursive, this function
		       * may be called on the same value more than once.
		       * If you want to avoid that, simply pass true
		       * as the third parameter of DymCache.add()
		       * (it's marked) or make this function
		       * so if given its output it returns that output;
		       * String.toUpperCase() is an example of a function
		       * that does this.
		       */
		      // Opera, at least, errors out if you try to pass my
		      // function val=undefined
		      // (e.g. val is coming from an empty <input>),
		      // so check to see that val is OK.
		      if(!val){ return false; }
		      // Call on a value to make it suitable for caching.
		      // This is user-defined; do whatever you want to the
		      // value to transform it into an appropriate cache key.
		      // PHP does strtolower for the input so we can do it for
		      // our cache key too, because if only case differs
		      // the response is the same.
		      // XXX
		      // You may want to change this,
		      // depending on your callback!
		      // XXX
		      var ckey = val.toLowerCase();
		      // trim whitespace.
		      ckey = ckey.replace(/^\s\s*/, '').replace(/\s\s*$/, '');
		      return ckey;
		  }
};

/*
 * Dym() takes care of all hiding/updating/showing.
 * For example, you can call reset() and it will reset the current
 * Options that it's pointing at. It acts like a cluster of Options
 * but gracefully (hopefully) abstracts away calls to Options.
 * NOTE:
 * Each Dym() should have unique input and refuse ids so that 
 * the input and refuse ids don't have multiple event handlers.
 */
function Dym(callback_url, input, output, refuse, loadText){
    this.curCacheKey = null; // key in DymCache for current Options obj.
    this.callback_url = callback_url; // where we get the suggestions from.
    this.elems = {
	input: $(input),   // element to get input from
	output: $(output), // element to update with the options
	refuse: $(refuse)  // element to click on to get next opt
    };
    // Text to display while we wait for Ajax.Request to finish./
    this.loadText = loadText || 'Loading...';
    // Keycodes to ignore on this.elems.input
    // Ignore all of the keys that are just movement, as that won't affect
    // what's in this.elems.input.
    this.ignore_keycodes = [Event.KEY_TAB, Event.KEY_RETURN, Event.KEY_ESC,
			    Event.KEY_LEFT, Event.KEY_RIGHT, Event.KEY_UP,
			    Event.KEY_DOWN, Event.KEY_HOME, Event.KEY_END,
			    Event.KEY_PAGEUP, Event.KEY_PAGEDOWN];
    
    this.elems.refuse.hide();
    this.timeout = null; // for timeouts on this.elems.input
    this.count = 0;
    /* We don't want >1 event handler for a given element and event type.
     * If we do, then all handlers fire when that event triggers.
     * So we use Dym(), which observes an element once (on object creation)
     * and passes the info to its cache of Options.
     * Otherwise we have multiple Options objects all looking at the same ids.
     * If the problem still exists, we can use our bound function (brefuse_fx)
     * and stop observing every time right before we observe.
     */
    // brefuse_fx is triggered when users refuse an option.
    this.brefuse_fx = function(){ this.nextOpt(); }.bindAsEventListener(this);
    // binput_fx is triggered when keyup happens on this.elems.input,
    // as long as that keycode isn't one of this.ignore_keycodes.
    this.binput_fx = function(e){
	if( this.ignore_keycodes.indexOf(e.keyCode) == -1 ){
	    if(this.timeout){
		window.clearTimeout(this.timeout);
	    }
	    // 400 feels good when I type. For slower typers, there's a cache.
	    // For faster typers, well, that's why we set a timeout.
	    this.timeout = window.setTimeout(function(){this.send()}.bind(this), 400);
	}
    }.bindAsEventListener(this);
    // bselect_fx is triggered when users click on a suggestion.
    this.bselect_fx = function(txt){ this.elems.input.value = txt }.bindAsEventListener(this);
    this.elems.refuse.observe('click', this.brefuse_fx);
    this.elems.input.observe('keyup', this.binput_fx);
}

Dym.prototype.setCurCacheKey = function(key){
    if( this.curCacheKey !== null ){
	// Only unregister if it's valid.
	// null is curCacheKey's initial value and so we should not
	// unregister it. If we do, DymCache.num_cache_keys_in_use
	// gets out of wack.
	DymCache.unregisterKey(this.curCacheKey);
    }
    this.curCacheKey = key;
    DymCache.registerKey(this.curCacheKey);
};

Dym.prototype.restart = function(){
    // Use restart for cached Options.
    // It resets their array pointers so they can be reused.
    this.reset();
    this.start();
};

Dym.prototype.start = function(){
    this.elems.refuse.show(); // this.hide() hides it.
    // Set initial option. Do after show()ing refuse since nextOpt may hide().
    this.nextOpt();
};

Dym.prototype.reset = function(){
    var o = DymCache.get(this.curCacheKey);
    o.curIndex = 0;
    o.no_more_options = (o.opts.length === 0);
};

Dym.prototype.hide = function(){
    this.elems.refuse.hide(); // hide the refuse element
    this.elems.output.update(); // clear any previous msg
};

Dym.prototype.nextOpt = function(){
    var link = '',
	o = DymCache.get(this.curCacheKey).getOpt();
    // getOpt returns null if it's out of options
    if( o === null ){
	// No more options.
	this.hide();
    } else {
	// We have an option to show.
	link = document.createElement("a");
	link.href="#";
	link.textContent = o;
	// onclick has to be wrapped so it doesn't immediately execute.
	// We return false so it doesn't follow the href.
	link.onclick = function(){ this.bselect_fx(o); return false; }.bindAsEventListener(this);
	this.elems.output.update( link );
    }
};

Dym.prototype.setLoading = function(){
    // Called before sending request so users know something is happening.
    this.elems.refuse.hide();
    this.elems.output.update(this.loadText);
};

Dym.prototype.send = function(){
    var cachekey = this.elems.input.value,
	that = this, // lexical closures ftw.
	params = {'in': cachekey},
	json; // JSON data returned from AJAX request
    // put in cache bucket if it's not already
    // We use input text instead of JSON as cache key
    // so that we don't have to make a request
    // to see if it's cached, thus removing the need for both
    // Ajax.Request and a new Options if it's cached.
    if(DymCache.get(cachekey) !== undefined){
	// It's cached.
	this.setCurCacheKey(cachekey);
	this.restart();
    } else {
	// Not cached.
	this.setLoading();
	// XXX Change params depending on what you pass to the callback XXX
	new Ajax.Request(that.callback_url, {
	    method: 'get',
	    parameters: params,
	    onSuccess: function(transport) {
		try {
		    json = transport.responseText.evalJSON(); // object
		    that.setCurCacheKey(cachekey);
		    // put in cache bucket, since it's not yet cached
		    DymCache.add(cachekey, new Options(json));
		    // we do start() here, not after the if/else loop,
		    // because it's ASYNCHRONOUS and therefore does not
		    // halt execution, so the "if" loop will go through
		    // before DymCache[cachekey] is set.
		    that.start();
		} catch(e) {
		    needs_firebug( console.log )("Failed: " + e);
		    return false;
		}
	    },
	    onFailure: function(transport) { 
		this.elems.output.update("There was a problem with the request. Try again. (Error code: " + transport.getStatus() + ' ' + transport.getStatusText() + ")");
	    }
	});
    }
    // for form onsubmit; if onsubmit is fed false, it doesn't submit.
    return false;
};

