/**
 * 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";
   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');
         li.setAttribute('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 (li.hasAttribute('elementId')) {
            var element = $(li.getAttribute('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') {
      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;
   }
}