(function ($, window, undefined) {
    'use strict';

    // Create the defaults once
    var pluginName = 'conditionals',
        defaults = {
            /**
             * This callback type is called `onInit` and offers the plugin caller a chance to run custom code
             * on plugin initialization.
             *
             * @type Function
             * @callback onInit
             */
            onInit: function () {},

            /**
             * An array of Rule objects.
             *
             * @type Object[] e.g.
             * {
             *     name: 'field_name',
             *     // These are the fields that will trigger the conditions to be checked.
             *     triggers: ['field_name'],
             *     // Currently any fields in this list will be hidden if none of the conditions say it should be shown.
             *     fields: [
             *         'field_name',
             *     ],
             *     conditions: [
             *         {
             *             checks: [
             *                 {
             *                     field: 'field_name',
             *                     // Supported operators: =, in, notIn. See: handleCheck()
             *                     op: '=',
             *                     value: 'Field Value'
             *                 }
             *             ],
             *             // The fields to show when this condition is met.
             *             show: [
             *                 'field_name',
             *             ],
             *             // It seems this currently is meant to work in conjunction with show. That is, once a field
             *             // is set to required, the only way to set it as not-required is to hide it. And, it won't
             *             // be shown again without a show:.
             *             required: [
             *                 'field_name',
             *             ],
             *             // Do anything you want when this condition is met.
             *             onHandle: function ([condition, conditionIsMet]) {}
             *         }
             *     ]
             * }
             */
            rules: [],

            /**
             * The label to show when we replace options and a selected item was not found.
             *
             * @type {String}
             */
            emptyLabel: '--- select ---',

            /**
             * The select2 config options.
             */
            select2: {
                /**
                 * The select2 theme. 'bootstrap' for front-end - 'default' for back-end.
                 *
                 * @type {String}
                 */
                theme: 'bootstrap'
            },

            /**
             * Resolve a jQuery element node using a callback, by default we lookup an id.
             *
             * @param identifier
             * @returns {jQuery|HTMLElement}
             */
            elementResolver: function (identifier) {
                var element = $('#' + identifier);

                if (! element.length) {
                    console.warn('Could not resolve element: #' + identifier);
                }

                return element;
            },

            /**
             * Resolve the jQuery element wrapper node for the given element.
             *
             * @param {jQuery|HTMLElement} elementNode See elementResolver()
             * @returns {jQuery|HTMLElement}
             */
            wrapperResolver: function (elementNode) {
                var wrapper = elementNode.closest('.form-group');

                if (! wrapper.length) {
                    console.warn('Could not resolve wrapper for element', elementNode);
                }

                return wrapper;
            }
        };

    /**
     * The actual plugin constructor.
     *
     * @param {Node} element
     * @param {Object} options
     */
    function Plugin(element, options) {
        this.element = element;
        this.settings = $.extend({}, defaults, options);
        this._defaults = defaults;
        this._name = pluginName;
        this.init();
    }

    // Avoid Plugin.prototype conflicts
    $.extend(Plugin.prototype, {

        /**
         * Initialize the rules and trigger necessary changes on load.
         */
        init: function () {
            if (this.settings.hasOwnProperty('rules') && this.settings.rules.length) {
                for (var i = 0; i < this.settings.rules.length; i++) {
                    this.initRule(this.settings.rules[i]);
                    this.triggerRule(null, this.settings.rules[i]);
                }
            }

            this.settings.onInit.apply(this);
        },

        /**
         * Initialize a individual rule and register necessary onChange functionality.
         *
         * @param {Object} rule
         */
        initRule: function (rule) {
            var conditionals = this;
            var onChange = function (e) {
                conditionals.triggerRule(e, rule);
            };

            if (rule.hasOwnProperty('triggers') && rule.triggers.length) {
                for (var i = 0; i < rule.triggers.length; i++) {
                    var node = this.elementResolver(rule.triggers[i]);
                    if (node) {
                        node.on('change', onChange);
                    }
                }
            }
        },

        /**
         * Trigger an individual rule.
         *
         * @param {Object} event
         * @param {Object} rule
         */
        triggerRule: function (event, rule) {
            var shownFields = [];

            if (rule.hasOwnProperty('conditions') && rule.conditions.length) {
                for (var i = 0; i < rule.conditions.length; i++) {
                    var fields = this.handleCondition(rule.conditions[i]);
                    shownFields = shownFields.concat(fields);
                }
            }

            if (rule.hasOwnProperty('fields') && rule.fields.length) {
                // hide/empty all fields not shown
                for (var j = 0; j < rule.fields.length; j++) {
                    if (shownFields.indexOf(rule.fields[j]) === -1) {
                        this.hideField(rule.fields[j], true);
                    }
                }
            }
        },

        /**
         * Handle an individual condition.
         *
         * @param {Object} condition
         * @return Array
         */
        handleCondition: function (condition) {
            var isMet = true;

            if (condition.hasOwnProperty('checks') && condition.checks.length) {
                for (var i = 0; i < condition.checks.length; i++) {
                    isMet = this.handleCheck(condition.checks[i]) && isMet;
                }
            }

            if (! isMet) {
                return [];
            }

            // show fields
            if (condition.hasOwnProperty('show') && condition.show.length) {
                for (var j = 0; j < condition.show.length; j++) {
                    this.showField(condition.show[j]);
                }
            }

            // required fields
            if (condition.hasOwnProperty('required') && condition.required.length) {
                for (var k = 0; k < condition.required.length; k++) {
                    this.requireField(condition.required[k]);
                }
            }

            // custom handle
            if (condition.hasOwnProperty('onHandle') && typeof condition.onHandle === 'function') {
                condition.onHandle.apply(this, [condition, isMet]);
            }

            return condition.show || false;
        },

        /**
         * Handle an individual check.
         *
         * @param {Object} check
         * @return Boolean
         */
        handleCheck: function (check) {
            var node = this.elementResolver(check.field);
            var value = null;

            if (! node) {
                return false;
            }

            // in display only mode, you may need to find the input inside the given div node
            if (node.hasClass('displayOnly') || node.attr('readonly') || node.prop('disabled')) {
                value = $('input', node).val();
                if (typeof value === "undefined") {
                    value = node.val();
                }
            } else {
                value = node.val();
            }

            // @todo: Support more operation types
            if ($.inArray(check.op, ['=', 'in', 'notIn']) === -1) {
                throw 'Unrecognized op: ' + check.op;
            }

            if (typeof check.value === 'undefined') {
                throw 'Condition based on value is the only option currently implemented.';
            }

            if ('in' === check.op) {
                return $.inArray(value, check.value) !== -1;
            }

            if ('notIn' === check.op) {
                return $.inArray(value, check.value) === -1;
            }

            return (check.value == value);
        },

        /**
         * Show an individual field.
         *
         * @param {String} name
         */
        showField: function (name) {
            var n = this.elementResolver(name);

            if (! n) {
                // elementResolver should always give a jQuery object, but some client code could have been written
                // to return a falsey sort of value. So, for backwards-compatability, keeping this check here.
                return;
            }

            n.show();

            this.wrapperResolver(n).show();
        },

        /**
         * Set an individual field as required.
         *
         * @param {String} name
         */
        requireField: function (name) {
            var n = this.elementResolver(name);
            if (n) {
                n.prop('required', true);
            }
        },

        /**
         * Set an individual field as required.
         *
         * @param {String} name
         * @param {Boolean=} isEmpty
         */
        hideField: function (name, isEmpty) {
            var n = this.elementResolver(name);

            if (! n) {
                // elementResolver should always give a jQuery object, but some client code could have been written
                // to return a falsey sort of value. So, for backwards-compatability, keeping this check here.
                return;
            }

            n.hide();
            var wrapper = this.wrapperResolver(n);

            // in display only mode, you need to find the input inside the given div node
            if (n.hasClass('displayOnly')) {
                wrapper.hide();
            } else {
                n.prop('required', false);
                wrapper.hide();
                if (isEmpty) {
                    n.val('');
                }
            }
        },

        /**
         * Toggle the element type from text input to select dropdown.
         *
         * @param {jQuery|HTMLElement|Node} element
         * @param {Array} options
         * @param {String} value
         */
        toggleInputSelectOptions: function (element, options, value) {
            if (element.prop('tagName') === 'INPUT') {
                this.convertInputToSelect(element, options, value || element.val() || element.attr('data-initial'));
            } else {
                this.replaceSelect2Options(element, options, value || element.val() || element.attr('data-initial'));
            }
        },

        /**
         * Toggle the element type from select dropdown to text input.
         *
         * @param {jQuery|HTMLElement|Node} element
         * @param {String} value
         */
        toggleSelectOptionsInput: function (element, value) {
            if (element.prop('tagName') === 'SELECT') {
                this.convertSelectToInput(element, value || element.val() || element.attr('data-initial'));
            }
        },

        isSelect2Available: function () {
            return typeof $ !== 'undefined' && $.fn && $.fn.select2 !== undefined;
        },

        useNativeSelect: function (node) {
            return this.settings.select2 === false || node.is('[data-native-select]') || !this.isSelect2Available();
        },

        populateSelectOptions: function (node, options, value) {
            var initialValue = node.attr('data-initial');
            var selectedValue = (typeof value !== 'undefined' ? value : '') || initialValue || '';
            var found = false;

            node.empty();

            if (node.hasClass('displayOnly') || node.attr('readonly') || node.prop('disabled')) {
                node.append($('<option />', { value: '', text: '' }));
            } else {
                node.append($('<option />', { value: '', text: this.settings.emptyLabel }));
            }

            for (var i in options) {
                if (options.hasOwnProperty(i)) {
                    var $option = $('<option />', { value: i, text: options[i] });

                    if (i == selectedValue) {
                        $option.prop('selected', true);
                        found = true;
                    }

                    node.append($option);
                }
            }

            if (found) {
                node.val(selectedValue).trigger('change');
            } else {
                node.val('').trigger('change');
            }

            return node;
        },

        /**
         * Replace the options within a select2 component.
         *
         * @param {jQuery|HTMLElement|Node} node
         * @param {Array} options
         * @param {String} value
         */
        replaceSelect2Options: function (node, options, value) {
            // if is displayOnly with input instead of select2
            // then no need to re-do the options,
            // (which would convert it into select2 from displayOnly)
            if (node.hasClass('displayOnly') || node.attr('readonly') || node.prop('disabled')) {
                return;
            }

            var initialValue = node.attr('data-initial');
            var oldValue = node.val();

            if (this.useNativeSelect(node)) {
                this.populateSelectOptions(
                    node,
                    options,
                    (typeof value !== 'undefined' ? value : '') || oldValue || initialValue
                );
            } else {
                if (this.isSelect2Available() && node.hasClass('select2-hidden-accessible')) {
                    node.select2().empty();
                    node.val(null).trigger('change');
                }

                this.initializeSelect2(
                    node,
                    options,
                    (typeof value !== 'undefined' ? value : '') || oldValue || initialValue
                );
            }
        },

        /**
         * Resolve the element given the identifier. By default we append the identifier to '#' for an ID.
         *
         * @param {String} identifier
         */
        elementResolver: function (identifier) {
            return this.settings.elementResolver.call(this, [identifier]);
        },

        /**
         * @see settings.wrapperResolver
         *
         * @param {jQuery|HTMLElement} element
         */
        wrapperResolver: function (element) {
            return this.settings['wrapperResolver'](element);
        },

        /**
         * Convert a select form field into a text input field.
         *
         * @param {jQuery|HTMLElement} node
         * @param {String} value
         * @returns {jQuery|HTMLElement}
         */
        convertSelectToInput: function (node, value) {
            if (node.prop('tagName') === 'SELECT') {
                var $input = $('<input />', {
                    'type': 'text',
                    'id': node.attr('id') || node.attr('name'),
                    'class': 'form-control',
                    'name': node.attr('name'),
                    'data-initial': node.attr('data-initial'),
                    'value': value
                });

                if (node.attr('readonly')) {
                    $input.attr('readonly', true);
                }

                if (node.prop('disabled')) {
                    $input.prop('disabled', true);
                }

                if (node.prop('required')) {
                    $input.prop('required', true);
                }

                node.parent().append($input);

                if (this.isSelect2Available() && node.hasClass('select2-hidden-accessible')) {
                    node.select2('destroy');
                }

                node.remove();
            }

            return node;
        },

        /**
         * Convert a input text field into a select form field.
         *
         * @param {jQuery|HTMLElement} node
         * @param {Array} options
         * @returns {*}
         */
        convertInputToSelect: function (node, options) {
            if (node.prop('tagName') === 'INPUT' && node.attr('type').toUpperCase() === 'TEXT') {
                var $select = $('<select />', {
                    'id': node.attr('id') || node.attr('name'),
                    'class': 'form-control',
                    'name': node.attr('name'),
                    'data-initial': node.attr('data-initial'),
                    'value': node.val()
                });

                if (node.attr('readonly')) {
                    $select.attr('readonly', true);
                }

                if (node.prop('disabled')) {
                    $select.prop('disabled', true);
                }

                if (node.prop('required')) {
                    $select.prop('required', true);
                }

                node.parent().append($select);

                if (this.useNativeSelect($select)) {
                    $select.attr('data-native-select', 'true');
                    $select = this.populateSelectOptions($select, options, node.val() || node.attr('data-initial'));
                } else {
                    $select = this.initializeSelect2($select, options, node.val() || node.attr('data-initial'));
                }

                node.remove();
            }

            return node;
        },

        initializeSelect2: function (node, options, value) {
            var data = []; // Temporary storage array for option objects
            var found = false; // set previous value again if available

            if (this.useNativeSelect(node)) {
                return this.populateSelectOptions(node, options, value);
            }

            if (!this.isSelect2Available()) {
                return this.populateSelectOptions(node, options, value);
            }

            if (node.hasClass('displayOnly') || node.attr('readonly') || node.prop('disabled')) {
                data.push({id: '', text: ''});
            } else {
                data.push({id: '', text: this.settings.emptyLabel});
            }

            for (var i in options) {
                if (options.hasOwnProperty(i)) {
                    data.push({id: i, text: options[i]});
                    if (i == value) {
                        found = true;
                    }
                }
            }

            // Re-create the select2 component
            node.select2($.extend({data: data}, this.settings.select2));

            if (found) {
                node.val(value).trigger('change');
            }

            return node;
        }
    });

    /**
     * A really lightweight plugin wrapper around the constructor,
     * preventing against multiple instantiations.
     *
     * @param {Object} options
     */
    $.fn[pluginName] = function (options) {
        return this.each(function () {
            if (! $.data(this, 'plugin_' + pluginName)) {
                $.data(this, 'plugin_' + pluginName, new Plugin(this, options));
            }
        });
    };

})(jQuery, window);
