emotionLoop/visualCaptcha-frontend-jquery

View on GitHub
visualcaptcha.jquery.src.js

Summary

Maintainability
D
2 days
Test Coverage
/*! visualCaptcha - v0.0.8 - 2016-01-23
* http://visualcaptcha.net
* Copyright (c) 2016 emotionLoop; Licensed MIT */

(function( root, factory ) {
    if ( typeof define === 'function' && define.amd ) {
        define( [ 'jquery' ], factory );
    } else {
        factory( root.jQuery );
    }
}( this, function( $ ) {/**
 * @license almond 0.2.9 Copyright (c) 2011-2014, The Dojo Foundation All Rights Reserved.
 * Available via the MIT or new BSD license.
 * see: http://github.com/jrburke/almond for details
 */
//Going sloppy to avoid 'use strict' string cost, but strict practices should
//be followed.
/*jslint sloppy: true */
/*global setTimeout: false */

var requirejs, require, define;
(function (undef) {
    var main, req, makeMap, handlers,
        defined = {},
        waiting = {},
        config = {},
        defining = {},
        hasOwn = Object.prototype.hasOwnProperty,
        aps = [].slice,
        jsSuffixRegExp = /\.js$/;

    function hasProp(obj, prop) {
        return hasOwn.call(obj, prop);
    }

    /**
     * Given a relative module name, like ./something, normalize it to
     * a real name that can be mapped to a path.
     * @param {String} name the relative name
     * @param {String} baseName a real name that the name arg is relative
     * to.
     * @returns {String} normalized name
     */
    function normalize(name, baseName) {
        var nameParts, nameSegment, mapValue, foundMap, lastIndex,
            foundI, foundStarMap, starI, i, j, part,
            baseParts = baseName && baseName.split("/"),
            map = config.map,
            starMap = (map && map['*']) || {};

        //Adjust any relative paths.
        if (name && name.charAt(0) === ".") {
            //If have a base name, try to normalize against it,
            //otherwise, assume it is a top-level require that will
            //be relative to baseUrl in the end.
            if (baseName) {
                //Convert baseName to array, and lop off the last part,
                //so that . matches that "directory" and not name of the baseName's
                //module. For instance, baseName of "one/two/three", maps to
                //"one/two/three.js", but we want the directory, "one/two" for
                //this normalization.
                baseParts = baseParts.slice(0, baseParts.length - 1);
                name = name.split('/');
                lastIndex = name.length - 1;

                // Node .js allowance:
                if (config.nodeIdCompat && jsSuffixRegExp.test(name[lastIndex])) {
                    name[lastIndex] = name[lastIndex].replace(jsSuffixRegExp, '');
                }

                name = baseParts.concat(name);

                //start trimDots
                for (i = 0; i < name.length; i += 1) {
                    part = name[i];
                    if (part === ".") {
                        name.splice(i, 1);
                        i -= 1;
                    } else if (part === "..") {
                        if (i === 1 && (name[2] === '..' || name[0] === '..')) {
                            //End of the line. Keep at least one non-dot
                            //path segment at the front so it can be mapped
                            //correctly to disk. Otherwise, there is likely
                            //no path mapping for a path starting with '..'.
                            //This can still fail, but catches the most reasonable
                            //uses of ..
                            break;
                        } else if (i > 0) {
                            name.splice(i - 1, 2);
                            i -= 2;
                        }
                    }
                }
                //end trimDots

                name = name.join("/");
            } else if (name.indexOf('./') === 0) {
                // No baseName, so this is ID is resolved relative
                // to baseUrl, pull off the leading dot.
                name = name.substring(2);
            }
        }

        //Apply map config if available.
        if ((baseParts || starMap) && map) {
            nameParts = name.split('/');

            for (i = nameParts.length; i > 0; i -= 1) {
                nameSegment = nameParts.slice(0, i).join("/");

                if (baseParts) {
                    //Find the longest baseName segment match in the config.
                    //So, do joins on the biggest to smallest lengths of baseParts.
                    for (j = baseParts.length; j > 0; j -= 1) {
                        mapValue = map[baseParts.slice(0, j).join('/')];

                        //baseName segment has  config, find if it has one for
                        //this name.
                        if (mapValue) {
                            mapValue = mapValue[nameSegment];
                            if (mapValue) {
                                //Match, update name to the new value.
                                foundMap = mapValue;
                                foundI = i;
                                break;
                            }
                        }
                    }
                }

                if (foundMap) {
                    break;
                }

                //Check for a star map match, but just hold on to it,
                //if there is a shorter segment match later in a matching
                //config, then favor over this star map.
                if (!foundStarMap && starMap && starMap[nameSegment]) {
                    foundStarMap = starMap[nameSegment];
                    starI = i;
                }
            }

            if (!foundMap && foundStarMap) {
                foundMap = foundStarMap;
                foundI = starI;
            }

            if (foundMap) {
                nameParts.splice(0, foundI, foundMap);
                name = nameParts.join('/');
            }
        }

        return name;
    }

    function makeRequire(relName, forceSync) {
        return function () {
            //A version of a require function that passes a moduleName
            //value for items that may need to
            //look up paths relative to the moduleName
            return req.apply(undef, aps.call(arguments, 0).concat([relName, forceSync]));
        };
    }

    function makeNormalize(relName) {
        return function (name) {
            return normalize(name, relName);
        };
    }

    function makeLoad(depName) {
        return function (value) {
            defined[depName] = value;
        };
    }

    function callDep(name) {
        if (hasProp(waiting, name)) {
            var args = waiting[name];
            delete waiting[name];
            defining[name] = true;
            main.apply(undef, args);
        }

        if (!hasProp(defined, name) && !hasProp(defining, name)) {
            throw new Error('No ' + name);
        }
        return defined[name];
    }

    //Turns a plugin!resource to [plugin, resource]
    //with the plugin being undefined if the name
    //did not have a plugin prefix.
    function splitPrefix(name) {
        var prefix,
            index = name ? name.indexOf('!') : -1;
        if (index > -1) {
            prefix = name.substring(0, index);
            name = name.substring(index + 1, name.length);
        }
        return [prefix, name];
    }

    /**
     * Makes a name map, normalizing the name, and using a plugin
     * for normalization if necessary. Grabs a ref to plugin
     * too, as an optimization.
     */
    makeMap = function (name, relName) {
        var plugin,
            parts = splitPrefix(name),
            prefix = parts[0];

        name = parts[1];

        if (prefix) {
            prefix = normalize(prefix, relName);
            plugin = callDep(prefix);
        }

        //Normalize according
        if (prefix) {
            if (plugin && plugin.normalize) {
                name = plugin.normalize(name, makeNormalize(relName));
            } else {
                name = normalize(name, relName);
            }
        } else {
            name = normalize(name, relName);
            parts = splitPrefix(name);
            prefix = parts[0];
            name = parts[1];
            if (prefix) {
                plugin = callDep(prefix);
            }
        }

        //Using ridiculous property names for space reasons
        return {
            f: prefix ? prefix + '!' + name : name, //fullName
            n: name,
            pr: prefix,
            p: plugin
        };
    };

    function makeConfig(name) {
        return function () {
            return (config && config.config && config.config[name]) || {};
        };
    }

    handlers = {
        require: function (name) {
            return makeRequire(name);
        },
        exports: function (name) {
            var e = defined[name];
            if (typeof e !== 'undefined') {
                return e;
            } else {
                return (defined[name] = {});
            }
        },
        module: function (name) {
            return {
                id: name,
                uri: '',
                exports: defined[name],
                config: makeConfig(name)
            };
        }
    };

    main = function (name, deps, callback, relName) {
        var cjsModule, depName, ret, map, i,
            args = [],
            callbackType = typeof callback,
            usingExports;

        //Use name if no relName
        relName = relName || name;

        //Call the callback to define the module, if necessary.
        if (callbackType === 'undefined' || callbackType === 'function') {
            //Pull out the defined dependencies and pass the ordered
            //values to the callback.
            //Default to [require, exports, module] if no deps
            deps = !deps.length && callback.length ? ['require', 'exports', 'module'] : deps;
            for (i = 0; i < deps.length; i += 1) {
                map = makeMap(deps[i], relName);
                depName = map.f;

                //Fast path CommonJS standard dependencies.
                if (depName === "require") {
                    args[i] = handlers.require(name);
                } else if (depName === "exports") {
                    //CommonJS module spec 1.1
                    args[i] = handlers.exports(name);
                    usingExports = true;
                } else if (depName === "module") {
                    //CommonJS module spec 1.1
                    cjsModule = args[i] = handlers.module(name);
                } else if (hasProp(defined, depName) ||
                           hasProp(waiting, depName) ||
                           hasProp(defining, depName)) {
                    args[i] = callDep(depName);
                } else if (map.p) {
                    map.p.load(map.n, makeRequire(relName, true), makeLoad(depName), {});
                    args[i] = defined[depName];
                } else {
                    throw new Error(name + ' missing ' + depName);
                }
            }

            ret = callback ? callback.apply(defined[name], args) : undefined;

            if (name) {
                //If setting exports via "module" is in play,
                //favor that over return value and exports. After that,
                //favor a non-undefined return value over exports use.
                if (cjsModule && cjsModule.exports !== undef &&
                        cjsModule.exports !== defined[name]) {
                    defined[name] = cjsModule.exports;
                } else if (ret !== undef || !usingExports) {
                    //Use the return value from the function.
                    defined[name] = ret;
                }
            }
        } else if (name) {
            //May just be an object definition for the module. Only
            //worry about defining if have a module name.
            defined[name] = callback;
        }
    };

    requirejs = require = req = function (deps, callback, relName, forceSync, alt) {
        if (typeof deps === "string") {
            if (handlers[deps]) {
                //callback in this case is really relName
                return handlers[deps](callback);
            }
            //Just return the module wanted. In this scenario, the
            //deps arg is the module name, and second arg (if passed)
            //is just the relName.
            //Normalize module name, if it contains . or ..
            return callDep(makeMap(deps, callback).f);
        } else if (!deps.splice) {
            //deps is a config object, not an array.
            config = deps;
            if (config.deps) {
                req(config.deps, config.callback);
            }
            if (!callback) {
                return;
            }

            if (callback.splice) {
                //callback is an array, which means it is a dependency list.
                //Adjust args if there are dependencies
                deps = callback;
                callback = relName;
                relName = null;
            } else {
                deps = undef;
            }
        }

        //Support require(['a'])
        callback = callback || function () {};

        //If relName is a function, it is an errback handler,
        //so remove it.
        if (typeof relName === 'function') {
            relName = forceSync;
            forceSync = alt;
        }

        //Simulate async callback;
        if (forceSync) {
            main(undef, deps, callback, relName);
        } else {
            //Using a non-zero value because of concern for what old browsers
            //do, and latest browsers "upgrade" to 4 if lower value is used:
            //http://www.whatwg.org/specs/web-apps/current-work/multipage/timers.html#dom-windowtimers-settimeout:
            //If want a value immediately, use require('id') instead -- something
            //that works in almond on the global level, but not guaranteed and
            //unlikely to work in other AMD implementations.
            setTimeout(function () {
                main(undef, deps, callback, relName);
            }, 4);
        }

        return req;
    };

    /**
     * Just drops the config on the floor, but returns req in case
     * the config return value is used.
     */
    req.config = function (cfg) {
        return req(cfg);
    };

    /**
     * Expose module registry for debugging and tooling
     */
    requirejs._defined = defined;

    define = function (name, deps, callback) {

        //This module may not have dependencies
        if (!deps.splice) {
            //deps is not an array, so probably means
            //an object literal or factory function for
            //the value. Adjust args.
            callback = deps;
            deps = [];
        }

        if (!hasProp(defined, name) && !hasProp(waiting, name)) {
            waiting[name] = [name, deps, callback];
        }
    };

    define.amd = {
        jQuery: true
    };
}());

define("almond", function(){});

/*global define */

define( 'visualcaptcha/core',[],function() {
    'use strict';

    var _addUrlParams,
        _refresh,
        _startUrl,
        _imageUrl,
        _audioUrl,
        _imageValue,
        _isRetina,
        _supportsAudio;

    _addUrlParams = function( config, url, params ) {
        params = params || [];

        if ( config.namespace && config.namespace.length > 0 ) {
            params.push( config.namespaceFieldName + '=' + config.namespace );
        }

        params.push( config.randomParam + '=' + config.randomNonce );

        return url + '?' + params.join( '&' );
    };

    _refresh = function( config ) {
        var core = this,
            startURL;

        // Set loading state
        config.applyRandomNonce();
        config.isLoading = true;

        // URL must be loaded after nonce is applied
        startURL = _startUrl( config );

        config._loading( core );

        if ( config.callbacks.loading ) {
            config.callbacks.loading( core );
        }

        config.request( startURL, function( response ) {
            // We need now to set the image and audio field names
            if ( response.audioFieldName ) {
                config.audioFieldName = response.audioFieldName;
            }

            if ( response.imageFieldName ) {
                config.imageFieldName = response.imageFieldName;
            }

            // Set the correct image name
            if ( response.imageName ) {
                config.imageName = response.imageName;
            }

            // Set the correct image values
            if ( response.values ) {
                config.imageValues = response.values;
            }

            // Set loaded state
            config.isLoading = false;
            config.hasLoaded = true;

            config._loaded( core );

            if ( config.callbacks.loaded ) {
                config.callbacks.loaded( core );
            }
        } );
    };

    _startUrl = function( config ) {
        var url = config.url + config.routes.start + '/' + config.numberOfImages;

        return _addUrlParams( config, url );
    };

    _imageUrl = function( config, i ) {
        var url = '',
            params = [];

        // Is the image index valid?
        if ( i < 0 || i >= config.numberOfImages ) {
            return url;
        }

        // If retina is required, add url param
        if ( this.isRetina() ) {
            params.push( 'retina=1' );
        }

        url = config.url + config.routes.image + '/' + i;

        return _addUrlParams( config, url, params );
    };

    _audioUrl = function( config, ogg ) {
        var url = config.url + config.routes.audio;

        if ( ogg ) {
            url += '/ogg';
        }

        return _addUrlParams( config, url );
    };

    _imageValue = function( config, i ) {
        if ( i >= 0 && i < config.numberOfImages ) {
            return config.imageValues[ i ];
        }

        return '';
    };

    //
    // Check for device/browser capabilities
    //
    _isRetina = function() {
      // Check if the device is retina-like
      return ( window.devicePixelRatio !== undefined && window.devicePixelRatio > 1 );
    };

    // Check if the device supports the HTML5 audio element, for accessibility
    // I'm using an IIFE just because I don't want audioElement to be in the rest of the scope
    _supportsAudio = function() {
        var audioElement,
            support = false;

        try {
            audioElement = document.createElement( 'audio' );
            if ( audioElement.canPlayType ) {
                support = true;
            }
        } catch( e ) {}

        return support;
    };

    return function( config ) {
        var core,
            refresh,
            isLoading,
            hasLoaded,
            numberOfImages,
            imageName,
            imageValue,
            imageUrl,
            audioUrl,
            imageFieldName,
            audioFieldName,
            namespace,
            namespaceFieldName;

        refresh = function() {
            return _refresh.call( this, config );
        };

        isLoading = function() {
            return config.isLoading;
        };

        hasLoaded = function() {
            return config.hasLoaded;
        };

        numberOfImages = function() {
            return config.imageValues.length;
        };

        imageName = function() {
            return config.imageName;
        };

        imageValue = function( index ) {
            return _imageValue.call( this, config, index );
        };

        imageUrl = function( index ) {
            return _imageUrl.call( this, config, index );
        };

        audioUrl = function( ogg ) {
            return _audioUrl.call( this, config, ogg );
        };

        imageFieldName = function() {
            return config.imageFieldName;
        };

        audioFieldName = function() {
            return config.audioFieldName;
        };

        namespace = function() {
            return config.namespace;
        };

        namespaceFieldName = function() {
            return config.namespaceFieldName;
        };

        core = {
            refresh: refresh,
            isLoading: isLoading,
            hasLoaded: hasLoaded,
            numberOfImages: numberOfImages,
            imageName: imageName,
            imageValue: imageValue,
            imageUrl: imageUrl,
            audioUrl: audioUrl,
            imageFieldName: imageFieldName,
            audioFieldName: audioFieldName,
            namespace: namespace,
            namespaceFieldName: namespaceFieldName,
            isRetina: _isRetina,
            supportsAudio: _supportsAudio
        };

        // Load the data if auto refresh is enabled
        if ( config.autoRefresh ) {
            core.refresh();
        }

        return core;
    };
} );
/*global define */

define( 'visualcaptcha/xhr-request',[],function() {
    'use strict';

    var XMLHttpRequest = window.XMLHttpRequest;

    return function( url, callback ) {
        var ajaxRequest = new XMLHttpRequest();

        ajaxRequest.open( 'GET', url, true );
        ajaxRequest.onreadystatechange = function() {
            var response;

            if ( ajaxRequest.readyState !== 4 || ajaxRequest.status !== 200 ) {
                return;
            }

            response = JSON.parse( ajaxRequest.responseText );
            callback( response );
        };

        ajaxRequest.send();
    };
} );
/*global define */

define('visualcaptcha/config',[ 'visualcaptcha/xhr-request' ], function( xhrRequest ) {
    'use strict';

    return function( options ) {
        var urlArray = window.location.href.split( '/' );
        urlArray[urlArray.length-1]='';

        var config = {
            /* REQUEST */
            request: xhrRequest,
            url: urlArray.join( '/' ).slice(0, -1),
            namespace: '',
            namespaceFieldName: 'namespace',
            routes: {
                start: '/start',
                image: '/image',
                audio: '/audio'
            },
            isLoading: false,
            hasLoaded: false,
            /* STATE */
            autoRefresh: true,
            numberOfImages: 6,
            randomNonce: '',
            randomParam: 'r',
            audioFieldName: '',
            imageFieldName: '',
            imageName: '',
            imageValues: [],
            /* CALLBACKS */
            callbacks: {},
            _loading: function() {},
            _loaded: function() {}
        };

        // Update and return the random nonce
        config.applyRandomNonce = function() {
            return ( config.randomNonce = Math.random().toString( 36 ).substring( 2 ) );
        };

        // We don't want to extend config, just allow setting a few of its options
        if ( options.request ) {
            config.request = options.request;
        }

        if ( options.url ) {
            config.url = options.url;
        }

        if ( options.namespace ) {
            config.namespace = options.namespace;
        }

        if ( options.namespaceFieldName ) {
            config.namespaceFieldName = options.namespaceFieldName;
        }

        if ( typeof options.autoRefresh !== 'undefined' ) {
            config.autoRefresh = options.autoRefresh;
        }

        if ( options.numberOfImages ) {
            config.numberOfImages = options.numberOfImages;
        }

        if ( options.routes ) {
            if ( options.routes.start ) {
                config.routes.start = options.routes.start;
            }

            if ( options.routes.image ) {
                config.routes.image = options.routes.image;
            }

            if ( options.routes.audio ) {
                config.routes.audio = options.routes.audio;
            }
        }

        if ( options.randomParam ) {
            config.randomParam = options.randomParam;
        }

        if ( options.callbacks ) {
            if ( options.callbacks.loading ) {
                config.callbacks.loading = options.callbacks.loading;
            }

            if ( options.callbacks.loaded ) {
                config.callbacks.loaded = options.callbacks.loaded;
            }
        }

        if ( options._loading ) {
          config._loading = options._loading;
        }

        if ( options._loaded ) {
          config._loaded = options._loaded;
        }

        return config;
    };
} );
/*global define */

define( 'visualcaptcha',['require','visualcaptcha/core','visualcaptcha/config'],function( require ) {
    'use strict';

    var core = require( 'visualcaptcha/core' ),
        config = require( 'visualcaptcha/config' );

    return function( options ) {
        options = options || {};

        return core( config( options ) );
    };
} );
/*global define */

define( 'visualcaptcha/templates',[],function() {
    'use strict';

    var _t,
        _buttonsHTML,
        _accessibilityHTML,
        _imagesHTML,
        _audioInputHTML,
        _imageInputHTML,
        _namespaceInputHTML;

    // Template engine
    _t = function( str, d ) {
        for ( var p in d ) {
            str = str.replace( new RegExp( '{' + p + '}', 'g' ), d[ p ] );
        }

        return str;
    };

    // Generate refresh and accessibility buttons HTML
    _buttonsHTML = function( captcha, language, path ) {
        var btnAccessibility,
            btnRefresh,
            string,
            params;

         btnAccessibility =
            '<div class="visualCaptcha-accessibility-button">' +
                '<a href="#"><img src="{path}accessibility{retinaExtra}.png" title="{accessibilityTitle}" alt="{accessibilityAlt}" /></a>' +
            '</div>';

        btnRefresh =
            '<div class="visualCaptcha-refresh-button">' +
                '<a href="#"><img src="{path}refresh{retinaExtra}.png" title="{refreshTitle}" alt="{refreshAlt}" /></a>' +
            '</div>';

        string =
            '<div class="visualCaptcha-button-group">' +
                btnRefresh +
                ( captcha.supportsAudio() ? btnAccessibility : '' ) +
            '</div>';

        params = {
            path: path || '',
            refreshTitle: language.refreshTitle,
            refreshAlt: language.refreshAlt,
            accessibilityTitle: language.accessibilityTitle,
            accessibilityAlt: language.accessibilityAlt,
            retinaExtra: captcha.isRetina() ? '@2x' : ''
        };

        return _t( string, params );
    };

    // Generate accessibility option and audio element HTML
    _accessibilityHTML = function( captcha, language ) {
        var string,
            params;

        if ( !captcha.supportsAudio() ) {
            return '';
        }

        string =
            '<div class="visualCaptcha-accessibility-wrapper visualCaptcha-hide">' +
                '<div class="accessibility-description">{accessibilityDescription}</div>' +
                '<audio preload="preload">' +
                    '<source src="{audioURL}" type="audio/ogg" />' +
                    '<source src="{audioURL}" type="audio/mpeg" />' +
                '</audio>' +
            '</div>';

        params = {
            accessibilityDescription: language.accessibilityDescription,
            audioURL: captcha.audioUrl(),
            audioFieldName: captcha.audioFieldName()
        };

        return _t( string, params );
    };

    // Generate images HTML
    _imagesHTML = function( captcha, language ) {
        var images = '',
            string,
            params;

        for ( var i = 0, l = captcha.numberOfImages(); i < l; i++ ) {
            string =
                '<div class="img">' +
                    '<a href="#"><img src="{imageUrl}" id="visualCaptcha-img-{i}" data-index="{i}" alt="" title="" /></a>' +
                '</div>';

            params = {
                imageUrl: captcha.imageUrl( i ),
                i: i
            };

            images += _t( string, params );
        }

        string =
            '<p class="visualCaptcha-explanation">{explanation}</p>' +
            '<div class="visualCaptcha-possibilities">{images}</div>';

        params = {
            imageFieldName: captcha.imageFieldName(),
            explanation: language.explanation.replace( /ANSWER/, captcha.imageName() ),
            images: images
        };

        return _t( string, params );
    };

    _audioInputHTML = function( captcha ) {
        var string,
            params;

        string =
            '<input class="form-control audioField" type="text" name="{audioFieldName}" value="" autocomplete="off" />';

        params = {
            audioFieldName: captcha.audioFieldName()
        };

        return _t( string, params );
    };

    _imageInputHTML = function( captcha, imageIndex ) {
        var string,
            params;

        string =
            '<input class="form-control imageField" type="hidden" name="{imageFieldName}" value="{value}" readonly="readonly" />';

        params = {
            imageFieldName: captcha.imageFieldName(),
            value: captcha.imageValue( imageIndex )
        };

        return _t( string, params );
    };

    _namespaceInputHTML = function( captcha ) {
        var string,
            params,
            namespace = captcha.namespace();

        // Ensure namespace is present
        if ( !namespace || namespace.length === 0 ) {
            return '';
        }

        string =
            '<input type="hidden" name="{fieldName}" value="{value}" />';

        params = {
            fieldName: captcha.namespaceFieldName(),
            value: namespace
        };

        return _t( string, params );
    };

    return {
        buttons: _buttonsHTML,
        accessibility: _accessibilityHTML,
        images: _imagesHTML,
        audioInput: _audioInputHTML,
        imageInput: _imageInputHTML,
        namespaceInput: _namespaceInputHTML
    };
} );
/*global define */

define( 'visualcaptcha/language',[],function() {
    'use strict';

    return {
        accessibilityAlt: 'Sound icon',
        accessibilityTitle: 'Accessibility option: listen to a question and answer it!',
        accessibilityDescription: 'Type below the <strong>answer</strong> to what you hear. Numbers or words:',
        explanation: 'Click or touch the <strong>ANSWER</strong>',
        refreshAlt: 'Refresh/reload icon',
        refreshTitle: 'Refresh/reload: get new images and accessibility option!'
    };
} );
/*global define */

define( 'visualcaptcha.jquery',[
    'jquery',
    'visualcaptcha',
    'visualcaptcha/templates',
    'visualcaptcha/language'
], function( $, visualCaptcha, templates, language ) {
    'use strict';

    var _request,
        _loading,
        _loaded,
        _toggleAccessibility,
        _chooseImage,
        _refreshCaptcha,
        _getCaptchaData;

    // Request function using jQuery's $.get
    _request = function( url, callback ) {
        $.get( url, callback, 'json' );
    };

    // callback on loading
    _loading = function() {};

    // callback on loaded
    _loaded = function( config, element, captcha ) {
        var captchaHTML;

        captchaHTML =
            // Add namespace input, if present
            templates.namespaceInput( captcha ) +
            // Add audio element, if supported
            templates.accessibility( captcha, config.language ) +
            // Add image elements
            templates.images( captcha, config.language ) +
            // Add refresh and accessibility buttons
            templates.buttons( captcha, config.language, config.imgPath );

        // Actually add the HTML
        element.html( captchaHTML );
    };

    // Toggle accessibility option
    _toggleAccessibility = function() {
        var captchaElement = $( this ).closest( '.visualCaptcha' ),
            accessibilityWrapper = captchaElement.find( '.visualCaptcha-accessibility-wrapper' ),
            possibilitiesWrapper = captchaElement.find( '.visualCaptcha-possibilities' ),
            explanation = captchaElement.find( '.visualCaptcha-explanation' ),
            audio = accessibilityWrapper.find( 'audio' ),
            audioInputHTML;

        if ( accessibilityWrapper.hasClass( 'visualCaptcha-hide' ) ) {
            // Hide images and explanation
            possibilitiesWrapper.toggleClass( 'visualCaptcha-hide' );
            explanation.toggleClass( 'visualCaptcha-hide' );

            // Reset selected images and input value
            possibilitiesWrapper.find( '.img' ).removeClass( 'visualCaptcha-selected' );
            explanation.find( 'input' ).val( '' );

            // Build the input HTML
            audioInputHTML = templates.audioInput( captchaElement.data( 'captcha' ) );

            // Add the input before the audio element
            $( audioInputHTML ).insertBefore( audio );

            // Show the accessibility wrapper
            accessibilityWrapper.toggleClass( 'visualCaptcha-hide' );

            // Play the audio
            audio[ 0 ].load();
            audio[ 0 ].play();
        } else {
            // Stop audio, delete input element, show images
            audio[ 0 ].pause();

            // Hide the accessibility wrapper
            accessibilityWrapper.toggleClass( 'visualCaptcha-hide' );

            // Delete the input element
            accessibilityWrapper.find( 'input' ).remove();

            // Show images and explanation
            explanation.toggleClass( 'visualCaptcha-hide' );
            possibilitiesWrapper.toggleClass( 'visualCaptcha-hide' );
        }
    };

    // Choose image
    _chooseImage = function() {
        var image = $( this ),
            captchaElement = image.closest( '.visualCaptcha' ),
            possibilitiesWrapper = captchaElement.find( '.visualCaptcha-possibilities' ),
            explanation = captchaElement.find( '.visualCaptcha-explanation' ),
            imageIndex,
            imageInput,
            imageInputHTML;

        // Check if an input element already exists
        imageInput = explanation.find( 'input' );

        if ( imageInput ) {
            // Remove it if so
            imageInput.remove();

            // Remove selected class from selected image
            possibilitiesWrapper.find( '.img' ).removeClass( 'visualCaptcha-selected' );
        }

        // Add selected class to image
        image.addClass( 'visualCaptcha-selected' );

        // Get the image index
        imageIndex = image.find( 'img' ).data( 'index' );

        // Build the input HTML
        imageInputHTML = templates.imageInput( captchaElement.data( 'captcha' ), imageIndex );

        // Append the input
        explanation.append( $( imageInputHTML ) );
    };

    // Refresh the captcha
    _refreshCaptcha = function() {
        var captchaElement = $( this ).closest( '.visualCaptcha' );

        captchaElement.data( 'captcha' ).refresh();
    };

    _getCaptchaData = function( element ) {
        var image = element.find( '.imageField' ),
            audio = element.find( '.audioField' ),
            valid = !! ( image.val() || audio.val() );

        return valid ? {
            valid: valid,
            name:  image.val() ? image.attr( 'name' )  : audio.attr( 'name' ),
            value: image.val() ? image.val() : audio.val()
        } : {
            valid: valid
        };
    };

    $.fn.visualCaptcha = function( options ) {
        var config;

        config = $.extend( {
            imgPath: '/',
            language: language,
            captcha: {
                request: _request
            }
        }, options );

        this
            // Add visualCaptcha class to element
            .addClass( 'visualCaptcha' )
            // Bind accessibility button
            .on( 'click', '.visualCaptcha-accessibility-button', _toggleAccessibility )
            // Bind refresh button
            .on( 'click', '.visualCaptcha-refresh-button', _refreshCaptcha )
            // Bind images
            .on( 'click', '.visualCaptcha-possibilities .img', _chooseImage );

        return this.each( function() {
            var element = $( this ),
                captcha,
                captchaConfig;

            captchaConfig = $.extend( config.captcha, {
                _loading: _loading.bind( null, config, element ),
                _loaded: _loaded.bind( null, config, element )
            } );

            // Load namespace from data-namespace attribute on element
            if ( typeof element.data( 'namespace' ) !== 'undefined' ) {
                captchaConfig.namespace = element.data( 'namespace' );
            }

            captcha = visualCaptcha( captchaConfig );

            captcha.getCaptchaData = _getCaptchaData.bind( null, element );

            // Initialize visualCaptcha
            element.data( 'captcha', captcha );
        } );
    };
} );
    define( 'jquery', function() {
        return $;
    } );

    require( 'visualcaptcha.jquery' );
} ));