/**
 * An jQuery plugin that enables one to use ajax to validate a form.
 *
 * To enable validation, call the attachValidator() method on a FORM element. The attachValidator()
 * and detachValidator() methods will only work with FORM elements.
 *
 * When the validator is attached, any change in any of the form's input elements will trigger
 * validation. An ajax request is sent and the server response with validation data.
 * Upon validation, a "validationSuccess" or "validationFailure" event will be
 * triggered for that element. You can use jQuery.bind() to bind event listeners on the form
 * input elements to listen for these events.
 *
 * You can also manually trigger validation by calling the doValidation() method on any of the
 * form input elements or the form element itself. If called on a form input, that input will be
 * validated. If called on the FORM element, validation is done on the whole form.
 *
 * Methods:
 * $(form_element).attachAjaxValidator();
 * $(form_element).detachAjaxValidator();
 * $(any_form_or_input_element).doValidation();
 *
 * @author Andy Don
 *
 */
(function($) {

    var NAMESPACE = "gorilla_ajaxvalidator";

    // The event that is fired when a successful validation occurs for the form or a specific form field
    var VALIDATION_SUCCESS_EVENT_NAME = "validationSuccess";

    // The event that is fired when a validation fails for the form or a specific form field
    var VALIDATION_FAILURE_EVENT_NAME = "validationFailure";

    // The event that is fired when the field should be reset to the intial state (neither success nor failure)
    var VALIDATION_RESET_EVENT_NAME = "validationReset";

    // Default options
    var DEFAULT_AJAX_OPTS = {
        dataType : "json",
        type : "POST",
        delay : 0
    };

    $.fn.extend({

        /**
         * Attaches the ajax validator to a form. The jQuery object that this function is called
         * on must contain a FORM element at the 0 index. It will ignore all other elements in the
         * jQuery object.
         *
         * Once the validator is attached, ajax requests will be made when any input element in
         * the form is updated. For example, if a text input is changed, an ajax request will
         * be made to the server and the field will be checked for validity. If the form is valid
         * a "validationSuccess" event will be triggered on the field. If invalid, a
         * "validationFailure" event will be triggered containing any error messages for that field.
         *
         * The form element itself will also have "validationSuccess" and "validationError" events
         * that will be triggered if the server determines that "global errors" are present. Global
         * errors are general errors that are not associated with any particular field, but instead
         * the form as a whole.
         *
         * @param ajaxOpts an object containing the ajax request options. This object is identical
         * to the object that is accepted by the jQuery.ajax() method. It also
         * adds an option called "delay" which specifies the amount of time to delay between
         * when an input element is modifed to when the ajax request is made. If no option object
         * is specified, defaults will be used, and the ajax url will be assumed to be the form action.
         * @param dataHandler a function that is able to convert the "data" returned by the ajax
         * call into validation data that the validator knows how to read. This parameter allows
         * the caller to specify a specialized data handler function in the event that the server
         * is returning data in an incompatable format. For an example of the formatting of the
         * validation data object, please see _defaultDataHandler() for an example.
         */
        attachAjaxValidator :  function(ajaxOpts, dataHandler) {
            var elem = _getElement(this);
            if (elem == null) {
                return this;
            }

            // The object where various data is kept
            var data = {};
            elem.data(NAMESPACE, data);
            data.previousValueMap = {};

            // Get the inputs in this form
            data.formInputs = elem.find(":text, :password, select, :checkbox, :radio, textarea").get();

            // Set the detault settings
            data.ajaxOpts = {};
            $.extend(data.ajaxOpts, DEFAULT_AJAX_OPTS);
            data.ajaxOpts.url = elem.attr("action");
            data.dataHandler = _defaultDataHandler;

            // Set the user defined settings
            if (ajaxOpts) {
                $.extend(data.ajaxOpts, ajaxOpts);
            }
            if (dataHandler && $.isFunction(dataHandler)) {
                data.dataHandler = dataHandler;
            }

            _bindInputEvents(elem);
            return this;
        },

        /**
         * Detaches the validator from the form. When this function is called, the validation
         * functionality attached to the form element will be removed.
         */
        detachAjaxValidator : function() {
            var elem = _getElement(this);
            if (elem == null) {
                return this;
            }
            var data = elem.data(NAMESPACE);
            
            elem.find("*").unbind("." + NAMESPACE);
            elem.removeData(NAMESPACE);
            return this;
        },

        /**
         * Validates the form or a specific input element in the form. Upon validation,
         * success or failure events will be triggered for the element(s) indicating if the
         * input is valid or not.
         * @param callback a callback function to run when all the validation checks are completed
         */
        doValidation : function(callback) {

            var numElements = this.size();

            // A function to determine whether or not all validation requests have been completed
            // and call the callback function if so.
            var doCallback = function() {
                numElements = numElements - 1;
                if (numElements <= 0) {
                    if ($.isFunction(callback)) {
                        callback.call();
                    }
                }
            };

            // Do validation on each element in the jQuery object
            this.each(function() {
                
                // Assume we're working with a form element
                var elem = $(this);
                var data = elem.data(NAMESPACE);
                var formInput = null;

                // If we are not working with a form element, get the form element
                if (!$(this).is("form")) {
                    elem = $(this).parents("form:first");
                    data = elem.data(NAMESPACE);
                    if (!data || !data.formInputs || $.inArray(this, data.formInputs) < 0) {
                        doCallback();
                        return;
                    }
                    formInput = $(this);
                }

                if (elem == null) {
                    doCallback();
                    return;
                }

                // Copy the ajax settings object into a new object
                var ajaxOpts = $.extend({}, data.ajaxOpts);

                // Set an ajax success function proxy, so we can do validation.
                // After we're done with validation, we'll call the original success function
                var successFn = ajaxOpts.success;
                ajaxOpts.success = function(responseData, textStatus) {
                    var validationData = data.dataHandler(responseData);
                    _validate(validationData, elem, formInput);
                    if (successFn && $.isFunction(successFn)) {
                        successFn.call(this, responseData, textStatus);
                    }
                };


                // Set a proxy to the ajax completion callback that determines whether or not
                // all ajax requests have been completed. If so, the main callback is called.
                var completionFn = ajaxOpts.complete;
                ajaxOpts.complete = function(xhr, textStatus) {
                    doCallback();
                    if (completionFn && $.isFunction(completionFn)) {
                        completionFn.call(this, xhr, textStatus);
                    }
                };

                // Set the query parameters
                ajaxOpts.data = elem.serialize();

                // Do the ajax call
                $.ajax(ajaxOpts);

            });
            
            return this;
        }
    });

    // --------------------- Internal functions ------------------------- //

    /**
     * The default data handler function. The default function assumes that the server is returning
     * json data exactly how this plugin requires it, so the default function just returns the
     * server response.
     *
     * Here is an example data object to be returned by this method:
     *
     * {
     *      submitted : false,
     *      globalErrors : [
     *          "This is error message #1",
     *          "This is error message #2",
     *      ],
     *      fieldErrorMap : {
     *          "fieldName1" : [
     *              "this is the first error message for fieldName1",
     *              "this is the seconde error message for fieldName1"
     *          ],
     *          "fieldName2" : [
     *              "this is the first error message for fieldName2"
     *          ]
     *      }
     *  }
     *
     * @param serverResponseData
     */
    function _defaultDataHandler(serverResponseData) {
        return serverResponseData;
    }

    /**
     * Binds the validation function to various "change" events of the various form elements.
     * Each different type of input element has a different "change" event". For example,
     * the <input> element's "change" event is onblur. The checkbox element's event is click,
     * and so on.
     * @param formElem the form element
     */
    function _bindInputEvents(formElem) {
        var elem = formElem;
        var data = elem.data(NAMESPACE);

        var getFieldValue = function(fieldElement) {
            fieldElement = $(fieldElement);
            var fieldValue = fieldElement.val();
            if (fieldElement.is(":checkbox")) {
                fieldValue = fieldElement.is(":checked") ? "on" : "off";
            }
            if ($(fieldElement).is(":radio")) {
                fieldValue = $(elem).find("input:radio[name=" + fieldElement.attr("name") + "]:checked").val();
            }
            return fieldValue;
        };

        for (var i = 0; i < data.formInputs.length; i++) {
            (function() {
                var formInput = $(data.formInputs[i]);
                var validationTimer = new Timer();

                // Set the intial values of each form input element
                data.previousValueMap[formInput.attr("name")] = getFieldValue(formInput);

                // Figure out which event name should be the "change" event for each element
                var bindEvent = "";
                if (formInput.is("select")) {
                    bindEvent = "change." + NAMESPACE + " keyup." + NAMESPACE;

                } else if (formInput.is(":checkbox")) {
                    bindEvent = "click." + NAMESPACE;

                } else if (formInput.is(":radio")) {
                    bindEvent = "click." + NAMESPACE;

                } else {
                    bindEvent = "blur." + NAMESPACE;
                }

                formInput.bind(bindEvent, function() {
                    var that = this;
                    var inputVal = getFieldValue(this);
                    if (inputVal == "" && inputVal != data.previousValueMap[$(this).attr("name")]) {
                        $(this).triggerHandler(VALIDATION_RESET_EVENT_NAME);
                        
                    } else if (inputVal != data.previousValueMap[$(this).attr("name")]) {
                        validationTimer.clear();
                        validationTimer.set(function() {
                            $(that).doValidation();
                        } , data.ajaxOpts.delay);
                    }
                    data.previousValueMap[$(this).attr("name")] = inputVal;
                });
            })();
        }

        elem.bind("submit." + NAMESPACE, function() {
            elem.doValidation();
        });
    }


    /**
     * The core validation function. This function will inspect the validationData object and
     * determine which fields have validation errors and if there are any global errors.
     *
     * If a field is determined to have a validation error, a validation error event will be
     * triggered for that field. If validation is successful for that field, a validation success
     * event will be triggered.
     *
     * If global errors are present, a validation error will be triggered for the form element.
     *
     * If there are no global or field errors, a validation success event will be triggered for the
     * form element.
     *
     * For an example of the formatting of the validation data object, 
     * please see _defaultDataHandler() for an example.
     *
     * @param validationData the validation data returned from the server
     * @param formElem the form element
     * @param formInputElem optional, if specified, only this element will have
     * validation events triggered for it.
     */
    function _validate(validationData, formElem, formInputElem) {
        var elem = formElem;
        var data = formElem.data(NAMESPACE);
        if (formInputElem) {
            formInputElem = $(formInputElem);
        }
        var hasErrors = false;

        var notificationElems = $(data.formInputs);
        if (formInputElem) {
            notificationElems = formInputElem;
            if (formInputElem.val() == "") {
                return;
            }
        }

        var notificationElemArr = notificationElems.get();
        var hasFieldErrors = false;
        for (var i = 0; i < notificationElemArr.length; i++) {
            var field = $(notificationElemArr[i]);
            var fieldErrors = validationData.fieldErrorMap[$(field).attr("name")];
            if (fieldErrors && fieldErrors.length > 0) {
                $(field).triggerHandler(VALIDATION_FAILURE_EVENT_NAME, [fieldErrors, validationData.globalErrors, validationData.fieldErrorMap]);
                hasFieldErrors = true;
            } else {
                $(field).triggerHandler(VALIDATION_SUCCESS_EVENT_NAME, [validationData.globalErrors, validationData.fieldErrorMap]);
            }
        }

        if (!formInputElem) {
            if ((validationData.globalErrors && validationData.globalErrors.length > 0) || hasFieldErrors) {
                if (!validationData.globalErrors) {
                    validationData.globalErrors = [];
                }
                if (!validationData.fieldErrorMap) {
                    validationData.fieldErrorMap = {};
                }
                formElem.triggerHandler(VALIDATION_FAILURE_EVENT_NAME, [validationData.globalErrors, validationData.fieldErrorMap]);
            } else {
                formElem.triggerHandler(VALIDATION_SUCCESS_EVENT_NAME);
            }
        }
    }

    /**
     * Internal function to get the first element from the jQuery object's element set.
     * Checks to ensure that the element is a "form" element.
     * @param jQueryObj the jquery object to get the element from.
     */
    function _getElement(jQueryObj) {
        if (!jQueryObj || jQueryObj.size() == 0 || !$(jQueryObj.get(0)).is("form")) {
            return null;
        }
        return $(jQueryObj.get(0));
    }


    // A timer class
    function Timer() {
        this.timer = null;
        var curFn;
        var curTimeout;

        this.set = function(fn, time) {
            this.clear();
            curFn = fn;
            curTimeout = time;
            this.timer = window.setTimeout(fn, time);
        };

        this.clear = function() {
            if(this.timer) {
                clearTimeout(this.timer);
            }
            this.timer = null;
            curFn = null;
            curTimeout = null;
        };

        this.reset = function() {
            if (curFn == null || curTimeout == null) { return; }
            this.set(curFn, curTimeout);
        };

        this.executeAndClear = function() {
            if (curFn) {
                curFn.call();
                this.clear();
            }
        };
    }

})(jQuery);