/**
 * vForm is a form validation class based on external rules defined in XML. The
 * rules are to be used on both the client and the server to ensure valid input.
 *
 * Released under the Creative Commons 2.0 License
 * http://creativecommons.org/licenses/by/2.0/
 *
 * If you find this solution useful, please let me know. Also, if you have any 
 * ideas on enhancements, don't hesitate to contact me.
 *
 * 
 * @author Samuel Sjöberg, http://samuelsjoberg.com
 */
function vForm(rulesUrl, properties) {
    this.version = "2.0.2";
    this.formName = undefined;

    if (typeof rulesUrl == 'undefined') {
        throw new Error("An URL with XML rules must be provided.");
    }

    /** Default properties. Used if properties are not defined at startup. */    
    if (typeof properties == 'undefined') {
        var properties = {
            validateOnBlur : true,
            listErrors : true,
            errorMessageId : 'errorMessage',
            focusClassName : 'focus',
            invalidClassName : 'invalid',
            validClassName : 'valid',
            skip : new Array('submit', 'button', 'reset', 'hidden', 'image')
        }
    }
    
    var groupRules = new Array('checkbox', 'radio');
            
    /** Hash map that holds the rules for all fields defined in the XML. */
    var rules = new vForm.Map();
    
    /** Contains the error messages for invalid fields. */
    var errors = new vForm.ErrorList(properties);
    
    var labels = new vForm.Map();
    
    /**
     * Reallocate validation event handlers. If the form has been modified 
     * through DOM manipulation or AJAX operations a refresh might be necessary.
     */
    var refresh = function() {
        var documentForms = typeof this.formName == 'undefined' ? 
                        document.getElementsByTagName('form') :
                        document.getElementsByName(this.formName);
        
        for (var i = 0, form; form = documentForms[i]; i++) {
            Event.stopObserving(form, 'submit', validate);
            Event.stopObserving(form, 'reset', reset);          
            Event.observe(form, 'submit', validate);
            Event.observe(form, 'reset', reset);

            var elements = Form.getElements(form);
            for (var j = 0, element; element = elements[j]; j++) {
                if (properties.skip.indexOf(element.type) < 0) {
                    if (groupRules.indexOf(element.type) >= 0) {
                        Event.stopObserving(element, 'change', elementChange);
                        Event.observe(element, 'change', elementChange);
                    } else {
                        Event.stopObserving(element, 'focus', elementFocus);
                        Event.stopObserving(element, 'blur', elementBlur);                   
                        Event.observe(element, 'focus', elementFocus);
                        Event.observe(element, 'blur', elementBlur);
                    }
                }
            }
            labels.clear();
            var formLabels = form.getElementsByTagName('label');
            for (var j = 0, label; label = formLabels[j]; j++) {
                labels.put(label.htmlFor, label);
            }
        }
    }
    
    /**
     * Validate the monitored form. This method is called on submission of the
     * form. Alternatively, it can be manually invoked to trigger the validation
     * without calling Form.submit().
     */
    var validate = function(event) {
    
        errors.clearAll();
        
        var form = Event.element(event);
        var elements = Form.getElements(form);
        for (var i = 0, element; element = elements[i]; i++) {
            if (properties.skip.indexOf(element.type) < 0) {
                validateElement(element, true);
            }
        }
        
        if (!errors.isEmpty()) {
            errors.display();
            Event.stop(event);
            return false;
        }
        
        return true;
    }
    
    /**
     * Reset the validation result and remove all error reports.
     * @param event the triggering event
     */
    var reset = function(event) {
        var form = Event.element(event);
        errors.clearAll();
        var elements = Form.getElements(form);
        for (var i = 0, element; element = elements[i]; i++) {
            if (properties.skip.indexOf(element.type) < 0) {
                resetValidationStatus(element);
            }
        }
    }
    
    var elementFocus = function(event) {
        var element = Event.element(event);
        if (!Element.hasClassName(element, properties.focusClassName)) {
            Element.addClassName(element, properties.focusClassName);
        }
    }
    
    var elementBlur = function(event) {
        var element = Event.element(event);
        Element.removeClassName(element, properties.focusClassName);
        
        if (properties.validateOnBlur) {
            validateElement(element);
        }        
    }
    
    var elementChange = function(event) {
        var element = Event.findElement(event, 'input');
        if (properties.validateOnBlur) {
            validateElement(element);
        }
    }
    
    /**
     * Validate an element. The element is validated against the rules specified
     * in its corresponding rule definition.
     * @param element the element to validate
     * @param log true if an error should be logged if validation fails
     */
    var validateElement = function(element, log) {
        var rule = rules.get(element.name);       
        
        if (typeof log == 'undefined') {
            log = false;
        }
        
        // Only validate if a rule exists and element is not disabled
        if (typeof rule != 'undefined' && !element.disabled) {  
            var isValid = rule.test(element);
            if (!isValid && log) {
                errors.log(element, rule.message);
            }
            
            // Take care of group member or single elements
            var elementGroup = element.form[element.name];
            if (elementGroup.length > 0 
                    && groupRules.indexOf(element.type) >= 0) {
                for (var i = 0, member; member = elementGroup[i]; i++) {
                    setValidationStatus(member, isValid);
                }
            } else {
                setValidationStatus(element, isValid);
            }
        }
    }
    
    /**
     * Set the validation status.
     * @param element the element to set
     * @param isValid true if element is valid
     */
    var setValidationStatus = function(element, isValid) {
        resetValidationStatus(element);
        var label = labels.get(element.id);
        var className = isValid ? properties.validClassName 
                                        : properties.invalidClassName;      
        
        if (groupRules.indexOf(element.type) < 0) {
            Element.addClassName(element, className);
        }
        
        if (typeof label != 'undefined') {
            Element.addClassName(label, className);
        }                
    }
    
    /**
     * Reset the CSS classes that indicate validation status.
     * @param element the element to reset.
     */
    var resetValidationStatus = function(element) {
        Element.removeClassName(element, properties.invalidClassName);
        Element.removeClassName(element, properties.validClassName);
        var label = labels.get(element.id);
        if (typeof label != 'undefined') {
            Element.removeClassName(label, properties.invalidClassName);
            Element.removeClassName(label, properties.validClassName);
        }
    }
        
    /**
     * Load the XML from an URL with an AJAX request.
     * @param url the URL of the XML rules
     */
    var loadXml = function(url) {
    
        var ajax = new Ajax.Request(url, { 
                method : 'get',
                onSuccess : initialize,
                onFailure : reportFailure
            });
        
        function reportFailure() {
            throw new Error('Could not load XML rules from location: ' + url);
        }
        
        function initialize(req) {
            try {
                loadRules(req.responseXML.documentElement);
            } catch (e) {
                throw new Error("Could not create vForm.Rules from XML document.");
            }        
            refresh(); // Setup event handlers.
        }
    }

    /**
     * Load rules from XML Document. The Document is created by the browser's 
     * XML parser which should be part of the XmlHttpRequest object.
     * @param xmlDocument XmlDocument Object
     */
    var loadRules = function(xmlDocument) {
        this.formName = xmlDocument.getAttribute('name') || undefined;      
        var fields = xmlDocument.getElementsByTagName('field');      
        for (var i = 0, field; field = fields[i]; i++) {
            rules.put(field.getAttribute('name'), new vForm.Rule(field)); 
        }
    }
    
    // Initialize vForm
    loadXml(rulesUrl);
    
    // Public interface
    this.refresh = refresh;
    this.validate = validate;
    this.reset = reset;
}

/**
 * Basic hash map structure. The hash map performs get, put and contains 
 * operations in constant time. The content of the hash map can be converted
 * into an array for iteration. The array is a collection of simple 
 * key-value pairs.
 *
 * @author Samuel Sjöberg, http://samuelsjoberg.com
 */
vForm.Map = function() {
    var keys = new Array();
    var map = new Array();
    
    this.put = function(key, value) {
        keys[keys.length] = key;
        map[key] = value;
    }
    
    this.get = function(key) {
        return map[key];
    }
    
    this.contains = function(key) {
        return !(typeof map[key] == 'undefined');
    }
    
    this.toArray = function() {
        var a = new Array();
        for (var i = 0, key; key = keys[i]; i++) {
            a[a.length] = {
                'key' : key,
                'value' : map[key]
            }
        }
        return a;
    }
    
    this.isEmpty = function() {
        return keys.length == 0;
    }
    
    this.clear = function() {
        keys = new Array();
        map = new Array();
    }
}

/**
 * Storage for validation error messages. All messages about invalid input is 
 * stored in this object together with references to fields and labels. This
 * object also inserts the errors into the HTML document by using DOM, or prompt
 * the user with an alert box.
 *
 * @author Samuel Sjöberg, http://samuelsjoberg.com
 */
vForm.ErrorList = function(properties) {
    
    var errors = new vForm.Map();
    
    this.log = function(element, message) {
        if (!errors.contains(element.name)) {
            errors.put(element.name, message);
        }
    }
    
    this.contains = function(element) {
        return errors.contains(element.name);
    }
    
    this.display = function() {
        var list = errors.toArray();         
        if ($(properties.errorMessageId) && properties.listErrors) {
            displayDiv(list);
        } else {
            displayPrompt(list);
        }
    }
    
    this.isEmpty = function() {
        return errors.isEmpty();
    }
    
    this.clearAll = function() {
        errors.clear();
        
        if ($(properties.errorMessageId) && properties.listErrors) {
            var div = $(properties.errorMessageId);
            var ul = div.getElementsByTagName('ul')[0];
            if (ul) {
                while (ul.hasChildNodes()) {
                    Event.stopObserving(ul.childNodes[0], 'click', selectElement);
                    ul.removeChild(ul.childNodes[0]);
                }
            } 
            div.style.display = 'none';
        }
    }
    
    var displayDiv = function(list) {
        var ul;
        var div = $(properties.errorMessageId);
        if (div.getElementsByTagName('ul').length > 0) {
            ul = div.getElementsByTagName('ul')[0];
        } else {
            ul = document.createElement('ul');
            div.appendChild(ul);
        }
        
        for (var i = 0, error; error = list[i]; i++) {
            var li = document.createElement('li');
            // We cannot use setAttribute if we want to support MSIE
            li.elementId = document.getElementsByName(error.key)[0].id;
            li.appendChild(document.createTextNode(error.value));
            Event.observe(li, 'click', selectElement);
            ul.appendChild(li);
        }
        div.style.display = 'block';
    }
    
    var displayPrompt = function(list) {
        var prompt = "";
        for (var i = 0, error; error = list[i]; i++) {
            prompt += error.value + '\n';
        }
        alert(prompt);
    }
    
    var selectElement = function(event) {
        try {
            var li = Event.findElement(event, 'li');
            if (typeof li.elementId != 'undefined') {
                var element = $(li.elementId);
                element.focus();
                if (typeof element.select == 'function') {
                    element.select();
                }
            }
        } catch (error) {
            // Non-existing element id. Ignore
        }
    }
}

/**
 * A representation of an XML rule block. A rule maps to a field. Upon 
 * validation, the field should validate itself against its rule's test method. 
 * If validation fails, the error message of the rule should be added to the 
 * error list and the field marked as invalid.
 *
 * @author Samuel Sjöberg, http://samuelsjoberg.com
 */
vForm.Rule = function(field) {
    this.name = field.getAttribute('name');
    
    if (field.getAttribute('optional')) {
        this.optional = field.getAttribute('optional') == 'true';
    } else {
        this.optional = false;
    }
    
    if (field.getAttribute('xml')) {
        this.xml = field.getAttribute('xml') == 'true';
    } else {
        this.xml = false;
    }
    
    this.message = field.getElementsByTagName('message')[0].childNodes[0].nodeValue;
    var rules = new vForm.Map();
    
    var xmlRules = field.getElementsByTagName('rule');
    for (var i = 0, rule; rule = xmlRules[i]; i++) {    
        var name = rule.getAttribute('name');
        var argument = undefined;        
        if (rule.hasChildNodes()) {
            argument = rule.childNodes[0].nodeValue;
        }
        rules.put(name, argument);
    }
    
    this.test = function(element) {
        var valid = true;
        
        if (this.optional == false) {
            valid &= this.required(element);
        }        
        
        var ruleArray = rules.toArray();          
        for (var i = 0, rule; rule = ruleArray[i]; i++) {
            if (!valid) {
                break;
            }            
            if (typeof this[rule.key] == 'function') {
                valid &= this[rule.key](element, rule.value);
            }
        }
        return valid;
    }
    
    this.toString = function() {
        return this.name + " optional: " + this.optional;
    }
}

vForm.Rule.prototype.required = function(element) {
    if (element.type == 'checkbox' || element.type == 'radio') {
        if (typeof element.form[element.name].length == 'undefined') {
            return element.checked;
        }
        return element.form[element.name].length > 0;
    }
    return !element.value.trim().isEmpty();
}

vForm.Rule.prototype.url = function(element) {
    if (element.value.isEmpty() && this.optional) {
        return true;
    }
    
    if (!/^https?:\/\//i.test(element.value)) {
        element.value = 'http://' + element.value;
    } 
    return /^https?:\/\/((?:[-a-z0-9]+\.)*)[-a-z0-9]+\.[a-z]{2,4}(\/?[-a-z0-9_:@&?=+,.!\/~*%$#]*)?$/i.test(element.value);
}

vForm.Rule.prototype.email = function(element) {
    if (element.value.isEmpty() && this.optional) {
        return true;
    }
    return /^[a-z0-9\.\-_\+]+@[a-z0-9\-_]+\.([a-z0-9\-_]+\.)*?[a-z]+$/i.test(element.value);
}

vForm.Rule.prototype.max = function(element, max) {
    var group = element.form[element.name];
    var checked = 0;    
    if (group.length) {
        for (var i = 0, member; member = group[i]; i++) {
            if (member.checked) {
                checked++;
            }
        }
    }    
    if (this.optional && checked == 0) {
        return true;
    }    
    return checked <= max;   
}

vForm.Rule.prototype.alpha = function(element) {
    if (element.value.trim().isEmpty() && this.optional) {
        return true;
    }
    return /^(\D)+$/.test(element.value)
}

vForm.Rule.prototype.numeric = function(element) {
    if (element.value.trim().isEmpty() && this.optional) {
        return true;
    }
    return /^(\d)+$/.test(element.value)
}

vForm.Rule.prototype.min = function(element, min) {
    var group = element.form[element.name];
    var checked = 0;
    if (group.length) {
        for (var i = 0, member; member = group[i]; i++) {
            if (member.checked) {
                checked++;
            }
        }
    }
    if (this.optional && checked == 0) {
        return true;
    }
    return checked >= min;
}

vForm.Rule.prototype.equals = function(element, equalName) {
    var equalTo = document.getElementsByName(equalName)[0];
    return element.value == equalTo.value;
}

vForm.Rule.prototype.expression = function(element, pattern) {
    if (element.value.isEmpty() && this.optional) {
        return true;
    }

    try {
        var re = new RegExp(pattern);
        return re.test(element.value);
    } catch (err) {
        return false;
    }
}

if (!String.prototype.trim) {
    String.prototype.trim = function() {
        return this.replace(/^\s+|\s+$/g, "");
    }
}
if (!String.prototype.isEmpty) {
    String.prototype.isEmpty = function() {
        return (this.trim().length <= 0) ? true : false;
    }
}