/*
	Package: Seymour
		Revision $Id: Seymour.js 9793 2009-09-21 18:16:44Z evan $

		Native javascript client-side frontend for Skinner webservice.  It uses no specific frameworks,
		and should be compatible with any framework.  As a result, however, its feature set is minimal.

		It does not use XHR for making service requests.  Instead, it dynamically adds a *script*
		element to the DOM structure, sourcing the Skinner webservice.  The service then returns
		the results wrapped in a compatible javascript callback method.
*/

/*
	Function: ZipCheck
		Generic interface function that can be used to create and attach a Seymour instance to an
		address form.

	Parameters:
		Parameters can be supplied positionally, or within a JSON object as named parameters.  The
		parameters may be IDs of the form field elements, or the elements themselves.  If no parameters
		are supplied, it will attempt to attach to a form in the page named *selfReg*, and will look
		for the form fields by their default field names.

	Returns:
		A new instance of the Seymour class.

	(start example)
	<!-- Positional parameters passing the field element ids -->
	<input type="text" id="zip" name="zipcode" onkeyup="ZipCheck('address', 'apt', 'city', 'state', 'zip')"/>

	<!-- Named parameters passing a form (functionally the same as an object containing the field elements) -->
	<input type="text" name="zipcode" onkeyup="ZipCheck(this.form)"/>

	<!-- No parameters, automatically find all fields in 'selfReg' form -->
	<form name="selfReg">
	<input type="text" name="zipcode" onkeyup="ZipCheck()"/>
	(end example)
*/
function ZipCheck(streetAddress, apartmentNumber, city, state, zipcode) {
	// information we'll need to instantiate our Seymour instance
	var addyForm = false;
	var addyFields = {};

	// determine named or positional parameters, and extend into existing hash
	switch( Seymour.$type(arguments[0]) ) {
		// named params
		case 'element':
			// *only* for form collections in ie, manually convert to collection of elements
			if(!arguments[0].hasOwnProperty) {
				for (var i=0; i < arguments[0].elements.length; i++) {
					var field = arguments[0].elements[i];
					addyFields[ Seymour.$getAttribute(field,'name') ] = field;
				}
				break;
			}
		case 'object': case 'hash':
			addyFields = arguments[0];
		break;
		// positional params
		default:
			addyFields = {
				'streetAddress'   : streetAddress,
				'apartmentNumber' : apartmentNumber,
				'city'            : city,
				'state'           : state,
				'zipcode'         : zipcode
			};
		break;
	}

	// no args provided
	if(arguments.length < 1) {
		// get form by default name
		addyForm = document.selfReg;

		// get fields via form by default names
		addyFields = Seymour._getFields(addyForm);
	}

	// get zipcode element
	zipcode = Seymour.$(addyFields['zipcode']);

	// get form if we haven't already from zipcode element
	if(!addyForm) addyForm = Seymour._getForm( zipcode );

	// if instance already attached, bail out
	if( Seymour.$retrieve(zipcode,'SeymourInstance') ) {
		return null;
	}

	// attach a new instance to given field (and return it for good measure)
	var instance = new Seymour({ 'form': addyForm, 'fields': addyFields });
	Seymour.$store(zipcode, 'SeymourInstance', instance);

	return instance;
}

/**
	The following properties and methods are defined in the class prototype, and are tied to an
	instance of the class
**/
var Seymour = function(classOptions) {

	// json hash of default options
	this.options = {
		// individual form field elements from whom requests are built, and to whom successful responses are written
		fields: {},
		// form element containing the form fields stored above
		form: null,
		// time (in milliseconds) allowed for a callback response, defaults to 5 seconds
		timeOut: 5000,
		// skinner web service to which all requests are sent, defaults to the load-balanced production instance.
		//url: '//10.0.0.139/cgi-bin/skinner.dll', // build-test skinner (on Tennessee)
		url: '//www.whitefence.com/scripts/server/skinner.php', // external skinner
                //url: '//www.whitefence.com/cgi-bin/skinner.dll', // build-night skinner
		// data saved from the most recent service response, used to prevent repeated identical requests.
		lastRequest: {},
		// values used to coordinate multiple callback requests and responses.
		transport: { id: -1, asset: false, timer: false }
	};

	/**
		Essentially a constructor, this method properly sets up the working instance, and attaches to
		the given form/field events.
	**/
	this.initialize = function(options) {
		var klass = this;

		// using defaults as a base, overwrite with any values provided by user
		Seymour.$combine(this.options, options);

		// if form but no fields, retrieve fields
		if(this.options.form && !this.options.fields) {
			this.options.fields = Seymour._getFields(this.options.form);
		}

		// and vice versa
		if(this.options.fields && !this.options.form) {
			this.options.form = Seymour._getForm(this.options.fields);
		}

		// objectify form and individual fields
		this.options.form = Seymour.$( this.options.form );
		Seymour.$each( this.options.fields, function(val, key) { klass.options.fields[key] = Seymour.$(val); } );

		// get the zipcode field
		var zipcode = this.options.fields['zipcode'];

		// disable autocomplete on it (for ie)
		Seymour.$setAttribute(zipcode, 'autocomplete','off');

		// attach this.start() to onKeyup event of zipcode field
		Seymour.$addEvent(
			zipcode,
			'keyup',
			function(ev){
				// dont trigger if key pressed was tab, enter, esc, or an arrow key
				var key = (ev.which) ? ev.which : ev.keyCode;
				switch(key) {
					case 9: case 13: case 27: case 37: case 38: case 39: case 40: break;
					default: klass.start();
				}
			}
		);
	};

	/**
		This method triggers validation of an address, first against the most recent response (to
		prevent repeated identical requests), then against a live service request.
	**/
	this.start = function() {
		var klass = this;

		// get zipcode, and ensure a value attribute is defined
		var zip = this.options.fields['zipcode'];
		if(Seymour.$getAttribute(zip,'value') == null) Seymour.$setAttribute(zip, 'value', '');

		// remove any non-digits, and ensure 5 digits in length
		var zipValue = Seymour.$getAttribute(zip,'value').replace(/\D+/, '');
		Seymour.$setAttribute(zip, 'value', zipValue);
		if(Seymour.$getAttribute(zip,'value').length < 5) return true;
		if(Seymour.$getAttribute(zip,'value').length > 5) Seymour.$setAttribute(zip, 'value', Seymour.$getAttribute(zip,'value').substr(0,5) );

		// extend name/value pairs of form fields into request object
		var requestFields = {};
		Seymour.$each(
			this.options.fields,
			function(value,name){
				// get value of field
				var fieldVal = Seymour.$getAttribute(value,'value');

				// if a hidden field
				if(Seymour.$getAttribute(value, 'type') == 'hidden') {
					// then blank it out before hand
					fieldVal = '';
					// as well as its counterpart in lastRequest
					if(name == 'apartmentNumber') klass.options.lastRequest['aptNumber'] = '';
					else klass.options.lastRequest[name] = '';
				}

				// assign field value
				requestFields[name] = fieldVal;
			}
		);

		// if no change from last request, abort request
		if( Seymour.$equals(this.options.lastRequest, requestFields) )
			return false;
		// otherwise, save for next request
		else
			this.options.lastRequest = requestFields;

		// add new requestor instance, prepare request hash including callback name and addy form fields
		Seymour.requestors.push(this);
		this.options.transport.id = Seymour.requestors.length - 1;
		var callback = 'Seymour.requestors['+this.options.transport.id+'].complete';
		var request = {
			'json': 'true',
			'callback': callback,
			'requestid': this.options.transport.id
		};
		Seymour.$combine( request, requestFields );

		// collapse request parameters into url-escaped query string
		var queryString = [];
		Seymour.$each(
			request,
			function(value, key){
				queryString.push(key + '=' + encodeURIComponent(value));
			}
		);
		queryString = queryString.join('&');

		// include script tag for address request into the document head
		var js = this.options.transport.asset = document.createElement('script');
		js.setAttribute('type', 'text/javascript');
		js.setAttribute('src', this.options.url + '?' + queryString );
		document.body.appendChild( js );

		// set a timeout in before which the request must succeed or be cancelled.
		this.options.transport.timer = window.setTimeout(callback+'({fields: { timedout: true, requestid: { value: \''+this.options.transport.id+'\'} } })', this.options.timeOut);

		return true;
	};

	/**
		This method is called automatically by a callback in each service response, or in the case
		that a callback times out.  If the callback returns a successfully validated address, the
		validated content is written back into the attached form.
	**/
	this.complete = function(response) {
		var klass = this;

		// if response's requestid doesnt match instance's requestid, end silently
		if(this.options.transport.id != response.fields.requestid.value) {
			return false;
		}

		// if response returned and script exists, dispose of asset script
		if(Seymour.$type(this.options.transport.asset) == 'element') {
			document.body.removeChild( this.options.transport.asset );
		}

		// if timeout, reset transport parameters and fire failure
		if(response.fields.timedout) {
			this.options.transport = {id: -1, asset: false, timer: false}; // reset transport
			return false;
		}

		// by this point, we can safely abort the timeout and set the requestor to null
		window.clearTimeout(this.options.transport.timer);
		Seymour.requestors[this.options.transport.id] = null;

		// if errorLevel less than 90, safely assume the addy check failed
		if(! (parseInt(response.fields.errorLevel.value) >= 90) ) {
			return false;
		}

		// correct form fields per the skinner response
		Seymour.$each(
			Seymour.fieldNames,
			function(formKey){
				// form field uses apartment, but response field uses apt
				var resKey = (formKey == 'apartmentNumber') ? 'aptNumber' : formKey;
				// replace form field value
				var field = klass.options.fields[formKey];
				Seymour.$setAttribute( field, 'value', response.fields[resKey]['value'] );
				// update lastRequest
				klass.options.lastRequest[resKey] = Seymour.$getAttribute(field, 'value');
			}
		);

		// execute any hooks
		Seymour.$each(
			Seymour.hooks,
			function(hook) {
				hook(response);
			}
		);
	};

	// introduce an artificial class constructor
	this.initialize(classOptions);
}

/**
	The following properties and methods are defined on the class definition, and not tied to any
	specific instance of the class.  In OOP terminology, they are static.
**/

// default names for form fields
Seymour.fieldNames = ['streetAddress', 'apartmentNumber', 'city', 'state', 'zipcode'];
// array used to store class instances, so that callbacks can call the appropriate instance
Seymour.requestors = [];
// json array of hooked events to be run on complete
Seymour.hooks = [];

/**
	Register a new event hook to be called when Seymour completes.	Each event will be passed a
	JSON object containing Skinner data.
**/
Seymour.register = function(hook) {
	Seymour.hooks.push(hook);
}

/**
	Given a form field or group of form fields, this method will return the parent form.
**/
Seymour._getForm = function(field) {
	// if presumably a collection...
	switch(Seymour.$type(field)) {
		case 'array': case 'object': case 'hash':
			// ...grab first item from it
			var fieldSet = false;
			Seymour.$each(field, function(value){ if(fieldSet == false) {field = value; fieldSet = true;} })
		break;
	}

	// return parent form
	return Seymour.$(field).form;
};

/**
	Given a form element, this method will return a JSON object of the relevant child fields.
**/
Seymour._getFields = function(form) {
	// objectify form (if needed), create fields hash
	var fields = {};

	// get fields via form by default names
	Seymour.$each( Seymour.fieldNames, function(field) { fields[field] = form[field]; } );

	// return fields hash
	return fields;
};

/**
	These methods are mostly framework-independant reimplementations of commonly used utility
	functions.  Many of them are shamelessly based on Mootools.
**/

/**
	A wrapper method for retrieving an element.  Accepts an element id as a string, or an actual
	element.
*/
Seymour.$ = function(identifier) {
	switch(Seymour.$type(identifier)) {
		case 'element': case 'textnode': case 'whitespace':
			return identifier;
			break;
		case 'string':
			return document.getElementById(identifier);
			break;
		default:
			return null;
			break;
	}
};

/**
	Method for improved type determination, builds off of the built-in typeof.
**/
Seymour.$type = function(obj) {
	if (obj == undefined) return false;
	if (obj.$family) return (obj.$family.name == 'number' && !isFinite(obj)) ? false : obj.$family.name;
	if (obj.nodeName){
		switch (obj.nodeType){
			case 1: return 'element';
			case 3: return (/\S/).test(obj.nodeValue) ? 'textnode' : 'whitespace';
		}
	} else if (typeof obj.length == 'number'){
		if (obj.callee) return 'arguments';
		else if (obj.item) return 'collection';
	}
	return typeof obj;
};

/**
	Retrieves an attribute by name from an element.  If attribute doesnt exist, creates it as a
	blank string.
**/
Seymour.$getAttribute = function(element, name) {
	if(element.getAttribute(name) == null) element.setAttribute(name, '');
	return (element[name] !== null) ? element[name] : element.getAttribute(name);
};

/**
	Writes an attribute by name to an element.
**/
Seymour.$setAttribute = function(element, name, value) {
	if(element.getAttribute(name) == null) element.setAttribute(name, '');
	element.setAttribute(name, value);
	element[name] = value;
};

/**
	An implementation of an each loop that properly handles JSON arrays and objects and supports
	binding.
**/
Seymour.$each = function(iterable, fn, bind) {
	var type = Seymour.$type(iterable);
	if(type == 'arguments' || type == 'collection' || type == 'array') {
		for (var i = 0, l = iterable.length; i < l; i++)
			fn.call(bind, iterable[i], i, iterable);
	}
	else {
		for (var key in iterable){
			if (iterable.hasOwnProperty(key))
				fn.call(bind, iterable[key], key, iterable);
		}
	}
};

/**
	Combines two JSON objects, overwriting values in the first with the second, if necessary.
**/
Seymour.$combine = function(base, addtl) {
	Seymour.$each(
		addtl,
		function(val, field) {
			base[field] = val;
		}
	);
};

/**
	Compares two JSON objects for equality at a per-item level.  If a key doesn't exist in both, or
	if a value doesn't match, the equality test will fail.
**/
Seymour.$equals = function(prev, current){
	// first, lets check that they're both json hashes
	if(Seymour.$type(prev) != 'object' || Seymour.$type(current) != 'object') return false;

	// start off hoping they are equal
	var isEqual = true;

	// compare in one direction, short circuiting if we find one missing
	Seymour.$each(prev, function(value, name){
		if(current[name] == null) current[name] = '';
		if(current[name] != prev[name]) isEqual = false;
	});
	if(isEqual == false) return false;

	// now, compare in the other direction, again short circuiting
	Seymour.$each(current, function(value,name){
		if(prev[name] == null) prev[name] = '';
		if(prev[name] != current[name]) isEqual = false;
	});
	if(isEqual == false) return false;

	// if we got this far, we're good
	return true;
};

/**
	Generic cross-browser method of attaching events to elements.
**/
Seymour.$addEvent = function(obj, type, fn) {
	if (obj.addEventListener)
		obj.addEventListener( type, fn, false );
	else if (obj.attachEvent)
	{
		obj["e"+type+fn] = fn;
		obj[type+fn] = function() { obj["e"+type+fn]( window.event ); }
		obj.attachEvent( "on"+type, obj[type+fn] );
	}
};

/**
	Generic cross-browser method of removing events from elements.
**/
Seymour.$removeEvent = function( obj, type, fn )
{
	if (obj.removeEventListener)
		obj.removeEventListener( type, fn, false );
	else if (obj.detachEvent)
	{
		obj.detachEvent( "on"+type, obj[type+fn] );
		obj[type+fn] = null;
		obj["e"+type+fn] = null;
	}
};

/**
	Method for associating a value with an element, without actually attaching it to the DOM node.
	This is to prevent memory leaks in certain browsers.
**/
Seymour.$store = function(element, name, value) {
	// uniqeuly identify the element
	var hash = Seymour.$hash(element);

	// ensure register exists
	if(!Seymour.$$register) Seymour.$$register = {}

	// ensure element exists in register
	if(!Seymour.$$register[hash]) Seymour.$$register[hash] = {};

	// store name-value pair in element register
	Seymour.$$register[hash][name] = value;
};

/**
	Method for retrieving a value from an element, that was assigned with the above method.
**/
Seymour.$retrieve = function(element, name) {
	// uniqeuly identify the element
	var hash = Seymour.$hash(element);

	// if register exists
	if(Seymour.$$register)
		// and if element exists in register
		if(Seymour.$$register[hash])
			// and if name-value pair exists in element register
			if(Seymour.$$register[hash][name])
				// return it
				return Seymour.$$register[hash][name];

	// otherwise return nothing
	return null;
};

/**
	Method for uniquely identifying an element within the page
**/
Seymour.$hash = function(element) {
	// make sure its a valid element
	element = Seymour.$(element);
	if(element == null) return null;

	// set up loop to recurse through parentNodes
	var next_parent = element;
	var selector = '';

	while(next_parent.parentNode) {
		// append seperator (if needed) and tag name
		if(selector.length > 0) { selector += '>'; }
		selector += next_parent.nodeName;
		
		// append sourceIndex if it exists (or create reasonable facsimile for FF, Safari, Chrome, etc)
		if(Seymour.$getAttribute(next_parent, 'sourceIndex') == null) {
			var indexCount = 0;
			Seymour.$each( next_parent.parentNode.childNodes, function(val) {
				if(val.nodeType == 1) { Seymour.$setAttribute(val, 'sourceIndex', indexCount++); }
			} );
		}
		selector += '[' + Seymour.$getAttribute(next_parent, 'sourceIndex') + ']';
		
		// append id (if it exists)
		if(next_parent.id) { selector +=  '#' + next_parent.id; }
		
		// append any classnames
		if(next_parent.className) { selector += '.' + next_parent.className.split(/\s+/).join('.') }
		
		// move to next parent
		next_parent = next_parent.parentNode;
	}

	// return generated selector for use as a hash
	return selector;
}
