/**
 * Forms.js
 * Form validation. By including this script, the validation code attaches itself
 * automatically to all forms on a page and executes on form submission. This
 * script is fairly dependent on the form HTML and the following assumptions are
 * made:
 * - The form is broken into "fields" which can contain multiple form elements.
 *   These fields are contained in <li> inside <ul> and no form elements appear
 *   outside these structures.
 * - The fields are labelled to indicate which are required and / or who's values
 *   must follow particular patterns. These requirements are indicated by class 
 *   names on the field <li> tags. The class names can be found below as values of
 *   the Form.properties object (and can be modified if needed).
 * - The name of the field is the first text node within the first <label> element
 *   in each field <li>.
 * - If the form has any required fields, there is a hidden <div> within the <form>
 *   that contains the text of a message that will appear if any required fields are
 *   empty.  The class of this <div> must be the concatenation of 
 *   Form.properties.ErrorPrefix and Form.properties.Required.
 *   The text within this <div> will be followed by a list of the labels of the 
 *   fields that are required but are empty.
 * - If any fields have any pattern requirements, there is a hidden <div> within the
 *   field <li> that contains the text of a message that will appear if the field's
 *   form elements do not match the pattern. The class of this <div> must be the
 *   concatenation of Form.properties.ErrorPrefix and the appropriate 
 *   Form.properties value.  The test within this div will appear by itself.
 * - For any fields that trigger failure, this script will apply a class to the 
 *   field <li> with the value Form.properties.ErrorPrefix and the appropriate 
 *   Form.properties value. The class Form.properties.GenericError is also applied to
 *   all problem fields. By setting styles to these classes in your css, you can make
 *   the problem fields highlight is some way.
 * - Any further validation of the form values beyond what this script is capable
 *   of will be stored in a method of the form with the name "doExtraValidation"
 *   which is called after this script's validation and which must return true if
 *   the form passes the extra validation or false if it fails.
 */
 
var Form = {

  properties: {
    
    // class names that can be applied to form elements to specify required fields or value requirements
    // Required - For text fields, a non-whitespace value must be entered.
    //            For checkboxes, checkbox must be checked.
    //            For selects, an option must be selected.
    //            For radio buttons, one button in a group must be selected
    //            (one, some, or all buttons in a group can have the class).
    // PatternEmail - Value must be in the form of an email address.
    // PatternZipcode - Value must be in the form of a US zipcode (ie. 12345 or
    //                  12345-1234).
    // PatternPostalcode - Value must be in the form of a CDN postal code.
    // PatternZipOrPostal - Value must be in the form of a US zipcode or a CDN
    //                      postal code.
    // PatternDate - Value must be in the form yyyymmdd (all numbers).
    // PatternNumbers - Value must contain only numbers.
    Required: 'required',
    PatternEmail: 'pattern-email',
    PatternZipCode: 'pattern-zipcode',
    PatternPostalCode: 'pattern-postalcode',
    PatternZipOrPostal: 'pattern-postalzipcode',
    PatternDate: 'pattern-date',
    PatternNumbers: 'pattern-numbers',
    
    // PatternLength requires the min length and max length (both required) appended to the class name
    // ie. 'pattern-length-4-12' for a value that must be between 4 and 12 characters long
    // for no minimum length, use 0 for the first number: ie. 'pattern-length-0-12'
    // for no maximum length, use a large number for the 2nd number: ie 'pattern-length-4-9999'
    PatternLength: 'pattern-length',
    
    // Match requires the name (not the id) of the matching field appended to the class name
    // ie. 'match-password1' means that the current field's value must equal the value of the password1 field
    Match: 'match',
    
    // prefix for error messages classes; a full error class name would be this prefix + a class name from above (ie 'error-pattern-email')
    ErrorPrefix: 'error-',
    
    // instead of specifying css for all the possible error message classes, you can just apply your css to the GenericError class
    // which is always applied to any field that causes an error along with the specific error class
    GenericErrorSuffix: 'generic',
    
    // in the absence of any child <div> of the <form> with a class of (Form.properties.Error + Form.properties.Required),
    // the following will appear as the error message when required fields are missing and will be followed by a list of the missing required fields
    ErrorMessageRequired: 'Missing required fields:',
    
    // in the absence of any child <div> of a field <li> with a class of (Form.properties.Error + any Form.properties error name),
    // the following will appear as the error message when a field fails any error test other than a required test
    // and will be followed by the name of the field
    ErrorMessageOther: ' is not in the correct format.'
  },

  /**
   * Register any event listeners, register any child elements with appropriate
   * classes and do any other object initialization.  In this case, call
   * validation on form submit and register Form.Field methods on form fields
   * of form.
   */
  initialize: function() {
    
    // event listener registration
    this.behavior = function() {
      addEvent(this, 'submit', Form.doBasicValidation);
    };
    this.behavior();
    
    // register child classes
    var fields = this.getFields()
    registerMethods(fields, Form.Field);
    
    // add reference from all fields back to this form
    for (var fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) {
      fields[fieldIndex].form = this;
    }
  },

  /**
   * Called by an event listener attached to form submit. This function has
   * been named so that the event listener can be unattached from the event if
   * needed. Because it is being attached using addEvent(), avoid using the 'this'
   * keyword because it won't work in IE - use e.target instead.
   */
  doBasicValidation: function(e) {
    e = window.event ? fixIEEvent(window.event) : e;
    var form = e.target;
    var fields = form.getFields();
    
    // clean up from past validations: clear all error classes on form and all fields
    form.clearAllErrors();
    
    // get any errors with form data
    var errors = form.getErrors();
    if (errors.length) {
      
      // put missing required fields before any other errors
      errors.sort(function(a,b) { return (a.failedTest == Form.properties.Required && b.failedTest != Form.properties.Required) ? -1 : 1; });

      // report missing required fields only if there are any
      if (errors[0].failedTest == Form.properties.Required) {
        
        // turn on the required error class for form
        form.showError(Form.properties.Required);
        
        // for each missing-required-fields error, turn on error class on missing fields
        for (var errorIndex = 0; errorIndex < errors.length; errorIndex++) {
          var error = errors[errorIndex];
          if (error.failedTest == Form.properties.Required) {
            error.field.showError(Form.properties.Required);
          }
        }
      }
      
      // no missing required fields
      else {
      
        // for all other errors, turn on error class on missing fields and append field-specific error message to the error message
        for (var errorIndex = 0; errorIndex < errors.length; errorIndex++) {
          var error = errors[errorIndex];
          if (error.failedTest != Form.properties.Required) {
            error.field.showError(error.failedTest);
          }
        }
      }
      
      // do extra validation
      if (form.doExtraValidation) {
        form.doExtraValidation();
      }
      
      // don't submit the form
      e.stopPropagation();
      e.preventDefault();
    }
    
    // if there is any extra validation method attached to the form, do those as well
    else if (form.doExtraValidation && form.doExtraValidation() == false) {
      e.stopPropagation();
      e.preventDefault();
    }
  },
      
  /**
   * Get a list of all form fields. This is different from form elements in
   * that fields include the element, label, hidden error messages, etc. As per
   * the current XSL, fields are contained in <li> inside <ul>.
   * @Returns An array of fields that make up the form.
   */
  getFields: function() {
    var lis = this.getElementsByTagName('li');
    var fields = new Array();
    
    for (var liIndex = 0; liIndex < lis.length; liIndex++) {
      li = lis.item(liIndex);
      fields.push(li);
    }
    return fields;
  },
  
  /**
   * Applies an error class to the form.
   * @Param One of the Form.properties error types.
   */
  showError: function(errorType) {
    Element.addClassName(this, Form.properties.ErrorPrefix + errorType);
  },

  /**
   * Removes an error class from the form.
   * @Param One of the Form.properties error types.
   */
  clearError: function(errorType) {
    Element.removeClassName(this, Form.properties.ErrorPrefix + errorType);
  },

  /**
   * Removes all error classes (all classes starting with Form.properties.ErrorClassPrefix) from the form and all fields.
   */
  clearAllErrors: function() {
    var classNames = this.className.split(' ');
    for (var i = classNames.length - 1; i >= 0; i--) {
      if (classNames[i].indexOf(Form.properties.ErrorPrefix) == 0) {
        Element.removeClassName(this, classNames[i]);
      }
    }
    
    // clear field errors
    var fields = this.getFields();
    for (var fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) {
      fields[fieldIndex].clearAllErrors();
    }
  },
  
  /**
   * Returns an array of all elements in a form that failed validation and the
   * name of the test that failed for each.
   * To apply a test to a form element apply a special class name to the form
   * element tag.  See Form.properties for class names and meanings.
   * @Returns An array of objects of the form { field, failedTest }.
   *          field = reference to the form field.
   *          failedTest = the Form.properties name of the test that failed.
   *          Or null if all tests validated.
   */   
  getErrors: function() {
    var errors = [];
    
    // see Form.Field.Validator.inputRadio for this variable
    this.radioGroups = [];
    
    // loop thru fields and test for errors on each
    // storing any error messages in an array along with the field reference
    var fields = this.getFields();
    for (var i = 0; i < fields.length; i++) {
      var error = fields[i].getErrors();
      if (isDefined(error)) {
        errors.push({ field: fields[i], failedTest: error });
      }
    }
    return errors;
  }
};

Form.Field = {

  /**
   * Cache to store array of elements after first call to getElements().
   */
  elements: null,

  /**
   * Reference to form containing the field.
   */
  form: null,
  
  /**
   * Register any event listeners, register any child elements with appropriate
   * classes and do any other object initialization.
   */
  initialize: function() { /* do nothing */ },

  /**
   * Returns an array of all the form elements that make up the field.
   * @Returns An array of all elements in the field.
   */
  getElements: function() {
    if (this.elements) {
      return this.elements;
    }
    var inputs = $A(this.getElementsByTagName('input'));
    var selects = $A(this.getElementsByTagName('select'));
    var textareas = $A(this.getElementsByTagName('textarea'));
    return this.elements = (new Array()).concat(inputs, selects, textareas);
  },

  /**
   * Returns the label of the field.
   * @Returns The first text node of the first label element in the field
   */
  getLabel: function() {
    return this.getElementsByTagName('label')[0].firstChild.nodeValue;
  },
  
  /**
   * Applies an error class to the field.
   * @Param One of the Form.properties error types.
   */
  showError: function(errorType) {
    Element.addClassName(this, Form.properties.ErrorPrefix + errorType);
    Element.addClassName(this, Form.properties.ErrorPrefix + Form.properties.GenericErrorSuffix);
  },

  /**
   * Removes an error class from the field.
   * @Param One of the Form.properties error types.
   */
  clearError: function(errorType) {
    Element.removeClassName(this, Form.properties.ErrorPrefix + errorType);
    Element.removeClassName(this, Form.properties.ErrorPrefix + Form.properties.GenericErrorSuffix);
  },

  /**
   * Removes all error classes (all classes starting with Form.properties.ErrorClassPrefix) from the field.
   */
  clearAllErrors: function() {
    var classNames = this.className.split(' ');
    for (var i = classNames.length - 1; i >= 0; i--) {
      if (classNames[i].indexOf(Form.properties.ErrorPrefix) == 0) {
        Element.removeClassName(this, classNames[i]);
      }
    }
  },

  /**
   * Returns the name of the first validation test that fails on a form field.
   * All form elements in a field must pass the test for the field to pass.
   * See Form.getInvalidFields() for more details.
   * @Returns The Form.properties name of the test that failed.
   *          Or null if all tests validated.
   */   
  getErrors: function() {
    var elements = this.getElements();
    for (var elementIndex = 0; elementIndex < elements.length; elementIndex++) {
      var element = elements[elementIndex];
      var error = Form.Field.Validator[element.tagName.toLowerCase()](this, element);
      if (error) {
        return error;
      }
    }
  }
};

/**
 * Methods to validate form elements.  Called thru Form.Field.getErrors().
 * Not intended to be called directly.
 */
Form.Field.Validator = {

  input: function(field, element) {
    switch (element.type.toLowerCase()) {
      case 'password':
      case 'text':
        return Form.Field.Validator.textarea(field, element);
      case 'checkbox':  
        return Form.Field.Validator.inputCheckbox(field, element);
      case 'radio':
        return Form.Field.Validator.inputRadio(field, element);
    }
  },

  inputCheckbox: function(field, element) {
    if (Form.Field.Validator.requiresTest(field, Form.properties.Required) && !element.checked) {
      return Form.properties.Required;
    }
  },

  /**
   * Note: This function tests a whole radio button group (all radio buttons with 
   * the same name as the radio button passed into the function) instead of just a
   * single radio button.  The radioGroups associative array contains flags that
   * prevents radio button sets from being tested multiple times.
   */
  inputRadio: function(field, element) {
    if (Form.Field.Validator.requiresTest(field, Form.properties.Required) && 
        (!element.form.radioGroups || !element.form.radioGroups[element.name]) ) {
      element.form.radioGroups[element.name] = true;
      for (radio in element.form[element.name]) {
        if (radio.checked) {
          return null;
        }
      }
      return Form.properties.Required;
    }
  },

  textarea: function(field, element) {
    
    // trim any leading or trailing whitespace from value in element
    var value = Form.Field.Validator.cleanTextValue(element.value);
    
    if (Form.Field.Validator.requiresTest(field, Form.properties.Required) &&
        value === '') {
      return Form.properties.Required;
    }
    if (Form.Field.Validator.requiresTest(field, Form.properties.PatternEmail) && value != '' &&
        value.search(/^\w+((\.|-)\w+)*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/) == -1) {
      return Form.properties.PatternEmail;
    }
    if (Form.Field.Validator.requiresTest(field, Form.properties.PatternZipCode) && value != '' &&
        value.search(/^\d{5}(-\d{4})?$/) == -1) {
      return Form.properties.PatternZipCode;
    }
    if (Form.Field.Validator.requiresTest(field, Form.properties.PatternPostalCode) && value != '' &&
        value.search(/^[a-zA-Z]\d[a-zA-Z](-| )?\d[a-zA-Z]\d$/) == -1) {
      return Form.properties.PatternPostalCode;
    }
    if (Form.Field.Validator.requiresTest(field, Form.properties.PatternZipOrPostal) && value != '' &&
        value.search(/^\d{5}(-\d{4})?$/) == -1 && value.search(/^[a-zA-Z]\d[a-zA-Z](-| )?\d[a-zA-Z]\d$/) == -1) {
      return Form.properties.PatternZipOrPostal;
    }
    if (Form.Field.Validator.requiresTest(field, Form.properties.PatternDate) && value != '' &&
        (value.length != 8 || value.search(/^\d+$/) == -1 || parseInt(value.substring(4,6)) > 12 || parseInt(value.substring(6,8)) > 31)) {
      return Form.properties.PatternDate;
    }
    if (Form.Field.Validator.requiresTest(field, Form.properties.PatternNumbers) && value != '' &&
        value.search(/^(\d|-|,|.| )+$/) == -1) {
      return Form.properties.PatternNumbers;
    }
    if (Form.Field.Validator.requiresTest(field, Form.properties.PatternLength + '-\\d+-\\d+') && value != '' &&
        (value.length < parseInt(field.className.match(new RegExp(Form.properties.PatternLength + '-(\\d+)'))[1]) ||
         value.length > parseInt(field.className.match(new RegExp(Form.properties.PatternLength + '-\\d+-(\\d+)'))[1]))) {
      return Form.properties.PatternLength;
    }
    var matchRegExpResult = field.className.match(new RegExp(Form.properties.Match + '-(\\S+)'));
    if (Form.Field.Validator.requiresTest(field, Form.properties.Match + '-\\S+') && 
        field.form.elements[matchRegExpResult[1]] &&
        value != Form.Field.Validator.cleanTextValue(field.form.elements[matchRegExpResult[1]].value)) {
      return Form.properties.Match;
    }
  },
  
  select: function(field, element) {
    if (Form.Field.Validator.requiresTest(field, Form.properties.Required) && element.selectedIndex == -1) {
      return Form.properties.Required;
    }
  },
  
  requiresTest: function(field, test) {
    return Element.hasClassName(field, test);
  },
  
  cleanTextValue: function(value) {
    return value.replace(/^\s+/, '').replace(/\s+$/, '');
  }
};


/**********************************************************************
   Utility functions - generic so should be moved to somewhere central
 *********************************************************************/

function isDefined(property) {
  return (typeof property != 'undefined');
}


/**********************************************************************
   Basic Event Registration
   has issues - see http://www.quirksmode.org/blog/archives/2005/08/addevent_consid.html
 *********************************************************************/

function addEvent(object, eventName, functionRef) {
  if (isDefined(window.addEventListener)) {
    object.addEventListener(eventName, functionRef, false);
  }
  else if (isDefined(window.attachEvent)) {
    object.attachEvent('on' + eventName, functionRef);
  }
}


/**********************************************************************
   Register methods defined on a class to a list of elements
 *********************************************************************/

registerMethods = function(elements, classRef) {
  for (var elementIndex = 0; elementIndex < elements.length; elementIndex++) {
    element = elements[elementIndex]
    for (property in classRef) {
      if (classRef[property] instanceof Function) {
        element[property] = classRef[property].bind(element);
      }
    }
    
    // call initialize method to do any object initialization
    if (element.initialize) {
      element.initialize();
    }
  }
};


/**********************************************************************
   Fix IE events
 *********************************************************************/

fixIEEvent = function(e) {
  e.stopPropagation = fixIEEvent.stopPropagation;
  e.preventDefault = fixIEEvent.preventDefault;
  e.relatedTarget = e.fromElement || e.toElement;
  e.pageX = event.clientX + (document.documentElement.scrollLeft || document.body.scrollLeft);
  e.pageY = event.clientY + (document.documentElement.scrollTop || document.body.scrollTop);
  e.target = e.srcElement;
  return e;
}
   
fixIEEvent.stopPropagation = function() {
  this.cancelBubble = true;
}
   
fixIEEvent.preventDefault = function() {
  this.returnValue = false;
}


/**********************************************************************
   Functions from Prototype needed for this script
 *********************************************************************/

if (!isDefined(window.Prototype)) {
  Function.prototype.bind = function() {
    var __method = this, args = $A(arguments), object = args.shift();
    return function() {
      return __method.apply(object, args.concat($A(arguments)));
    }
  }

  var $A = Array.from = function(iterable) {
    if (!iterable) return [];
    if (iterable.toArray) {
      return iterable.toArray();
    } else {
      var results = [];
      for (var i = 0, length = iterable.length; i < length; i++)
        results.push(iterable[i]);
      return results;
    }
  }
  
  if (!window.Element) {
    var Element = new Object();
  }
  
  Element.hasClassName = function(element, className) {
    var elementClassName = element.className;
    if (elementClassName.length == 0) return false;
    if (elementClassName == className ||
        elementClassName.match(new RegExp("(^|\\s)" + className + "(\\s|$)")))
      return true;
    return false;
  };
  
  Element.addClassName = function(element, classNameToAdd) {
    if (Element.hasClassName(element, classNameToAdd)) {
      return;
    }
    element.className = element.className.split(' ').concat(classNameToAdd).join(' ');
  };
  
  Element.removeClassName = function(element, classNameToRemove) {
    if (!Element.hasClassName(element, classNameToRemove)) {
      return;
    }
    element.className = element.className.replace(new RegExp('\\s*' + classNameToRemove + '\\s*', 'g'), '');
  };
}
 
// attach Form methods to all forms when document finishes loading
addEvent(window, 'load', function() { registerMethods(document.getElementsByTagName('form'), Form) } );

