/**
*		PLUGIN NAME 	: jValidateForm
*
*		VERSION			:  1.0
*
*		DEPENDENCIES 	: 	- jquery 1.2 +
*							- the tipsy jquery plugin (http://onehackoranother.com/projects/jquery/tipsy/) for rendering tooltips
*
*		LICENCE			:	buy me a beer and we'll talk ;)
*
*		AUTHOR			: 	bogdan gradinariu (bogdan.gradinariu [at] gmail [dot] com)
*
*		DESC			:	 a plugin that 
*								- validates inputs based on their types and classes (ex: if the input has type="email", it's value will be ran agains an email validation regex) 
*								- adds a css error class to any field that does not pass the validation
*								- shows a tipsy tooltip with a proper message targeting the invalid field (if the tipsy plugin is present)
*						
*		USAGE 			: 	$(selector).jValidateForm(settings);
*							where 
*								- selector is a css  selector pointing at a input container (not necessarily the form itself: it can be a part of a form)
*								- settings is an object that can overwrite the default settings ($.fn.jValidateForm.defaults) explained lower
*
*		CHANGELIST		:	well, it's the first version, so.. nothing :P
*
*  		TO DO			: 	- provide a nice tipsy-less event/callback sistem to trigger custom tooltips 
**/
(function($,window,undefined){
	$.fn.jValidateForm = function(o){
		// some globals
		var options = $.extend(true,{},$.fn.jValidateForm.defaults,o);
		O = options;
		function _isValidInput (el){
			var v = el.val() + "",
				type = el.attr('type');
			
			// trim the value			
			if(options.trimValues)
				v = $.trim(v);
				
			// check if the inpu type corresponds with ny rule name (such as type="number" would go with the "number" rule
			if(type in options.rules)
				{
					var r = options.rules[type](v);
					if(r !== true)
						return r;
				}
			var status = true,
				classes = _filterClasses(el);
			for(var i=0,l=classes.length;i<l;i++)
				{
					status = _validateByClass(el,classes[i]);
					if(status !== true)
						return status;
				}
			return true;
		}
		
		function _filterClasses(el){
			var c = el.attr('class');
			if(!c || c == "")
				return [];
			return $.map($.grep(c.split(/\s+/),function(){
						return new RegExp("^" + options.classPrefix +'_').test(arguments[0]);
					}),
				function(val,key){
					return val.replace(new RegExp("^" + options.classPrefix +'_'),'');
				}
			);
		}
		
		function _validateByClass(el,cl){
			if(!cl || cl === "")
				return true;
			var val = el.val();
			if(cl in options.rules)
				{
					var r = options.rules[cl](val,el);
					if(r !== true)
						return r;
				}
			for(var k in options.dynamicRules)
				{
					var match = cl.match(new RegExp(k));
					if(match !== null)
						{
							r = options.dynamicRules[k](val,el,match);
							if(r !== true)
								return r;
						}
				}
			
			// required or not_required
			if(options.requireAllFields === true)
				{
					if(cl == 'not_required')
						return true;
					return (!val || val !== "") || "this field is required";
				}
			else
				if(cl == 'required')
					return val !== "" || "this field is required";
			return true;
		}
		
		// ~constructor
		function jValidateForm(el,o){
			var api = this,
				$el = $(el),
				elements = $el.find('input[type!=submit][type!=hidden][type!=reset],select,textarea'),
				_hideTips,
				_showTips,
				tipsyExists;
			
			//internal api
			function _initialise(options){
				this.settings = options;
				elements.data('jValidateFormClassPrefix',options.classPrefix);
				elements.data('jValidateFormEventNamepsace',options.eventNamespace);
				_initToolTip(_bindEvents);
			}
			function _bindEvents(){
				elements
					.bind($.trim(options.hideOn.join(options.eventNamespace+' ')),function(e){
						$(this).hasClass(options.classPrefix + '_error') && $(this).trigger('remove_hints.'+options.eventNamespace);
					})
					.bind($.trim(options.checkOn.join(options.eventNamespace+' ')),function(e){
						$(this).trigger('validate.'+options.eventNamespace);
					})
					.bind('validate.'+options.eventNamespace,function(e){
						var $this = $(this),
							validation = _isValidInput($this);
						validation !== true && _showTips($this,validation);
					})
					.bind('remove_hints.'+options.eventNamespace,function(e){
						_hideTips($(this));
					});
					
				$el.bind('submit.'+options.eventNamespace,function(e){
					if($(this).data('jValidateFormApi').check() === true)
						return true;
					e.preventDefault();
					e.stopPropagation();
					e.cancelBubble = true;
					return false;
				});
				
				if(options.frustrateUser === false)
					return;
				
				function _blurHelper(e){
					var $this = $(this),
						validation = _isValidInput($this);
					if(validation !== true) 
						{
							_showTips($this,validation);
							e.preventDefault();
							e.stopPropagation();
							e.cancelBubble = true;
							setTimeout(function(){
								$this
									.unbind('blur.'+options.eventNamespace)
									.trigger('focus.'+options.eventNamespace);
									setTimeout(function(){$this.bind('blur.'+options.eventNamespace,_blurHelper);});
								}
							);
							return false;
						}
				//	else
					_hideTips($this);
				}

				elements
					.bind('blur.'+options.eventNamespace,_blurHelper)
					.bind('focus.'+options.eventNamespace,function(){
						var err = elements.filter('.' + options.classPrefix + '_error'),
							$this = $(this);
					
						// there are other invalid fields before this one 
						if(err.length > 0 && $this.get(0) !== err.get(0))
							{
								$this.unbind('blur.'+options.eventNamespace);
								setTimeout(function(){
									err.get(0).focus();
									$this.bind('blur'+options.eventNamespace,_blurHelper);
								});
							}
					});
			}
			function _initToolTip(callback){
				tipsyExists = 'tipsy' in $.fn;
				if(tipsyExists)
					elements.tipsy(options.tipsyOptions);
			
				_showTips = function(el,tip){
					return el.each(function(i,elem){
						$e = $(elem);
						$e
							.data('tipsy_hint',tip)
							.addClass(options.classPrefix + '_error')
							.trigger('show_tip.'+options.eventNamespace,[tip]);
						if(tipsyExists)
							$e.tipsy('show');
					});
				}
				_hideTips = function(el){
					return el.each(function(i,elem){
						$e = $(elem);
						$e
							.removeData('tipsy_hint')
							.removeClass(options.classPrefix + '_error')
							.trigger('hide_tip.'+options.eventNamespace);
						if(tipsyExists)
							$e.tipsy('hide');
					});
				}
				typeof callback === 'function' && callback();
			}
			
			function _destroy(){
				$el.unbind('.'+options.eventNamespace);
				elements
					.unbind('.'+options.eventNamespace)
					.removeClass(options.classPrefix + '_error')
					.removeClass(options.classPrefix + '_disabled');
				if(tipsyExists)
					try{elements.tipsy('disable');}catch(e){}
			}
			
			// function that returns a jquery element relative to the form
			// based on the argument provided : 
			//									- number -> index of the child inputs
			//									- string -> css selector
			//									- dom element -> the actual input element
			//									- jquery element -> jquery(input element)
			//	IF NOT A VALID SELECTOR, THE FUNCTION RETURNS AN EMPTY JQUERY OBJECT SO THAT THE SCRIPT WON'T BREAK IF YOU APPLY METHODS TO THIS OBJECT
			function _argToJquery(e){
				var typeofe = typeof e,
					element = $();
				if(!isNaN(+e))
					element = elements.eq(+e);
				else if(typeofe === 'string')
					element = $el.find(e);
				else if(typeofe === 'object')
					{
						if(e instanceof jQuery && $.contains(el,e.get(0)))
							element = e;
						else if(e instanceof HTMLElement && $.contains(el,e))
							element = $(e);	
					}
				return element;
			}
			
			// external api	
			$.extend(api,{
				// destroy this object
				destroy : function(){
					_destroy.apply(api,arguments);
					return api;
				},
				
				// reinitialise this plugin
				// useful when you want to change some settings
				reinitialise : function(settings){
					api.destroy.apply(api,arguments);
					_initialise.apply(api,arguments);
					return api;
				},
				
				// function that checks if a certain element is valid
				checkField : function(f){
					return  _isValidInput(_argToJquery(e));
				},
				
				// checks if the form os valid and applyes the tooltips and error classes to any invalid elements found
				checkAllFields : function(){
					elements.trigger('validate.' + options.eventNamespace);
					return elements.filter(options.classPrefix + '_error').length === 0;
				},
				
				// validates the form and stops at the first encountered error
				check : function(){
					for(var i=0,l=elements.length;i<l;i++)
						if(_isValidInput(elements.eq(i)) !== true)
							{
								elements.eq(i).trigger('validate.' + options.eventNamespace).focus();
								return false;
							}
					return true;
				},
				
				// function that removes all the error hints on this form
				removeHints : function(){
					elements.trigger('remove_hints.' + options.eventNamespace);
				},
				
				// function that removes the error hints on one of the form's elements
				// the argument can be either an index(reprezenting the n-th element), a string(representing a selector relative to the form), a dom element or a jquery element
				removeElementHints : function(e){
					_argToJquery(e).trigger('remove_hints.' + options.eventNamespace);
					return api;
				},
				
				// function that disables the validation for the matched objects
				disableElementValidation : function(e){
					_argToJquery(e).addClass(options.classPrefix + '_disabled');
					return api;
				},
				
				// function that enables the validation for the matched objects
				enableElementValidation : function(e){
					_argToJquery(e).removeClass(options.classPrefix + '_disabled');
					return api;
				},
				
				// checks if the form is valid withouth modifying the html (any tooltips/error classes)
				isValid : function(){
					for(var i=0,l=elements.length;i<l;i++)
						if(_isValidInput(elements.eq(i)) !== true)
							return false;
					return true;
				}
			});
			
			// initialise
			_initialise(o);
		}
		
		return this.each(function(){
			var that = $(this),
				api = that.data('jValidateFormApi');
			if(api instanceof jValidateForm)
				api.reinitialise(options);
			else
				that.data('jValidateFormApi',new jValidateForm(this,options));
		});
	}

	$.fn.jValidateForm.defaults = {
		classPrefix : 'jvf',					// the default class prefix. all css_classes used by this plugin will have this prefix
		
		eventNamespace : 'jvf',					// the default event namespace. all the events binded/triggered/handled by this plugin will be namespaced  : event.namespace
		
		requireAllFields : false,				// if true, all the fields are required by default, only those with the class "prefix_not_required" are not validated,
												// if false, none of the fields will be validated unless the have at least one of the classes "prefix_required" or "prefix_rule"
		
		trimValues : true,						// if true, the values are trimmed before validated
												
		frustrateUser : false,					// if true, the user will not be able to focus on another element until the current "problem" is solved [might be quite annoying]
		
		checkOn : [								// an array indicating what events should trigger the field validation
			'change' 
		],
		
		hideOn : [								// an array indicating what events should stop the field validation (hide the errors) 
			'keydown'
		],
		
		rules : {								// an object containing validation rules
												// the rules analise the input's value passed as argument and return true if the value is valid or an error message that will be displayed in a tooltip otherwise 
												// rules can be added or overwritten with own rules
			// a positive number
			'number' : function(val){
				return /^0$|^[1-9][0-9]*$/.test(val) || "not a valid number";
			},
		
			// same as above
			'numeric' : function(val){
				return /^0$|^[1-9][0-9]*$/.test(val) || "not a valid number";
			},
			
			// an integer : +/- number
			'integer' : function(val){
				return /^0$|^[-]?[1-9][0-9]*$/.test(val) || "not a valid integer";
			},	
			
			// a floating point number
			'float' : function(val){
				if(val.indexOf('.') == -1)
					return _this.validate.integer(val);
				val = val.split('.');
				var r = [_this.validate.integer(val[0]),_this.validate.number(val[1])];
				return r[0] && r[1] || "not a valid float number";
			},
			
			// a telephone number
			'tel' : function(val){
				return /^\+?[0-9. ]{10,15}$/.test(val) || "not a valid phone number";
			},
			
			// a zip code
			'zip' : function(val){
				return /^[1-9]\d{5}$/.test(val) || "not a valid zip code (should consist of 6 digits)";
			},
			
			// alphanumeric : letters/numbers/-/_/"space"
			'alpha_numeric' : function(val){
				return /^(\w\s)*$/g.test(val) || "the field can contain only letters,numbers,spaces,underscores and minuses";
			},
			
			// email address
			'email' : function(val){
				if(!val)
					return "invalid email address";
				var regex = /^[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+\.[a-zA-Z]{2,6}$/;
				if(val.indexOf(',') == -1)
					return regex.test(val.toString()) ? true : "invalid email address";
				val = val.split(',');
				for(var i=0,l=val.length;i<l;i++)
					if(!regex.test(val[i].trim()))
						return "email #" + (i+1) + " is not a valid email address";
				return true;
			},
			
			// an email confirmation field
			'confirm_email' : function(val,el){
				var m = el.attr('class').match(/confirm_for_([^\s]+)/);
				if(m.length < 2)
					return true;
				return el.val() == "" || $('#' + m[1]).val() === el.val()
					? true
					: "emails do not match";
			},
			
			// a date input - format : DD/MM/YYYY
			'date' : function(val){
				return /^[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{4}$/.test(val)
					? true 
					: "the date format is : <b>DD/MM/YYYY</b>";	
			},
			
			// a date-range format : DD/MM/YYYY-DD/MM/YYYY
			'date-range' : function(val){
				return /^[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{4}\-[0-9]{1,2}\/[0-9]{1,2}\/[0-9]{4}$/.test(val) 
					? true 
					: "the date-range format is : <b>DD/MM/YYYY-DD/MM/YYYY</b>";	
			},
			
			// a valid url
			'url' : function(val){
				return new RegExp('((https?|ftp|gopher|telnet|file|notes|ms-help):((//)|(\\\\))+[\w\d:#@%/;$()~_?\+-=\\\.&]*)').test(val) || "not a valid url";
			}
		},
		
		dynamicRules : {								// an object containing dynamic rulls
														// the dynamic rulls are more generic than the "static" ones, because they contain parameters that can be changed
														// ex : a static rule can be "number" wich determines a value to be a number, 
														//      but a dynamic rule can be "max_23","min_10","lt_32","gt_12" 
														//			ex :  	- "gt_30" checks if the value is greater than 30	
														//					- "min_10" checks if the value has at least 10 characters

			// min_length
			"^min_([1-9][0-9]*)$" : function(val,el,match){
				if(match && match.length == 2)
					return match[1] < val.length || "the value must be at least <strong>" + match[1] + "</strong> characters long";
			},
			
			// max_length
			"^max_([1-9][0-9]*)$" : function(val,el,match){
				if(match && match.length == 2)
					return match[1] > val.length || "the value must be at most <strong>" + match[1] + "</strong> characters long";
			},
			
			// lt => lower than "<"
			"^lt_(.+?)$" : function(val,el,match){
				if(match && match.length == 2)
					{
						if(!isNaN(+val) && !isNaN(+match[1]))
							{
								val = +val;
								match[1] = +match[1];
							}
						return  val < match[1] || "the value must be <strong>lower</strong> than <strong>" + match[1] + "</strong>";
					}
			},
			
			// lte => lower than equal "<="
			"^lte_(.+?)$" : function(val,el,match){
				if(match && match.length == 2)
					{
						if(!isNaN(+val) && !isNaN(+match[1]))
							{
								val = +val;
								match[1] = +match[1];
							}
						return val <= match[1] || "the value must be equal to or <strong>lower</strong> than <strong>" + match[1] + "</strong>";
					}
			},
			
			// gt => lower than "<"
			"^gt_(.+?)$" : function(val,el,match){
				if(match && match.length == 2)
					{
						if(!isNaN(+val) && !isNaN(+match[1]))
							{
								val = +val;
								match[1] = +match[1];
							}
						return val > match[1] || "the value must be <strong>greater</strong> than <strong>" + match[1] + "</strong>";
					}
			},
			
			// gte => lower than equal ">="
			"^gte_(.+?)$" : function(val,el,match){
				if(match && match.length == 2)
					{
						if(!isNaN(+val) && !isNaN(+match[1]))
							{
								val = +val;
								match[1] = +match[1];
							}
						return val >= match[1] || "the value must be equal to or <strong>greater</strong> than <strong>" + match[1] + "</strong>";
					}
			}
		},
		
		tipsyOptions : {						// the tipsy tooltip options more details on http://onehackoranother.com/projects/jquery/tipsy/
			html : true,
			trigger : 'manual',
			title : function(){
				return $(this).siblings('span.' + $(this).data('jValidateFormEventNamepsace') + '_hint:first').html() || $(this).data('tipsy_hint') || "this field is required";
			},
			fallback : 'this field is required',
			gravity : function(){
				var classes = $(this).attr('class') || "";
				var dir = classes.match(/\s?tipsy_([a-zA-Z])\s?/g);
				if(dir == null || dir.length == 0)
					return $.fn.tipsy.autoNS.call(this);
				return $.trim(dir[dir.length-1]).replace('tipsy_','').toLowerCase();
			}
		}
	}
	$.fn.jValidateForm.version = 1.0;
	
})(jQuery,window);
