/**
 * @author Victor Bolshov <crocodile2u@yandex.ru>
 * @version 1.0 (2009-01-13)
 *
 * Smartform jquery plugin makes all the forms on a page to act via AJAX -
 * unless a special runat="client" attribute is provided.
 */

// the Jquery plugin
$.fn.smartform = function(options) {
	var merged_options = {};
	var defaults = {
		success: null,
		failure: null
	};
	$.extend(merged_options, defaults, options);
	this.each(function(){
		new $.Smartform(this, merged_options);
	});
}

// the Smartform class: constructor
// @param HTMLFormElement f
// @param object options
$.Smartform = function(f, options) {
	this.form = f;
	this.options = options;
	this.disabled_upon_submit = [];
	// sanitize form action
	if (! f.action)
	{
		f.action = location.href.toString();
	}
	// create error-list
	this.createErrorList();

	var multipart = false;
	if (this.isAjax())
	{
		f.served = this.serveMultipart();
		multipart = f.served;
	}
	
	var that = this;
	
	// listen to submissions
	$(f).bind('submit', function() {
		// validate form
		if (! that.form.validator.validate())
		{
			that.fail();
			return false;
		}
		
		// submission handler
		if ((! multipart) && that.isAjax())
		{// we really should listen
			$.ajax({
				url: this.action,
				type: this.method,
				data: $(this).serialize(),
				success: function(data) {
					that.acceptText(data);
				},
				error: function(r, t) {
					that.fail(["Request failed: " + t]);
				}
			});
			that.disableRepeatedSubmissions();
			return false;
		}
		
		that.disableRepeatedSubmissions();
		
	});
	$(f).bind('reset', function() {
		setTimeout(function(){
			that.form.validator.validate();
		}, 100);
	});
	// apply validation
	f.validator = new $.Smartform.validator(f);
	f.validator.validate();
	f.served = true;
}

$.Smartform.attr = function(el, attr) {
	var ret = el.getAttribute(attr);
	if ('string' == typeof ret)
	{
		return ret;
	} else {
		return el.getAttribute('sf-' + attr);
	}
}

// Smartform instance methods
$.Smartform.prototype = {
	// should be run with AJAX?
	isAjax: function() {
		return 'client' != this.form.getAttribute('runat');
	},
	/**
	 * Multipart forms are served differently.
	 * They are not submitted via AJAX, instead the data is sent to <iframe>s,
	 * either created by user or by the script itself (the script examines the `target' attribute of the form, 
	 * and if it is set, the script attempts to use the frame specified there; if the `target' attribute is
	 * not specified, then the script creates the <iframe> itself, and make the form submitted into this <iframe>).
	 */
	serveMultipart: function() {
		if (this.form.served)
		{
			return true;
		}
		if (this.isMultipart())
		{
			this.form.setAttribute('enctype', 'multipart/form-data');
			if (this.form.target)
			{// suppose the user has already prepared the iframe
				var frames = document.getElementsByTagName('iframe'), frame;
				for (var i = 0; i < frames.length; ++i)
				{
					frame = frames.item(i);
					if (frame.getAttribute('name') == this.form.target)
					{
						this.createIframeListener(frame);
						break;
					}
				}
			} else {
				// create the iframe, for the form to be submitted into it,
				// and listen to that frame's load event
				var name = 'i' + Math.random();
				var frame = $('<iframe name="'+name+'" class="x-iframe" ></iframe>');
				$(this.form.parentNode).append(frame);
				this.form.target = name;
				this.form.setAttribute('target', name);
				this.createIframeListener(frame);
			}
			
			return this.form.served = true;
		} else {
			return this.form.served = false;
		}
	},
	/**
	 * Creates <iframe> listener for multipart forms submissions
	 * @param HTMLIFrameElement frame - the #ID is also accepted
	 */
	createIframeListener: function(frame) {
		var that = this;
		$(frame).bind('load', function() {
			var doc = ('undefined' == typeof(this.contentWindow)) ? this.contentDocument : this.contentWindow.document;
			var text = $(doc.body).text();
			if (text.match(/[^\s]/))
			{// the iframe is not empty
				that.acceptText(text);
			} else {
				that.reenableDisabledElements();
			}
		});
	},
	/**
	 * Check whether the form is multipart
	 */
	isMultipart: function() {
		return ('multipart/form-data' == this.form.getAttribute('enctype')) || ($('input[type="file"]', this.form).length);
	},
	/**
	 * @access private
	 * @param String t
	 */
	acceptText: function(t) {
		try {
			eval('var j = (' + t + ')');
			'x' in j;// should not issue exception-throw
		} catch (e) {
			var truncated = t.substring(0, 100).replace(/\</g, "&lt;").replace(/\>/g, "&gt;");
			var append;
			if (t == truncated)
			{
				append = "";
			}
			else
			{
				var id = "d" + Math.random();
				append = '... <button class="x-invalid-response" onclick="alert(document.getElementById(\'' + id + '\').innerHTML)">Full text</button><div id="' + id + '" style="display:none;">' + t + '</div>';
			}
			j = {errors: ["Invalid JSON response: " + truncated + append]};
		}

		if (('errors' in j) &&  (j.errors != null) && j.errors.length)
		{
			this.fail(j.errors);
		} else {
			if (! ('data' in j))
			{
				j.data = {};
			}
			this.succeed(j.data);
		}
	},
	/**
	 * Handle form successful submission
	 * @param mixed data
	 */
	succeed: function(data) {
		this.reenableDisabledElements();
		
		this.form.errorList.innerHTML = '';
		$(this.form.errorList).hide();
		var handler = this.options.success || this.form.getAttribute("done");
		if (handler)
		{
			this.callback(handler, data, "Invalid success callback");
		} else {
			location.reload();
		}
	},
	/**
	 * Handle form submission errors
	 * @param HTMLFormElement form
	 * @param String[] list error list
	 */
	fail: function(error_list) {
		this.reenableDisabledElements();
		
		if (0 == arguments.length) {
			error_list = this.form.validator.errors;
		}
		var handler = this.options.failure || this.form.getAttribute("failed");
		if ('alert' == handler)
		{
			alert(error_list.join("\n"));
		} else if (handler)	{
			this.callback(handler, error_list, "Invalid failure callback");
		} else {
			this.form.errorList.innerHTML = '';
			$(this.form.errorList).show();
			for (var i = 0; i < error_list.length; ++i)
			{
				var li = this.form.errorList.appendChild(document.createElement('li'));
				li.innerHTML += error_list[i];
			}
		}
	},
	disableRepeatedSubmissions: function() {
		/**
		 * We're run as AJAX: we should disable submission elements temporarily,
		 * until we get a response from server.
		 */
		if (this.isAjax()) {
			var disable_on_submit = this.form.getAttribute('disable-onsubmit') || 'yes';
			if ('no' != disable_on_submit.toLowerCase())
			{
				var that = this;
				$('input[type="submit"], input[type="image"], button, .x-submit', this.form).each(function(){
					if (! this.disabled)
					{
						that.disabled_upon_submit.push(this);
						this.disabled = true;
					}
				});
			}
		}
	},
	// enable elements that were disabled upon submit
	reenableDisabledElements: function() {
		for (var i in this.disabled_upon_submit) {
			this.disabled_upon_submit[i].disabled = false;
		}
		this.disabled_upon_submit = [];
	},
	/**
	 * Call a callback function being given its name
	 * @param string spec the callback name
	 * @param mixed arg
	 * @param string error_prefix
	 */
	callback: function(spec, arg, error_prefix) {
		try {
			if ('function' == typeof spec)
			{
				spec(arg);
			} else {
				eval("var cb = " + spec);
				cb(arg);
			}
		} catch(e) {
			alert(error_prefix + ":\n" + e.message);
		}
	},
	/**
	 * Create a "standard" error-list
	 */
	createErrorList: function() {
		var errorList = document.createElement('ul');
		errorList.style.display = 'none';
		errorList.className = 'x-error-list';
		this.form.parentNode.insertBefore(errorList, this.form);
		this.form.errorList = errorList;
	}
}

$.Smartform.toFunction = function(spec) {
	if ('function' == typeof spec)
	{
		return spec;
	} else {
		eval("var cb = " + spec);
		return cb;
	}
}

// element validator class constructor
$.Smartform.elementValidator = function(el, callback, error_attr, default_error) {
	this.element = el;
	this.callback = callback;
	this.error = $.Smartform.attr(el, error_attr);
	if (! this.error)
	{
		this.error = default_error.replace(/\{name\}/, el.getAttribute('name'));
	}
}
$.Smartform.elementValidator.prototype = {
	validate: function() {
		if (this.callback.call(this.element, this.element.value)) {
			return true;
		} else {
			var jel = $(this.element);
			$(this.element).addClass('x-invalid');
			return false;
		}
	}
}
// get an element error
$.Smartform.getElementError = function(el) {
	if (! el.validators) return false;
	for (var i in el.validators) {
		if (el.validators[i].validate && ! el.validators[i].validate())
		{
			return el.validators[i].error;
		}
	}
	
	$(el).removeClass('x-invalid');
	return false;
}

// Smartform built-in validators
$.Smartform.validators = {
	email: function(value) {
		return /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(value);
	},
	url: function(value) {
		return /[-\w\.]+:\/\/([-\w\.]+)+(:\d+)?(:\w+)?(@\d+)?(@\w+)?([-\w\.]+)(\/([\w/_\.]*(\?\S+)?)?)?(#\S*)?/i.test(value);
	},
	int: function(value) {
		return ! isNaN(parseInt(value));
	},
	float: function(value) {
		return ! isNaN(parseFloat(value));
	}
}

// form validator class constructor
$.Smartform.validator = function(form) {
	this.form = form;
	this.errors = [];
	for (var i = 0; i < form.elements.length; ++i)
	{
		this.createValidators(form.elements[i]);
	}
}
// form validator instance methods 
$.Smartform.validator.prototype = {
	/**
	 * @return bool
	 */
	validate: function() {
		this.errors = [];
		var err;
		for (var i = 0; i < this.form.elements.length; ++i)
		{
			err = $.Smartform.getElementError(this.form.elements[i]);
			if (err) {
				this.errors.push(err);
			}
		}
		return (0 == this.errors.length);
	},
	/**
	 * Create validators for a single element
	 * @param DOMNode el
	 */
	createValidators: function(el) {
		el.validators = [];
		var jquery_el = $(el);
		
		// required validator
		var req = $.Smartform.attr(el, 'required'), v;
		if ('string' == typeof req)
		{
			jquery_el.addClass('x-required');
			el.validators.push(new $.Smartform.elementValidator(el, function(value) {
				if (req.length) {
					// conditional "required"
					try {
						eval('var test = ' + req);
						if (false === test.call(el, value))
						{
							return true;
						}
					} catch (e) {
						alert("Invalid conditional required specification: illegal callback " + req);
					}
				}
				return value.match(/\S/);
			}, "required-error", "Required rule failed on {name}"));
		}
		
		// pattern validator
		var ptrn = el.getAttribute('pattern');
		if (ptrn)
		{
			try {
				eval('var re = ' + ptrn);
			} catch (e) {
				alert("Invalid pattern: " + ptrn);
			}
			el.validators.push(new $.Smartform.elementValidator(el, function(value) {
				if (! value.match(/\S/)) {// empty values chould pass
					return true;
				}
				return re.test(value);
			}, "pattern-error", "Pattern rule failed on {name}"));
		}
		
		// custom validator, either built-in or not
		var custom = el.getAttribute('validator');
		if (custom)
		{
			var mx = custom.match(/^sf\:(\S+)$/i);
			if (mx)
			{// Smartform built-in validator is to be applied
				if (! mx[1] in $.Smartform.validators)
				{
					throw mx[1] + " is not recognized as a built-in validator";
				}
				custom = $.Smartform.validators[mx[1]];
			}
			el.validators.push(new $.Smartform.elementValidator(el, function(value) {
				if (! value.match(/\S/)) {// empty values chould pass
					return true;
				}
				return $.Smartform.toFunction(custom).call(this, value);
			}, "validator-error", "validation failed on {name}"));
		}
		
		if (el.validators.length)
		{
			jquery_el.bind('change', function() {
				$.Smartform.getElementError(this);
			});
		}
	}
}
