/** 
 * @fileOverview Form validation.
 * @author Oliver Bishop / Tom McCourt
 * @version 0.0.27
 * @changeLog Added methods to the rule field.
 * @changeLog Fixed the configuration setup.
 */

/* This sets the global namespaces */
var UKISA = UKISA || {};
UKISA.widget = UKISA.widget || {};

/**
 * Create a form validation instance with localisation and fancy error messages.
 *
 * @constructor
 * @param config Custom configuration settings.
 * @param config.showAllErrors By default only the first error will show up. Set to true this will show all errors for a given field.
 */
UKISA.widget.FormValidation = function(el, options) {
	var i, instance, locale, Event;

	// Default configuration.
	this.config = {
		showAllErrors: false,
		debug: false,
		display: null,
		callback: null
	};

	// This hold all the form information.
	this.context = {
		form:  null, // The <form> element.
		log:   [],	 // Logging errors and messages.
		rules: {},	 // Holds all validation rules.
		errors: 0,	 // Total number of errors created.
		builder: null  // Used for the Validation Builder.
	};	

	// Get the form.
	el = document.getElementById(el);
	if (!el) {
		this.log("Cannot find form: " + el); 
		return; 
	};

	instance = this;
	Event = YAHOO.util.Event;
	this.context.form = el;

	Event.addListener(this.context.form, "submit", function(e) {
		var valid = instance.validate();

		if (valid) {
			instance.log("The form is valid");
			if (typeof instance.config.callback === "function") {
				instance.log("Callback: " + instance.config.callback);
				if (window.console) {
					console.info(instance.config.callback);
				}
				return instance.config.callback.call(instance, e);
			}
			return true;
		} else {
			instance.log("The form is NOT valid");
			Event.stopEvent(e);
			return false;
		} 
	}); 


	this.log("Found form: " + el.getAttribute("id"));

	if (typeof UKISA.locale !== "undefined") {
		// Get the l10n values if specified in the config.
		locale = UKISA.locale.get("widget.FormValidation");

		this.log("Found locale settings.");

		// Look for locale specific error messages.
		if (locale && locale.messages) {
			this.config.messages = locale.messages;
		} else {
			this.config.messages = UKISA.widget.FormValidation.messages;
		}

		// Look for locale specific error messages.
		if (locale && locale.display) {
			this.config.display = locale.display;
		}
	} else {
		this.log("No locale settings.");
		this.config.messages = UKISA.widget.FormValidation.messages;
	}

	// Update the default configuration.
	for (var i in options) {
		if (typeof this.config[i] !== "undefined") {
			this.config[i] = options[i];
		}
	}

	if (this.config.debug) {
		window.onbeforeunload = function() {
			return false;
		};
	}

	// Make a dummy error box to preload the error messages CSS images.
	var canvas, errorHeader, errorContent, errorFooter, close, closeLink;

	canvas = document.createElement("div");

	errorHeader = canvas.cloneNode(false);
	errorContent = canvas.cloneNode(false);
	errorFooter = canvas.cloneNode(false);

	canvas.className = "validation-error";
	canvas.style.position = "absolute";
	canvas.style.left = "-999em";

	errorHeader.className = "validation-error-header";
	errorContent.className = "validation-error-content";
	errorFooter.className = "validation-error-footer";

	close = document.createElement("p");
	close.className = "close";
	
	closeLink = document.createElement("a");

	close.appendChild(closeLink);
	canvas.appendChild(errorHeader);
	errorContent.appendChild(close);	
	canvas.appendChild(errorContent);
	canvas.appendChild(errorFooter);
	document.body.appendChild(canvas);

	// Put it on the page to have the styles applied and initiate the image requrests, then hide it.
	canvas.style.display = "none";

	// Now get rid of it.
	document.body.removeChild(canvas);
};

/**
 * Flag to determine if the form is valid.
 */
UKISA.widget.FormValidation.prototype.isValid = false;

UKISA.widget.FormValidation.prototype = {
	/**
	 * Disable a validation rule on an element.
	 */
	disable: function(el) {
		if (this.context.rules[el]) {
			this.context.rules[el]["active"] = false;
			this.log("Disabled element: " + el);
		} else {
			this.log("Cannot disable element: " + el);
		}
	},
	/**
	 * Reenable a validation field.
	 */
	enable: function(el) {
		if (this.context.rules[el]) {
			this.context.rules[el]["active"] = true;
			this.log("Enabled element: " + el);
		} else {
			this.log("Cannot enable element: " + el);
		}
	},
	/**
	 * Adds a validation rule to the collection.
	 */
	add: function(el) {
		var instance, builder;

		instance = this;

		builder = {
			is: function(rule, args) {
				if (instance.context.builder.el) {
					instance.context.builder.options[rule] = args;
					instance.context.builder.lastOption = rule;
				}

				instance.log("Validation builder: " + instance.context.builder.el + ", is: " + rule);

				return this;
			},
			andIs: function(rule, args) {
				this.is.apply(this, arguments);

				return this;
			},
			withMessage: function(msg) {
				if (typeof instance.context.builder.options.message === "undefined") {
					instance.context.builder.options.message = {}
				}
				instance.context.builder.options.message[instance.context.builder.lastOption] = msg;

				return this;
			}
		};

		if (!this.context.builder) {
			this.context.builder = {
				el: null,
				options: {},
				lastOption: ""
			};
		}

		// Add the new rule
		if (this.context.builder.el) {
			this.rule(this.context.builder.el, this.context.builder.options);
		}

		// Reset
		this.context.builder.el = el;
		this.context.builder.options = {};
		this.context.builder.lastOption = "";

		this.log("Validation builder: " + el);

		return builder;
	},
	/** 
     * Add a form input element to the validation collection.
	 *
	 * @returns {UKISA.widget.FormValidation} Returns instance of the FormValidation object for method chaining.
	 */
	rule: function(el, options) {
		var instance, Event, validate, node, nodeType, nodeCollection, i, event;

		// Expose object to closure
		instance = this;

		// Get YUI.
		Event = YAHOO.util.Event;

		// If there is no form then don't add any rules.
		if (!this.context.form) {
			return;
		}

		validate = function(e) {
			var event;
			e = e || window.event;
			event = (e.keyCode) ? e.keyCode : e.which;
			
			instance.log("KeyUp event: " + event);
			// Exclude tabs and shift tab
			if (event !== 9 && event !== 16) {
				instance.log("Validate from data change event: " + String.fromCharCode(event));
				instance.validate(el);
			}
		};

		if (this.context.form[el]) {
			this.log("Found form input: " + el);
			
			this.context.rules[el] = options;

			// Add the enable/disable flag.

			this.context.rules[el]["active"] = true;

			if (typeof this.context.form[el].nodeName !== "undefined") {
				// If the element is NOT a radio/checkbox
				node = this.context.form[el];
			} else {
				// If the element IS a radio/checkbox
				node = this.context.form[el][0];
			}

			if (typeof options.events !== "undefined") {
				
				for (event in options.events) {
					if (options.events.hasOwnProperty(event)) {
						if (event !== "submit") {
							Event.addListener(node, event, validate); 
						}
					}
				}

			} else {

				nodeType = node.getAttribute("type");

				// Sort out the event handlers to control when to validate the form values.
				switch (node.nodeName.toLowerCase()) {
					case "select":
						node.onchange = validate;
					break;
					case "input":
						if (nodeType === "checkbox" || nodeType === "radio") {
							nodeCollection = this.context.form[el];
				
							// A collection of radio/checkboxes a returned as an array and so we need to loop.
							if (typeof nodeCollection.length !== "undefined") {
								for (i = 0, ix = nodeCollection.length; i < ix; i++) {
									Event.addListener(nodeCollection[i], "click", validate); 
								}
							} else {
								// A sinlge checkbox (like a "I have read the T&Cs") return the HTMLObject.
								Event.addListener(nodeCollection, "click", validate);
							}
						} else if (nodeType === "password") {
							// To prevent annoyances and for comfirming 2 passwords.
							Event.addListener(node, "blur", validate);
						} else {
							// A normal textbox.
							//Event.addListener(node, "keyup", validate); Remove this for now until
							// I put in the events: {
							//		email: ["keyup", "blur"],
							//		required: "blur"
							// }

							// This is for the case when an option from autocomplete is selected as no other event is fired.
							Event.addListener(node, "blur", validate);
						}
					break;
					case "textarea":
						Event.addListener(node, "keyup", validate);

						// This is for the case when an option from autocomplete is selected as no other event is fired.
						Event.addListener(node, "blur", validate);
					break;
					default:
						this.log("Unkown element node type.");
					break;	
				}		
			}
		} else {
			this.log("Cannot find form input: " + el);
		}

		return this;
	},
	/**
	 * Validate the form.
	 *
	 * @returns {Boolean} If the form passes validation.
	 */
	validate: function(el) {
		var instance, validators, messages, validate;

		// If there is no form then don't add any rules.
		if (!this.context.form) {
			return true;
		}

		// Add the new rule for the Validation Builder.
		if (this.context.builder && this.context.builder.el) {
			this.rule(this.context.builder.el, this.context.builder.options);
		}

		// Expose to closure.
		instance = this;

		// Shorthand the validators.
		validators = UKISA.widget.FormValidation.validators;

		// Shorthand the validators.
		messages = this.config.messages;

		// Reset error count.
		this.context.errors = 0;

		// Validate the form input.
		validate = function(el) {
			var rule, options, input, errors, i, args, message, errorMessage;

			// Get the validation rules for a form element
			options = instance.context.rules[el]

			if (typeof options["active"] !== "undefined" && options["active"] == false) {
				instance.log("This element has been disabled: " + el);

				// Remove the error message if there is one.
				errorMessage = document.getElementById("error-" + instance.context.form.id + "-" + el);

				if (errorMessage) {
					errorMessage.parentNode.removeChild(errorMessage);
				}
				return;
			}

			input = instance.context.form[el];
			
			errors = [];

			i = 0;

			for (rule in options) {
				if (rule !== "messages" && rule !== "errors" && rule !== "events" && rule !== "display" && rule !== "active" && rule !== "onError" && rule !== "onSuccess") {
					
					if (typeof validators[rule] !== "undefined") {

						args = options[rule];
					
						if (validators[rule].call(instance, input, input.value, args)) {
							instance.log("Validating: " + el + " with rule: " + rule + " and status: passed");
						
							if (options["onSuccess"]) {
								options["onSuccess"].call(instance, input);
							}

						} else {
							instance.log("Validating: " + el + " with rule: " + rule + " and status: failed");

							if (typeof options.messages !== "undefined" && typeof options.messages[rule] !== "undefined") {
								message = options.messages[rule];
								instance.log("Using custom message: " + message);
							} else {
								if (typeof messages[rule] !== "undefined") {
									message = messages[rule];
								} else {
									instance.log("Default message not found");
								}
							}	
							
							// Store the error messages.
							errors.push(message.replace(/\{[0-9]\}/g, 
								function(match) {
									// Add one as the first argument is the placeholder string
									var i = parseInt(match.charAt(1));
									
									// Check that the array has the index
									if (typeof args === "object") {
										// Fixed this to not evaluate int 0 as false.
										if (typeof args[i] !== "undefined") {
											return args[i];
										} else {
											return "";
										}
									} else {
										return args;
									}
								}
							));

							// Increment error count.
							i++;
						}

					} else {
						instance.log("Cannot find validator: " + rule + " for: " + el);
					}

					// Exceptions here to change the events after the form element has been used for the first time.

					// These are exceptions for specific validation rules for better usability.
					if (rule === "compare") {
						instance.log("Exception for 'compare': add onkeychange");
						input.onkeyup = input.onblur;
					}		
				}
			}

			// Add the error messages and number.
			instance.context.rules[el].errors = {
				length: i,
				messages: errors
			}
			
			// Add to global error count.
			instance.context.errors += i;

			// Draw the error messages for the form input.
			instance.render(el);
		};
		
		if (el) {
			// Validate a single element.
			validate(el);
			return false;
		} else {
			// Loop throught and validate the entire validation group.
			for (el in this.context.rules) {
				validate(el);	
			}

			this.isValid = (this.context.errors === 0);
			return this.isValid;
		}			
	},
	render: function(el) {
		var error, errorId, errors, input, parent, canvas, errorHeader, errorFooter, errorContent, canvasList, canvasItem, message, canvasMessage, inputClip, canvasClip;

		// Shorthand the errors
		errors = this.context.rules[el].errors;

		// Get the form element.
		input = this.context.form[el];

		// If nodeName is not defined then the element is a radio/checkbox
		if (typeof input.nodeName === "undefined") {
			input = input[0];
		}

		// Get the form element container.
		parent = input.parentNode;	
		
		// Set the error Id.
		errorId = "error-" + this.context.form.id + "-" + el;
		
		// Remove the old error.
		error = document.getElementById(errorId);
		if (error) {
			error.parentNode.removeChild(error);
		}

		canvas = document.createElement("div");

		errorHeader = canvas.cloneNode(false);
		errorContent = canvas.cloneNode(false);
		errorFooter = canvas.cloneNode(false);

		canvas.className = "validation-error";
		canvas.id = errorId;

		errorHeader.className = "validation-error-header";
		errorContent.className = "validation-error-content";
		errorFooter.className = "validation-error-footer";

		var close = document.createElement("p");
		close.className = "close";
		
		var closeLink = document.createElement("a");
		closeLink.innerHTML = "Close";
		closeLink.title = "Close";
		closeLink.onclick = function() {
			var error = document.getElementById(errorId);
			error.parentNode.removeChild(error);
		};

		close.appendChild(closeLink);

		canvasList = document.createElement("ul");
		canvasItem = document.createElement("li");

		if (errors.length) {
			for (message in errors.messages) {
				if (errors.messages.hasOwnProperty(message)) {
					this.log("Form element: " + el + " has the error message: " + errors.messages[message]);

					// Display error message.
					canvasMessage = canvasItem.cloneNode(true);
					canvasMessage.appendChild(document.createTextNode(errors.messages[message]));
					canvasList.appendChild(canvasMessage);
				}
			}

			errorContent.appendChild(canvasList);
			canvas.appendChild(errorHeader);
			canvas.appendChild(errorContent);
			canvas.appendChild(errorFooter);
			canvas.appendChild(close);	
			
			// Now do any custom display settings.
			var display = this.context.rules[el].display;

			// Test to see if it needs to be appended somewhere different.
			if (typeof display !== "undefined" && (display.insertBefore || display.insertAfter)) {
				var selector = YAHOO.util.Selector.query || document.getElementById;
				
				if (display.insertBefore) {
					parent = selector(display.insertBefore);
					if (parent) {
						YAHOO.util.Dom.insertBefore(canvas, parent);
					}
				} else if (display.insertBefore) {
					parent = selector(display.insertBefore);
					if (parent) {
						YAHOO.util.Dom.insertBefore(canvas, parent);
					}
				}

				this.log("Custom error message DOM insertion.");
			} else {
				parent.insertBefore(canvas, input);
			}
				
			// Make sure the parent is position: relative or the display will go wrong.
			parent.style.position = "relative";
	
			// Get the dimensions of the error message.
			canvasClip = YAHOO.util.Dom.getRegion(canvas);

			// Set a default position of the error message.
			var y = (canvasClip.bottom - canvasClip.top) * -1;
			this.log("Default y position: " + y);

			// Test for global display settings.
			if (this.config.display || typeof display !== "undefined") {
				this.log("Custom display for: " + el);

				display = display || {};

				var inputClip = YAHOO.util.Dom.getRegion(input);
				var xPosition = display.xPosition || this.config.display.xPosition;
				var xOffset = display.xOffset || this.config.display.xOffset;
				var x = null;

				var yPosition = display.yPosition || this.config.display.yPosition;
				var yOffset = display.yOffset || this.config.display.yOffset;

				if (typeof xPosition !== "undefined") {
					// This calculates the left position whatever the "xPosition" may be.
					x = (inputClip.left - inputClip[xPosition]) * -1;
					this.log("xPosition: " + xPosition + ", x: " + x);
					
					if (typeof xOffset !== "undefined") {
						x += xOffset;
						this.log("xOffset: " + xOffset + ", x: " + x);
					}
					canvas.style.left = x + "px";
				}

				if (typeof yPosition !== "undefined") {
					// This calculates the left position whatever the "xPosition" may be.
					y += (inputClip.top - inputClip[yPosition]) * -1;
					this.log("yPosition: " + yPosition + ", y: " + y);
					
					if (typeof yOffset !== "undefined") {
						y -= yOffset;
						this.log("yOffset: " + yOffset + ", y: " + y);
					}
				}
			}
			
			// Set the final top position.
			canvas.style.top = y + "px";

			// Now call an error callback.					
			if (this.context.rules[el]["onError"]) {
				this.context.rules[el]["onError"].call(this, this.context.form[el]);
			}
		}
	},
	/**
	 * Log errors and messages.
	 *
	 * @param {String} s Message.
	 */
	log: function(s) {
		if (s) {
			if (window.console) {
				console.log(s);
			}
			this.context.log.push(s);
		}
	}
};


UKISA.widget.FormValidation.filters = {
	/**
	 * Remove trailing whitespace from a string.
	 */
	trim: function(s) {
		return s.replace(/^\s+|\s+$/g, "");
	},
	/**
	 * Remove all whitespace from a string.
	 */
	strip: function(s) {
		return s.replace(/\s+/g, "");
	}
};

UKISA.widget.FormValidation.validators = {	
	/**
	 * Test for ensuring a text box has a value and radio/checkbox have had at least one option checked.
	 */
	required: function(el, v, arg) {
		if (typeof el.nodeName === "undefined") {
			return UKISA.widget.FormValidation.validators.checked(el, v, arg);
		} else {
			if (el.getAttribute("type") === "checkbox" || el.getAttribute("type") === "radio") {
				return UKISA.widget.FormValidation.validators.checked(el, v, arg);	
			} else {
				return (v.length > 0) ? true : false;
			}
		}
	},
	/**
	 * Ensure that a radio/checkbox has at least one option checked.
	 */
	checked: function(el, v, arg) {
		if (typeof el.length !== "undefined") {
			for (var i = 0, ix = el.length; i < ix; i++) {
				if (el[i].checked) { return true; }
			}
			return false;
		} else {
			return (el.checked);
		}
	},
	/**
	 * Determines if a value lies between a range of characters.
	 */
	range: function(el, v, arg) {
		return (arg[1]) ? (arg[0] === 0) ? (v.length > 0 && v.length <= arg[1]) : (v.length >= arg[0] && v.length <= arg[1]) : (v.length >= arg[0]);
	},	
	/**
	 * Determines if an email address is in a legal format.
	 */
	email: function(el, v, arg) {
		v = v.toLowerCase();
		var exp = new RegExp(/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[(2([0-4]\d|5[0-5])|1?\d{1,2})(\.(2([0-4]\d|5[0-5])|1?\d{1,2})){3} \])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/);
		return exp.test(v);
	},
	postcode: function(el, v, arg) {	
		var exp, postcode;

		// check if field filled
		if (v.length == 0) {
			return false;
		}
		
		postcode = UKISA.widget.FormValidation.filters.strip(v.toUpperCase());
		
		if (!UKISA.widget.FormValidation.validators.alphaNumeric(postcode)) {
			return false;
		}
		
		// check postcode formatting
		exp = new RegExp("^[A-Z]{1,2}([0-9]{1,2}|[0-9][A-Z])[0-9][ABD-HJLNP-UW-Z]{2}$");
		
		// need to disable this final validation because NS4 doesn't evalute regexp properly
		if (!exp.test(postcode)) {
			return false;
		}

		return true;
	},
	/**
	 * Check to ensure that the value contains only numbers and letters.
	 */
	alphaNumeric: function(el, v, arg) {
		var exp = new RegExp("[^A-Za-z0-9]");
		return !exp.test(v);
	},
	/**
	 * Check to see that the value is a number (with possible decimal point).
	 */
	 numeric: function(el, v, arg) {
		var num = parseFloat(v);
		return (!isNaN(num) || v === "");
	},
	/**
	 * Check to see if the value is a valid telephone number.
	 */ 
	phoneNumber: function(el, v, arg) {
		var exp = new RegExp("^[0-9 ]*$");
		return exp.test(v);
	},
	compare: function(el, v, arg) {
		var compare;

		compare = this.context.form[arg];

		return (compare && compare.value === v);
	},
	min: function(el, v, arg) {
		return (v.length >= arg);
	},
	max: function(el, v, arg) {
		return (v.length < arg);
	},
	/** 
	 * Validate a given number of words.
	 */
	maxWords: function(el, v, arg) {
		var max, cleanString, words;

		max = arg;
		cleanString = "";

		// Trim leading and trailing spaces.
		cleanString = v.replace(/^\s+|\s+$/g, "");

		// Remove excessive spaces.
		cleanString = cleanString.replace(/\s+/g, " ");

		// Split up the words.
		words = cleanString.split(" ");

		return (words.length <= max);
	},
	/**
	 * Checks that a number is an integer.
	 */
	integer: function(el, v, args) {
		var num = parseInt(v);
		return (v.indexOf(".") < 0 && (!isNaN(num) || v === ""));
	},
	/**
	 * Tests that a number lies between a range of values. 
	 */
	between: function(el, v, args) {
		var num = (isNaN(v)) ? 0 : v;
		return (num >= args[0] && num <= args[1]);
	}
};

/**
 * Default validation messages.
 */
UKISA.widget.FormValidation.messages = {
	required: "This is a required field",
	range: "Please enter between {0} and  {1} characters",
	email: "This email address does not appear to be correct",
	postcode: "Please enter a valid UK postcode",
	compare: "Please ensure that this value is correct",
	min: "Please enter more than {0} characters",
	max: "Please enter less than {0} characters",
	maxWords: "You may only enter up to {0} words",
	integer: "Only whole numbers are accepted e.g. 14",
	between: "Please enter a number between {0} and  {1}"
};

