/*
 * web: validate.js
 * XNAT http://www.xnat.org
 * Copyright (c) 2016, Washington University School of Medicine and Howard Hughes Medical Institute
 * All Rights Reserved
 *
 * Released under the Simplified BSD.
 */

/*!
 * Form and value validation functions for XNAT
 * Some code adapted from http://rickharrison.github.com/validate.js
 */

var XNAT = getObject(XNAT);

(function(factory){
    if (typeof define === 'function' && define.amd) {
        define(factory);
    }
    else if (typeof exports === 'object') {
        module.exports = factory();
    }
    else {
        return factory();
    }
}(function(){

    var undefined, undef, validate;

    XNAT.validation = getObject(XNAT.validation || {});

    // HELPERS

    function isNull(value){
        return !!(value == null || value === '');
    }

    // copy of jQuery's $.isNumeric() method
    function isNumeric( num ) {
        return !isArray( num ) && (num - parseFloat( num ) + 1) >= 0;
    }

    // TODO: implement display of error messages
    var message = {
        required: 'The __NAME__ field is required.',
        matches: 'The __NAME__ field does not match the __VALUE__ field.',
        "default": 'The __NAME__ field is still set to default, please change.',
        email: 'The __NAME__ field must contain a valid email address.',
        emails: 'The __NAME__ field must contain all valid email addresses.',
        minLength: 'The __NAME__ field must be at least __VALUE__ characters in length.',
        maxLength: 'The __NAME__ field must not exceed __VALUE__ characters in length.',
        exactLength: 'The __NAME__ field must be exactly __VALUE__ characters in length.',
        greaterThan: 'The __NAME__ field must contain a number greater than __VALUE__.',
        lessThan: 'The __NAME__ field must contain a number less than __VALUE__.',
        alpha: 'The __NAME__ field must only contain alphabetical characters.',
        alphaNumeric: 'The __NAME__ field must only contain alpha-numeric characters.',
        alphaDash: 'The __NAME__ field must only contain alpha-numeric characters, underscores, and dashes.',
        numeric: 'The __NAME__ field must contain only numbers.',
        number: "The __NAME__ value must be of type 'number'.",
        integer: 'The __NAME__ field must contain an integer.',
        decimal: 'The __NAME__ field must contain a decimal number.',
        natural: 'The __NAME__ field must contain only positive numbers.',
        naturalNoZero: 'The __NAME__ field must contain a number greater than zero.',
        ip: 'The __NAME__ field must contain a valid IP.',
        base64: 'The __NAME__ field must contain a base64 string.',
        creditCard: 'The __NAME__ field must contain a valid credit card number.',
        fileType: 'The __NAME__ field must contain only __VALUE__ files.',
        validUrl: 'The __NAME__ field must contain a valid URL.',
        greaterThanDate: 'The __NAME__ field must contain a more recent date than __VALUE__.',
        lessThanDate: 'The __NAME__ field must contain an older date than __VALUE__.',
        greaterThanOrEqualDate: "The __NAME__ field must contain a date that's at least as recent as __VALUE__.",
        lessThanOrEqualDate: "The __NAME__ field must contain a date that's __VALUE__ or older."
    };

    // auto-generate alternate property names from camelCase names
    // creates hyphen-ated and under_score aliases
    // clutters up the namespace, but... oh, well
    forOwn(message, function(name){
        message[name.toLowerCase()] = message[name];  // lowercase names
        message[toDashed(name)]     = message[name];  // hyphen-ated names
        message[toUnderscore(name)] = message[name];  // under_score names
    });

    var regex = {
        //required: /[\S\W]+/,                // whitespace characters will still validate
        notEmpty: /[\S]/,                   // must contain more than just whitespace characters
        rule: /^(.+?)\[(.+)\]$/,            // ?
        //numeric: /^-?\d*\d{3}[,]*\d[.]*\d+$/,
        integer: /^-?[0-9]+$/,              // positive or negative whole number
        natural: /^[0-9]+$/,                // positive whole number
        naturalNoZero: /^([1-9]+[0-9]*)$/,  // positive whole number, no leading 0s
        decimal: /^(-?[0-9]*\.?[0-9])$/,
        hexadecimal: /^[0-9a-f]$/i,
        email: /^([a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))$/i,
        alpha: /^[a-z]$/i,                 // ONLY letters
        alphaSafe: /^[a-z_]$/i,            // ONLY letters and underscores
        alphaDash: /^[a-z_\-]$/i,          // ONLY letters, underscore, and dash
        alphaNum: /^[a-z0-9]$/i,           // ONLY letters and numbers
        alphaNumSafe: /^[a-z0-9_]$/i,      // ONLY letters, numbers, and underscore
        alphaNumDash: /^[a-z0-9_\-]$/i,    // ONLY letters, numbers, underscore, and dash
        alphaNumDashSpace: /^[a-z0-9_\- ]$/i, // ONLY letters, numbers, underscore, dash, and space
        idSafe: /^([a-z][a-z0-9_\-]*)$/i,     // safe to use as an ID - alphasafe and must start with a letter
        idStrict: /^([a-z][a-z0-9_]*)$/i,    // 'idSafe' without hyphens
        ip: /^(((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2}))$/i,
        base64: /^([^a-zA-Z0-9\/+=])$/i,
        numericDash: /^[\d\-\s]$/,
        //url: /^(((http|https):\/\/(\w+:{0,1}\w*@)?(\S+)|)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?)$/,
        //url: /^(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&/=]*))$/i,
        url: /^(https?:\/\/[^\/\s]+(\/.*)?)$/i, // keep it simple for less strict url validation
        //uri: /^(([\/](\w+:{0,1}\w*@)?(\S+)|)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/])))$/,
        uri: /^(\/\w*)/i, // simpler URI check only requires string start with a single '/'
        // these date regexes can't check leap years or other incorrect MM/DD combos
        dateISO: /^((19|20)\d\d([- /.])(0[1-9]|1[012])\2(0[1-9]|[12][0-9]|3[01]))$/,
        dateUS: /^((0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])[- /.](19|20)\d\d)$/,
        dateEU: /^((0[1-9]|[12][0-9]|3[01])[- /.](0[1-9]|1[012])[- /.](19|20)\d\d)$/,
        // CRON!!!!!  (Say it like "KHAN!!!!!")
        cronWords: /^@(reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)$/i,
        cronSeconds: /^((\*|\?|0|([1-9]|[1-5][0-9]))(\/\d+)?)$/,
        cronMinutes: /^((\*|\?|0|([1-9]|[1-5][0-9]))(\/\d+)?)$/,
        cronHours: /^((\*|\?|([0-9]|1[0-9]|2[0-3]))(\/\d+)?)$/,
        cronDay: /^((\*|\?|([0-9]|[1-2][0-9]|3[0-1]))(\/\d+)?)$/,
        cronMonth: /^((\*|\?|([0-9]|1[0-2]))(\/\d+)?)$/,
        cronMonths: /^(((\*|\?|([0-9]|1[0-2]))(\/\d+)?)|(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|DEC))$/i,
        cronMonthNames: /^(\*|\?|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|DEC)$/i,
        cronWeekday: /^((\*|\?|([0-7]))(\/\d+)?)$/,
        cronWeekdays: /^(((\*|\?|([0-7]))(\/\d+)?)|(MON|TUE|WED|THU|FRI|SAT|SUN))$/i,
        cronWeekdayNames: /^(\*|\?|MON|TUE|WED|THU|FRI|SAT|SUN)$/i,
        // cronAlt: /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
        // cron regex lifted from this post: http://stackoverflow.com/questions/235504/validating-crontab-entries-w-php
        // cron: /^\s*($|#|\w+\s*=|(\*(?:\/\d+)?|(?:[0-5]?\d)(?:-(?:[0-5]?\d)(?:\/\d+)?)?(?:,(?:[0-5]?\d)(?:-(?:[0-5]?\d)(?:\/\d+)?)?)*)\s+(\*(?:\/\d+)?|(?:[01]?\d|2[0-3])(?:-(?:[01]?\d|2[0-3])(?:\/\d+)?)?(?:,(?:[01]?\d|2[0-3])(?:-(?:[01]?\d|2[0-3])(?:\/\d+)?)?)*)\s+(\*(?:\/\d+)?|(?:0?[1-9]|[12]\d|3[01])(?:-(?:0?[1-9]|[12]\d|3[01])(?:\/\d+)?)?(?:,(?:0?[1-9]|[12]\d|3[01])(?:-(?:0?[1-9]|[12]\d|3[01])(?:\/\d+)?)?)*)\s+(\*(?:\/\d+)?|(?:[1-9]|1[012])(?:-(?:[1-9]|1[012])(?:\/\d+)?)?(?:,(?:[1-9]|1[012])(?:-(?:[1-9]|1[012])(?:\/\d+)?)?)*|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+(\*(?:\/\d+)?|(?:[0-6])(?:-(?:[0-6])(?:\/\d+)?)?(?:,(?:[0-6])(?:-(?:[0-6])(?:\/\d+)?)?)*|mon|tue|wed|thu|fri|sat|sun)\s+|(@reboot|@yearly|@annually|@monthly|@weekly|@daily|@midnight|@hourly)\s+)([^\s]+)\s+(.*)$/,
        bogus: /bogus/i // filler
    };
    // aliases
    regex.int = regex.integer;
    //regex.number = regex.numeric;
    regex.float = regex.decimal;
    regex.hex = regex.hexadecimal;
    regex.alphaNumeric = regex.alphaNum;
    regex.alphaNumericSafe = regex.alphaNumSafe;
    regex.ipAddr = regex.ipAddress = regex.ip;
    regex.fullUrl = regex.url;
    regex.path = regex.uri;
    regex.date = regex.dateISO;

    // auto-generate alternate property names from camelCase names
    // creates hyphen-ated and under_score aliases
    // clutters up the namespace, but... oh, well
    forOwn(regex, function(name){
        regex[name.toLowerCase()] = regex[name];  // lowercase names
        regex[toDashed(name)]     = regex[name];  // hyphen-ated names
        regex[toUnderscore(name)] = regex[name];  // under_score names
    });

    // export combined 'regex' object back to global namespace
    XNAT.validation.regex = extend(regex, XNAT.validation.regex || {});


    // define custom test methods for more complex validations
    var test = {};

    test.required = function(){
        // 'this' is the parent Validator instance
        return (new Validator(this.element)).required().validated;
    };

    test.empty = function(value){
        return !(value+'');
    };

    test.not = function(value, not){
        return (new Validator()).val(value).not(not).validated;
    };

    test.numeric = test.number = function(value){
        console.log('numeric');
        return isNumeric(value);
    };

    test.interval = function(value){
        console.log('interval');
        var parts = value.split(/([0-9]+)/);
        var units = /\s+(sec|second|min|minute|hour|day|week|month|year)(s)?\s*/;
        var num = true;
        var valid = value ? true : false;
        var i = parts[0] === '' ? 1 : 0; // start i at 1 if parts[0] is an empty string
        var part;
        while (parts[i] && valid === true) {
            part = (parts[i] + '');
            if (num) {
                valid = /\d+/.test(part);
            }
            else {
                valid = units.test(part);
            }
            num = !num; // flip for next iteration
            i++;
        }
        return valid;
    };

    // match to 6-field cron syntax:
    // 0 0 * * * *
    test.cron = test.cronSyntax = function(value){

        value = (value+'').trim();

        // easiest test - use words
        if (regex.cronWords.test(value)) {
            return true;
        }

        // split value to test parts
        var parts = value.split(/\s+/);

        // array of regexes to match 'parts' array
        var tests = [
            regex.cronSeconds,
            regex.cronMinutes,
            regex.cronHours,
            regex.cronDay,
            regex.cronMonths,
            regex.cronWeekdays
        ];

        var errors = 6;

        parts.forEach(function(part, i){
            errors = tests[i].test(part) ? errors - 1 : errors ;
        });

        return errors === 0;

    };

    // check a comma- or space-separated list of multiple email addresses
    test.emails = function(value){
        var errors = 0;
        value.split(/[,\s]+/).forEach(function(email){
            if (errors) return false;
            email = email.trim();
            if (!regex.email.test(email)) {
                errors++
            }
        });
        return errors === 0;
    };

    // make sure there's a minimum number of characters
    test.minLength = function(value, length){
        if (!regex.naturalNoZero.test(length)) {
            return false;
        }
        return (value.length >= parseInt(length, 10));
    };

    // don't exceed the maximum number of characters
    test.maxLength = function(value, length){
        if (!regex.naturalNoZero.test(length)) {
            return false;
        }
        return (value.length <= parseInt(length, 10));
    };

    test.exactLength = function(value, length){
        if (!regex.naturalNoZero.test(length)) {
            return false;
        }
        return (value.length === parseInt(length, 10));
    };
    test.isLength = test.exactLength;

    // XNAT.validate('#concurrent-sessions').is('greaterThan', 0).check();
    test.greaterThan = function(value, num){
        if (!regex.decimal.test(value)) {
            return false;
        }
        return (parseFloat(value) > parseFloat(num));
    };

    test.greaterThanOrEqual = function(value, num){
        if (!regex.decimal.test(value)) {
            return false;
        }
        return (parseFloat(value) >= parseFloat(num));
    };
    test.greaterThanOrEqualTo = test.greaterThanOrEqual;
    test.gte = test.greaterThanOrEqual;

    // XNAT.validate('#session-timeout').is('lessThan', 999).check();
    test.lessThan = function(value, num){
        if (!regex.decimal.test(value)) {
            return false;
        }
        return (parseFloat(value) < parseFloat(num));
    };

    test.lessThanOrEqual = function(value, num){
        if (!regex.decimal.test(value)) {
            return false;
        }
        return (parseFloat(value) <= parseFloat(num));
    };
    test.lessThanOrEqualTo = test.lessThanOrEqual;
    test.lte = test.lessThanOrEqual;

    test.equalTo = function(value, testValue){
        if (/^![^!]/.test(testValue)){
            return value+'' !== testValue+'';
        }
        return value+'' === testValue+''
    };
    test.equals = test.equalTo;
    test.eq = test.equalTo;

    // date checks
    test.greaterThanDate = function(value, date){
        var enteredDate = getValidDate(value),
            validDate   = getValidDate(date);
        if (!validDate || !enteredDate) {
            return false;
        }
        return enteredDate > validDate;
    };
    test.gtDate = test.greaterThanDate;
    test.greaterThanOrEqualDate = function(value, date){
        var enteredDate = getValidDate(value),
            validDate   = getValidDate(date);
        if (!validDate || !enteredDate) {
            return false;
        }
        return enteredDate >= validDate;
    };
    test.greaterThanOrEqualToDate = test.greaterThanOrEqualDate;
    test.gteDate = test.greaterThanOrEqualDate;
    test.lessThanDate = function(value, date){
        var enteredDate = getValidDate(value),
            validDate   = getValidDate(date);
        if (!validDate || !enteredDate) {
            return false;
        }
        return enteredDate < validDate;
    };
    test.ltDate = test.lessThanDate;
    test.lessThanOrEqualDate = function(value, date){
        var enteredDate = getValidDate(value),
            validDate   = getValidDate(date);
        if (!validDate || !enteredDate) {
            return false;
        }
        return enteredDate <= validDate;
    };
    test.lessThanOrEqualToDate = test.lessThanOrEqualDate;
    test.lteDate = test.lessThanOrEqualDate;


    // XNAT.validate('input.credit-card').is('creditCard').check();
    test.creditCard = function(value){
        // Luhn Check Code from https://gist.github.com/4075533
        // accept only digits, dashes or spaces
        if (!regex.numericDash.test(value)) return false;

        // The Luhn Algorithm. It's so pretty.
        var nCheck = 0, nDigit = 0, bEven = false;
        var strippedField = value.replace(/\D/g, "");

        for (var n = strippedField.length - 1; n >= 0; n--) {
            var cDigit = strippedField.charAt(n);
            nDigit = parseInt(cDigit, 10);
            if (bEven) {
                if ((nDigit *= 2) > 9) nDigit -= 9;
            }

            nCheck += nDigit;
            bEven = !bEven;
        }

        return (nCheck % 10) === 0;
    };

    // Check file extension of submitted file.
    //
    // XNAT.validate('input.doc[type="file"]').is('fileType', 'doc').check()
    //
    test.fileType = function(value, type){
        //if (type !== 'file') {
        //    return true;
        //}
        var ext       = value.substr((value.lastIndexOf('.') + 1)).toLowerCase(),
            typeArray = type.split(','),
            inArray   = false,
            i         = -1,
            len       = typeArray.length;

        while (++i < len) {
            if (ext == typeArray[i].toLowerCase()) {
                inArray = true;
            }
        }

        return inArray;
    };

    // auto-generate alternate property names from camelCase names
    // creates hyphen-ated and under_score aliases
    forOwn(test, function(name){
        test[name.toLowerCase()] = test[name];  // lowercase names
        test[toDashed(name)]     = test[name];  // hyphen-ated names
        test[toUnderscore(name)] = test[name];  // under_score names
    });

    // export combined 'test' object back to global namespace
    XNAT.validation.test = extend(test, XNAT.validation.test || {});


    // HELPERS

    function init(element){
        var obj = {
            element$: $.spawn('input.tmp|type=hidden'),
            len: 0,
            regex: '',
            value: '',
            values: [], // use to check more than one value
            validated: true // true until proven false
        };
        obj.element = obj.element$[0];
        if (element) {
            obj.element$ = $$(element).removeClass('valid invalid');
            obj.len = obj.element$.length;
            if (obj.len) {
                obj.element = obj.element$[0];
                obj.value = obj.element.value || '';
            }
        }
        return obj;
    }

    // TODO: get this working
    function getValidDate(date){

        var regexDateMatch = (
                date.match(regex['dateISO']) ||
                date.match(regex['dateUS']) ||
                date.match(regex['dateEU'])
        );

        if (!date.match('today') && !regexDateMatch) {
            return false;
        }

        var validDate = new Date(),
            validDateArray;

        if (!date.match('today')) {
            validDateArray = date.split(/[\s.-/]+/);
            validDate.setFullYear(validDateArray[0]);
            validDate.setMonth(validDateArray[1] - 1);
            validDate.setDate(validDateArray[2]);
        }

        return validDate;

    }


    // CONSTRUCTOR

    function Validator(element){
        extend(this, init(element));
    }

    Validator.fn = Validator.prototype;

    Validator.fn.init = function(element){
        if (element){
            extend(this, init(element));
        }
        return this;
    };

    // reset element so it can be validated again
    Validator.fn.reset = function(element){
        extend(this, init(element||this.element));
        return this;
    };

    // explicitly set a value to check
    // XNAT.validate().val('bar@foo.org').is('email').check();
    // -> true
    Validator.fn.val = function(value){
        this.value = value;
        return this;
    };

    Validator.fn.trim = function(){
        this.trimValue = true;
        this.element$.each(function(){
            this.value = this.value.trim();
        });
        return this;
    };

    // set className to valid/invalid
    Validator.fn.setClass = function(){
        var className = this.validated ? 'valid': 'invalid';
        this.element$
            .removeClass('valid invalid')
            .addClass(className);
    };

    Validator.fn.is = function(type, args){

        var parts = [];

        // check all if there's more than
        // one element in the selection
        if (this.len > 1) {
            this.all(type, args);
            return this;
        }

        // return early if the validation is already false
        // (this is necessary for working with chained methods)
        if (this.validated === false) { return this }

        // skip on* types
        if (/^on/i.test(type)) {
            return this;
        }

        if (this.trimValue) {
            this.value = (this.value+'').trim();
        }

        // set 'allowEmpty' flag
        if (type === 'allow-empty') {
            this.allowEmpty = true;
            return this;
        }

        if (typeof type === 'string') {
            parts = type.split(':');
            type = parts.shift();
            if (parts.length) {
                this.is(type, parts.join(':'));
                return this;
            }
        }

        // start with '!' for negation
        // !eq:0
        if (/^![^!]/.test(type)){
            this.not(type.replace(/^!/, ''), args);
            return this;
        }

        // if there's a test['test'] method, use that
        if (typeof test[type] === 'function') {
            this.validated = test[type].apply(this, [].concat(this.value, args));
        }
        // if there's a regex defined (above) for 'type', use that
        else if (regex[type]) {
            this.pattern(regex[type]);
            // this.validated = regex[type].test(this.value);
        }
        // if 'type' is a string, number or boolean, do a string comparison
        else if (/string|number|boolean/i.test(typeof type)) {
            this.validated = test.equals.apply(this, [].concat(this.value, type, args));
        }
        // a 'type' function can also be passed
        // (must return boolean true or false)
        else if (typeof type === 'function') {
            this.validated = type.apply(this, [].concat(args));
        }
        // otherwise do a regex test
        else {
            try {
                this.pattern(type);
                // this.validated = type.test(this.value);
            }
            catch(e) {
                console.log(e);
            }
        }

        // let empty string validate if 'allowEmpty' is true
        if (this.allowEmpty && (this.value+'').trim() === ''){
            this.validated = true;
        }

        this.setClass();

        return this;
    };

    Validator.fn.not = function(type, args){
        this.is(type, args);
        this.validated = !this.validated;
        this.setClass();
        return this;
    };

    // validate all elements in a collection
    // XNAT.validate('input.float').all('float');
    Validator.fn.all = function(type, args){
        // var self = this;
        var invalid = 0;
        this.elements = this.element$.toArray();
        if (!type) return this;
        // if type is specified, check each element
        this.element$.each(function(){
            var elValidate = new Validator(this);
            elValidate.is(type, args);
            //valid = regex[type].test(this.value);
            if (!elValidate.isValid(true)) {
                invalid++
            }
        });
        this.validated = invalid === 0;
        return this;
    };

    Validator.fn.none = function(type){
        // make sure NONE of the elements match the type
    };

    Validator.fn.pattern = function(regex){
        this.regex = (typeof regex === 'string') ? new RegExp(regex) : regex;
        this.validated = regex.test(this.value);
        this.setClass();
        return this;
    };

    // match the value of another element
    // optionally trimming leading and trailing whitespace
    Validator.fn.matches = function(target, trim){
        var sourceValue = this.value + '';
        var targetValue = $$(target).val() + '';
        if (trim) {
            sourceValue = sourceValue.trim();
            targetValue = targetValue.trim();
        }
        this.validated = sourceValue === targetValue;
        this.setClass();
        return this;
    };

    // XNAT.validate('#url').required().check();
    Validator.fn.required = function(){
        this.all(function(){
            if (/checkbox|radio/.test(this.element$.type)) {
                return (this.checked === true);
            }
            return this.value+'' !== '';
        });
        return this;
    };

    // set up shortcut methods (uses test[type]() methods)
    // example:
    // XNAT.validate('#sessions-concurrent-max').lessThan(1000).check();
    [   'minLength', 'maxLength', 'exactLength', 'isLength',
        'greaterThan', 'gt', 'greaterThanOrEqual', 'greaterThanOrEqualTo', 'gte',
        'lessThan', 'lt', 'lessThanOrEqual', 'lessThanOrEqualTo', 'lte',
        'equalTo', 'equals', 'fileType'   ].forEach(function(method) {

        Validator.fn[method] = function (test) {
            this.is(method, test);
            return this;
        }

    });

    // .valid() must be called LAST
    // XNAT.validate('#email').trim().is('email').valid(true);
    Validator.fn.valid = function(bool){
        bool = (bool === undefined) ? true : bool;
        return bool ? this.validated : !this.validated;
    };
    Validator.fn.isValid = Validator.fn.valid;
    //
    // call *either* .valid() -OR- .check() last
    //
    // XNAT.validate('input.email').is('email').valid(true);
    //
    // --OR--
    //
    // XNAT.validate().value('foo').check(function(){ return this.value === 'foo' });
    //
    // .check() must be called last
    // XNAT.validate('#email').check('email');
    //
    // type can be regex['type'] string,
    // function (must return true or false),
    // or custom regex
    Validator.fn.check = function(type){
        var self = this,
            types = [];
        if (type) {
            this.is(type);
        }
        else if (type !== false) {
            if (this.element$.dataAttr('validate')) {
                types = this.element$.dataAttr('validate').split(/\s+/);
                $.each(types, function(idx, item){
                    // stop if validation has already failed
                    if (!self.validated) {
                        return false;
                    }
                    // skip on* types
                    if (!/^on/i.test(type)) {
                        self.is(item);
                    }
                })
            }
        }
        return this.isValid(true);
    };

    // usage:
    // XNAT.validate('#user-email').trim().is('email').check();

    validate = function(element){
        return new Validator(element);
    };


    // TODO: move the date methods below to {test} object: test['greaterThanDate']() etc...
    validate.check = {

        greater_than_date: function(field, date){
            var enteredDate = getValidDate(field.value),
                validDate   = getValidDate(date);
            if (!validDate || !enteredDate) {
                return false;
            }
            return enteredDate > validDate;
        },

        less_than_date: function(field, date){
            var enteredDate = getValidDate(field.value),
                validDate   = getValidDate(date);
            if (!validDate || !enteredDate) {
                return false;
            }
            return enteredDate < validDate;
        },

        greater_than_or_equal_date: function(field, date){
            var enteredDate = getValidDate(field.value),
                validDate   = getValidDate(date);
            if (!validDate || !enteredDate) {
                return false;
            }
            return enteredDate >= validDate;
        },

        less_than_or_equal_date: function(field, date){
            var enteredDate = getValidDate(field.value),
                validDate   = getValidDate(date);
            if (!validDate || !enteredDate) {
                return false;
            }
            return enteredDate <= validDate;
        }
    };


    // add event listeners for validation
    $(function(){

        var $body = $('body');

        $body.on('focus', ':input[data-validate]', function(){
            $(this).removeClass('valid invalid');
        });

        $body.on('blur', ':input[data-validate].onblur', function(){
            validate(this).check();
        });

        // TODO: enable this after testing validation methods more thoroughly
        // $body.on('submit', 'form.validate', function(e){
        //     e.preventDefault();
        //     var errors = 0,
        //         $form = $(this);
        //     $form.find(':input[data-validate]').not('.ignore').each(function(){
        //         var valid = validate(this).check();
        //         if (!valid) { errors++ }
        //     });
        //     return errors === 0;
        //     // if (errors === 0){
        //     //     //$form.removeClass('validate').submit();
        //     //     return true;
        //     // }
        //     // return false;
        // });

    });

    // this script has loaded
    validate.loaded = true;

    return XNAT.validate = validate;

}));