/**
 * chkFrm : jQuery plugin which handles form validation
 * 
 * @author	Boye Oomens <boye@e-sites.nl>
 * 			Joris van Summeren <joris@e-sites.nl>
 * 
 * @since   10-02-2011
 * @version 3.0
 */
(function ($) {

	// Private vars 
	var defaults = {
			errorContainer: 'p.error',
			errorClass: 'error',
			errorMessages : null,
			requiredClass: 'required',
			headMsg: 'Niet alle verplichte velden zijn correct ingevuld',
			defaultMsg: 'Het veld "%" is niet correct ingevuld',
			footMsg: 'Deze velden zijn geel gemarkeerd.',
			msgAttr: 'data-errorMsg', // name of the attribute for overridden error messages
			singleChecks: null, // id:event
			trackErrors: false,
			excludedValues: [],
			compare: {}, // e.g. for passwords
			// Callbacks
			onBeforeValidate: null,
			onFail: null,
			onSuccess: null
		},
		
		undef = undefined,
		
		// Default error messages
		errorMessages = {
			'name': 'Uw naam is niet ingevuld',
			'email': 'U dient een geldig e-mail adres op te geven',
			'firstname': 'Uw voornaam is niet ingevuld',
			'lastname': 'Uw achternaam is niet ingevuld',
			'businessname': 'Uw bedrijfsnaam is niet ingevuld',
			'street': 'U heeft geen straat ingevuld',
			'postal': 'Uw postcode is niet correct ingevuld',
			'city': 'U heeft geen plaats ingevuld',
			'country': 'U heeft uw land van herkomst niet ingevuld',
			'tel': 'Uw telefoonnummer is niet correct ingevuld'
		},		
		
		// Regex validation patterns
		patterns = {
			'email': '^[_a-z0-9&+-]+(\\.[_a-z0-9&+-]+)*@[a-z0-9-]+(\\.[a-z0-9-]+)*(\\.[a-z]{2,6})$',
			'postal': '^([0-9]{4})([A-Z]{2})$',
			'date': '^([1-9]|0[1-9]|[12][0-9]|3[01])[- /.]([1-9]|0[1-9]|1[012])[- /.](19[0-9]{2}|20[0-9]{2})$',
			'numeric': '^[0-9]+$',
			'tel': '^([a-z0-9 +()-/:,]{9,})$',
			'url': '^(https?:\\/\\/)?([\\da-z\\.-]+)\\.([a-z\\.]{2,6})([\\/\\w \\.-]*)*\\/?$',
			'specific': '' // Reserved to match specific fields
		};
	
	/**
	 * General validation constructor
	 * 
	 * @author Boye Oomens <boye@e-sites.nl>
	 * @param {Object} form - form element
	 * @param {Object} event - jQuery event object
	 * 
	 * @constructor
	 * @private
	 */
	function Chkfrm(form, o) {
		
		var self = this,
			frm = form,
			globalErrors = [],
			inputs = frm.find(':input').not('[type=submit]'); // Also grabs textarea's & selects
		
		// Extend context with API methods
		// These will be available in all callbacks as well as the data instance
		$.extend(self, {
			
			/**
			 * Main validation method - the backbone of this plugin
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {Object} event - jQuery event object
			 * @param {Object} el - element (optional)
			 * @return {Boolean}
			 */
			validate: function (event, el) {

				// Invoke onBeforeValidate callback
				if ( o.onBeforeValidate !== null && $.isFunction(o.onBeforeValidate) ) {
					o.onBeforeValidate.apply(self, arguments);
				}

				// Private vars	/ caching			
				var req = el ||	frm.find('[required], [required=required], .' + o.requiredClass),
					index = req.length,
					err = [],
					elem, $elem, elemKey, lblFor, $elemLbl,
					elemAndLbl, groups, groupedEls, elemClass;
				
				// Start iteration over each required element checking their values based on element type
				while (index--) {
					elem = req[index],
					$elem = $(elem),
					elemKey = elem.name,
					elemVal = elem.value,
					elemClass = elem.className,
					lblFor = (elemKey.indexOf('[]') !== -1 ? elem.id : elemKey),
					$elemLbl = frm.find('label[for=' + lblFor + ']'),
					elemAndLbl = $.merge($elem, $elemLbl),
					groups = null,	groupedElems = [];
					
					// Check if all required fields have error messages, if not, use the data attribute (if present)
					// Else use the default ones
					if ( !errorMessages.hasOwnProperty(elemKey) ) {
						if ( $elem.attr(o.msgAttr) !== undef ) {
							errorMessages[elemKey] = $elem.attr(o.msgAttr);
						} else {
							errorMessages[elemKey] = o.defaultMsg.replace('%', elemKey);
						}
					}
					
					// Validate radio buttons and checkboxes
					if ( /(radio|checkbox)/.test(elem.type) ) {
						
						groups = frm.find('input[name=' + elemKey + ']');
						
						// Store all elements that are checked
						for (var g = 0, gl = groups.length; g < gl; g++) {							
							if ( groups[g].checked ) {
								groupedElems.push(groups[g]);							
							}
						}
						
						// If the array is empty we know they aren't checked
						if ( !groupedElems.length ) {
							err.unshift(elemKey);
							groups.next('label').addClass(o.errorClass);	
						} else {
							// Remove error styles
							self.reset(groups.next('label'));
						}
						
					} 
					// Validate input, textarea and select elements
					else if ( $.trim(elemVal) === '' || self.isExcludedValue(elemVal) ) {
						err.unshift(elemKey);
						elemAndLbl.addClass(o.errorClass);
					} else {
						// Take care of specific fields, such as email etc.
						if ( self.isSpecificField(elemClass) ) {
							
							// Strip the additional space in zipcodes
							if ( /(postal|zipcode|postcode)/.test(elemClass) ) {
								elemVal = elemVal.replace(/\s+/g, '');
							}
							
							if ( !self.isValidInput(self.getSpecificField(elemClass), elemVal) ) {
								err.unshift(elemKey);
								elemAndLbl.addClass(o.errorClass);
							} else {
								self.reset($elem);
							}
							
						// Reset error styles
						} else {
							self.reset($elem);
						}
						
					}
					
				}
				
				// Complement error array with compared values that possibly don't match
				// Also map errors to a global array
				err = globalErrors = $.merge( err, self.compareValues() );
				
				// Print errors if there are any
				if ( event.type === 'submit' ) {
					if ( err.length ) {
						self.handleErrors(event);
					} else {
						// Invoke onSuccess callback
						if ( o.onSuccess !== null && $.isFunction(o.onSuccess) ) {
							o.onSuccess.apply(self, arguments);
							event.preventDefault();
						}
						return true;
					}
				}
				
				
			},
			
			/**
			 * Gathers all errors in one string
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param none
			 * @return {String} string with all errormessages
			 */
			printErrors: function () {
				
				var err = globalErrors,
					html = o.headMsg + '%messages%' + (o.footMsg ? '<br>' + o.footMsg : ''),
					messages = '';
					// Check for custom (multilingual) error messages and override the errorMessages object
					errorMessages = (o.errorMessages ? $.parseJSON(o.errorMessages) : errorMessages);
					
				// Put all attached error messages in an array
				for (var a = 0, j = err.length; a < j; a++) {
					if ( errorMessages.hasOwnProperty(err[a]) ) {
						// Append messages
						messages += '&nbsp;&nbsp;- ' + errorMessages[err[a]] + '<br>';
						// Track errors through GA
						if ( o.trackErrors ) {
							self.trackError(frm[0].id, messages[msg]);
						}						
					}
				}
				
				return html.replace(/%messages%/, (o.errorMessages !== false ? '<br>' + messages : ''));
				
			},
			
			/**
			 * Function that handles all errors 
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {Array} err - array with errors
			 * @param {Object} e - jQuery event object
			 * @return none
			 */
			handleErrors: function (e) {
				
				// Show / append error container
				if ( o.errorContainer ) {
					frm.find(o.errorContainer).html( self.printErrors() ).fadeIn('medium');
				}
				
				// Invoke onFail callback
				if ( o.onFail !== null && $.isFunction(o.onFail) ) {
					o.onFail.apply(self, arguments);
				}
				
				// Prevent default semantics
				e.preventDefault();
				
			},			
			
			/**
			 * Checks if label elements contain an asterisk, if so the related input will get the required classname
			 * Please note that elements with the html5 required attribute are excluded
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {Object} form - HTMLFormElement
			 * @return void
			 */
			setRequiredFields: function (form) {
				
				if ( form === undef ) {
					throw new Error('No form given');
				}
				
				var labels = $('label', form),
					i = labels.length;
		
				while (i--) {
					if ( labels[i].innerHTML.indexOf('*') !== -1 ) {
						$(labels[i])
							.next('input, select, textarea')
							.not('[required], [required=required]')
							.addClass(o.requiredClass);
					}
				}
			},
			
			/**
			 * Compare values based on the given object
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param  {Object} obj - object
			 * @return {Array} err
			 */
			compareValues: function (obj) {
				
				if ( $.isEmptyObject(o.compare) ) {
					return [];
				}
				
				var err = [],
					compare = obj || o.compare,
					key, a, b;
					
				for (key in compare) {
					a = $('input[name=' + key + ']', frm)[0];
					b = $('input[name=' + o.compare[key] + ']', frm)[0];
					
					// Empty strings shouldnt be compared
					if ( a.value === '' && b.value === '' ) {
						return err;
					}
					
					// Compare actual values
					if ( a.value !== b.value ) {
						err.push( b.name );
						$(b).add(frm.find('label[for=' + b.id + ']')).addClass(o.errorClass);
					}
				}
				
				return err;
				
			},
			
			/**
			 * Tracks errors by calling the push method from the global _gaq object.
			 *
			 * @author Joris van Summeren <joris@e-sites.nl>
			 * @param {String} action
			 * @param {String} label
			 * @return void
			 * @since 2 sep 2010
			 */
			trackError: function (action, label) {
				var category = 'Validation errors';

				if (typeof _gaq === 'object') {
					_gaq.push(['_trackEvent', category, action, label]);
				} else if (typeof pageTracker === 'object') {
					pageTracker._trackEvent(category, action, label);
				}
			},
			
			/**
			 * Simply returns the config object
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param void
			 * @return {Object} o
			 */
			getOptions: function () {
				return o;
			},			
			
			/**
			 * Returns form context
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param none
			 * @return {Object}
			 */
			getForm: function () {
				return form;		
			},
			
			/**
			 * Returns serialized form data 
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param none
			 * @return {String}
			 */
			getFormData: function () {
				return form.serialize();		
			},
			
			/**
			 * Returns matched string from the given className based on the specific regex 
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {String} c - className
			 * @return {String} 
			 */
			getSpecificField: function (c) {
				if ( patterns['specific'] !== '' ) {
					return c.match(patterns['specific'])[0];
				}	
				return false;
			},	
		
			/**
			 * Check if given element has the correct classname
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {String} elem - element to be checked
			 * @return {Boolean}
			 */
			isRequired: function (elem) {
				return ( !!elem.attr('required') || elem.hasClass(o.requiredClass) );
			},
		
			/**
			 * Checks whether we are dealing with an excluded value
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {String} val - input value
			 * @return {Boolean}
			 */
			isExcludedValue: function (val) {
				return (val && $.inArray($.trim(val), o.excludedValues) !== -1);				
			},		
			
			/**
			 * Function which checks a string against a regular expression (based on the given pattern type)
			 * and returns either true or false
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {String} type (optional)
			 * @param {String} input
			 * @param {String} pattern
			 * @return {Boolean}
			 */
			isValidInput: function (type, input, pattern) {
				var regex = new RegExp(pattern || patterns[type], 'i');		
				return ( $.trim(input) !== '' && regex.test(input) );
			},
			
			/**
			 * Checks if the given className is a specific field (such as email, zipcode, etc.)
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {String} c
			 * @return {Boolean}
			 */
			isSpecificField: function (c) {
				var fields = [];
				for (field in patterns) {
					fields.push(field);
				}
				patterns['specific'] = '(' + fields.join('|') + ')';
				return self.isValidInput(null, c, patterns['specific']);
			},
			
			/**
			 * Adds new validation pattern to the main pattern object
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {String} id
			 * @param {String} pattern
			 * @return {Object} self
			 */
			addPattern: function (id, pattern) {
				if ( !errorMessages.hasOwnProperty(id) ) {
					patterns[id] = pattern;
					frm.find('#' + id).addClass(id); // Add a class to make it a specific field
				}
				return self;
			},
			
			/**
			 * Adds new error message to the main errorMessage object
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {String} name
			 * @param {String} msg
			 * @return {Object} self
			 */
			addErrorMessage: function (name, msg) {
				if ( !errorMessages.hasOwnProperty(name) ) {
					errorMessages[name] = msg;
				}
				return self;
			},
			
			/**
			 * Remove all error related classes
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @param {Object} el - optional element
			 * @return {Object} self
			 */
			reset: function (el) {
				if (el) {
					el.removeClass(o.errorClass);
				} else {
					frm.find('.' + o.errorClass).not('p').removeClass(o.errorClass);
				}				
				return self;
			},
			
			/**
			 * Unbind all events
			 * 
			 * @author Boye Oomens <boye@e-sites.nl>
			 * @return {Object} self
			 */
			destroy: function () {
				frm.unbind('submit'); 
				inputs.unbind();
				return self.reset();
			}
			
		});
		
		// Disable default html5 form validation
		// http://www.w3.org/TR/html5/association-of-controls-and-forms.html#attr-fs-novalidate
		frm.attr('novalidate', 'novalidate');	
		
		// Disable browser's default validation mechanism
		if ( inputs[0] && inputs[0].validity ) {
			inputs.each(function ()  {
				this.oninvalid = function () {
					return false; 
				};
			});
		}	
		
		// Determine required fields
		self.setRequiredFields(frm);
		
		// Form validation
		frm.bind('submit', self.validate);
		
		// Single input validation
		if ( o.singleChecks ) {
			if ( typeof o.singleChecks === 'object') {
				for (evt in o.singleChecks) {
					if ( o.singleChecks.hasOwnProperty(evt) && /(change|blur|keyup)/.test(evt) ) {
						$(o.singleChecks[evt], frm).bind(evt, function (e) {
							self.validate(e, $(this));
						});
					}
				}
			} else if ( /(change|blur|keyup)/.test(o.singleChecks) ) {
				inputs.bind(o.singleChecks, function (e) {
					self.validate(e, $(this));
				});
			}
			
		}
		
	}

	/**
	 * jQuery plugin setup
	 * 
	 * @param {Object} options - custom options
	 * @return {Object}
	 */
	jQuery.fn.chkFrm = function (options) {

		// Only deal with forms
		if ( this[0] && this[0].nodeName !== 'FORM' ) {
			throw new Error('Wrong element given: chkFrm only validates forms.');
		}
		
		var $self = this,
			instance = $self.data('chkFrm');
		
		// Destroy existing instance
		if ( instance ) { 
			instance.destroy();
			$self.removeData('chkFrm');
		}
		
		// Extend default options
		options = $.extend(true, {}, defaults, options);
		
		// Return context
		$self.each(function () {
			instance = new Chkfrm($(this), options);				 
			$(this).data('chkFrm', instance);
		});
		
		return $self;
		
	};

}(jQuery));
