/*!
 * jQuery.textcomplete.js
 *
 * Repositiory: https://github.com/yuku-t/jquery-textcomplete
 * License:     MIT
 * Author:      Yuku Takahashi
 */

;(function ($) {

    'use strict';

    /**
     * Exclusive execution control utility.
     */
    var lock = function (func) {
        var free, locked;
        free = function () { locked = false; };
        return function () {
            var args;
            if (locked) return;
            locked = true;
            args = toArray(arguments);
            args.unshift(free);
            func.apply(this, args);
        };
    };

    /**
     * Convert arguments into a real array.
     */
    var toArray = function (args) {
        var result;
        result = Array.prototype.slice.call(args);
        return result;
    };

    /**
     * Get the styles of any element from property names.
     */
    var getStyles = (function () {
        var color;
        color = $('<div></div>').css(['color']).color;
        if (typeof color !== 'undefined') {
            return function ($el, properties) {
                return $el.css(properties);
            };
        } else {  // for jQuery 1.8 or below
            return function ($el, properties) {
                var styles;
                styles = {};
                $.each(properties, function (i, property) {
                    styles[property] = $el.css(property);
                });
                return styles;
            };
        }
    })();

    /**
     * Default template function.
     */
    var identity = function (obj) { return obj; };

    /**
     * Memoize a search function.
     */
    var memoize = function (func) {
        var memo = {};
        return function (term, callback) {
            if (memo[term]) {
                callback(memo[term]);
            } else {
                func.call(this, term, function (data) {
                    memo[term] = (memo[term] || []).concat(data);
                    callback.apply(null, arguments);
                });
            }
        };
    };

    /**
     * Determine if the array contains a given value.
     */
    var include = function (array, value) {
        var i, l;
        if (array.indexOf) return array.indexOf(value) != -1;
        for (i = 0, l = array.length; i < l; i++) {
            if (array[i] === value) return true;
        }
        return false;
    };

    /**
     * Textarea manager class.
     */
    var Completer = (function () {
        var html, css, $baseWrapper, $baseList, _id;

        html = {
            wrapper: '<div class="textcomplete-wrapper"></div>',
            list: '<ul class="dropdown-menu"></ul>'
        };
        css = {
            wrapper: {
                position: 'relative'
            },
            list: {
                position: 'absolute',
                top: 0,
                left: 0,
                zIndex: '100',
                display: 'none'
            }
        };
        $baseWrapper = $(html.wrapper).css(css.wrapper);
        $baseList = $(html.list).css(css.list);
        _id = 0;

        function Completer($el) {
            var focus;
            this.el = $el.get(0);  // textarea element
            focus = this.el === document.activeElement;
            // Cannot wrap $el at initialize method lazily due to Firefox's behavior.
            this.$el = wrapElement($el); // Focus is lost
            this.id = 'textComplete' + _id++;
            this.strategies = [];
            if (focus) {
                this.initialize();
                this.$el.focus();
            } else {
                this.$el.one('focus.textComplete', $.proxy(this.initialize, this));
            }
        }

        /**
         * Completer's public methods
         */
        $.extend(Completer.prototype, {

            /**
             * Prepare ListView and bind events.
             */
            initialize: function () {
                var $list, globalEvents;
                $list = $baseList.clone();
                this.listView = new ListView($list, this);
                this.$el
                    .before($list)
                    .on({
                        'keyup.textComplete': $.proxy(this.onKeyup, this),
                        'keydown.textComplete': $.proxy(this.listView.onKeydown,
                            this.listView)
                    });
                globalEvents = {};
                globalEvents['click.' + this.id] = $.proxy(this.onClickDocument, this);
                globalEvents['keyup.' + this.id] = $.proxy(this.onKeyupDocument, this);
                $(document).on(globalEvents);
            },

            /**
             * Register strategies to the completer.
             */
            register: function (strategies) {
                this.strategies = this.strategies.concat(strategies);
            },

            /**
             * Show autocomplete list next to the caret.
             */
            renderList: function (data) {
                if (this.clearAtNext) {
                    this.listView.clear();
                    this.clearAtNext = false;
                }
                if (data.length) {
                    if (!this.listView.shown) {
                        this.listView
                            .setPosition(this.getCaretPosition())
                            .clear()
                            .activate();
                        this.listView.strategy = this.strategy;
                    }
                    data = data.slice(0, this.strategy.maxCount);
                    this.listView.render(data);
                }

                if (!this.listView.data.length && this.listView.shown) {
                    this.listView.deactivate();
                }
            },

            searchCallbackFactory: function (free) {
                var self = this;
                return function (data, keep) {
                    self.renderList(data);
                    if (!keep) {
                        // This is the last callback for this search.
                        free();
                        self.clearAtNext = true;
                    }
                };
            },

            /**
             * Keyup event handler.
             */
            onKeyup: function (e) {
                var searchQuery, term;
                if (this.skipSearch(e)) { return; }

                searchQuery = this.extractSearchQuery(this.getTextFromHeadToCaret());
                if (searchQuery.length) {
                    term = searchQuery[1];
                    if (this.term === term) return; // Ignore shift-key or something.
                    this.term = term;
                    this.search(searchQuery);
                } else {
                    this.term = null;
                    this.listView.deactivate();
                }
            },

            /**
             * Suppress searching if it returns true.
             */
            skipSearch: function (e) {
                if (this.skipNextKeyup) {
                    this.skipNextKeyup = false;
                    return true;
                }
                switch (e.keyCode) {
                    case 40:
                    case 38:
                        return true;
                }
            },

            onSelect: function (value) {
                var pre, post, newSubStr;
                pre = this.getTextFromHeadToCaret();
                post = this.el.value.substring(this.el.selectionEnd);

                newSubStr = this.strategy.replace(value);
                if ($.isArray(newSubStr)) {
                    post = newSubStr[1] + post;
                    newSubStr = newSubStr[0];
                }
                pre = pre.replace(this.strategy.match, newSubStr);
                this.$el.val(pre + post)
                    .trigger('change')
                    .trigger('textComplete:select', value);
                this.el.focus();
                this.el.selectionStart = this.el.selectionEnd = pre.length;
                this.skipNextKeyup = true;
            },

            /**
             * Global click event handler.
             */
            onClickDocument: function (e) {
                if (e.originalEvent && !e.originalEvent.keepTextCompleteDropdown) {
                    this.listView.deactivate();
                }
            },

            /**
             * Global keyup event handler.
             */
            onKeyupDocument: function (e) {
                if (this.listView.shown && e.keyCode === 27) { // ESC
                    this.listView.deactivate();
                    this.$el.focus();
                }
            },

            /**
             * Remove all event handlers and the wrapper element.
             */
            destroy: function () {
                var $wrapper;
                this.$el.off('.textComplete');
                $(document).off('.' + this.id);
                if (this.listView) { this.listView.destroy(); }
                $wrapper = this.$el.parent();
                $wrapper.after(this.$el).remove();
                this.$el.data('textComplete', void 0);
                this.$el = null;
            },

            // Helper methods
            // ==============

            /**
             * Returns caret's relative coordinates from textarea's left top corner.
             */
            getCaretPosition: function () {
                // Browser native API does not provide the way to know the position of
                // caret in pixels, so that here we use a kind of hack to accomplish
                // the aim. First of all it puts a div element and completely copies
                // the textarea's style to the element, then it inserts the text and a
                // span element into the textarea.
                // Consequently, the span element's position is the thing what we want.

                if (this.el.selectionEnd === 0) return;
                var properties, css, $div, $span, position, dir;

                dir = this.$el.attr('dir') || this.$el.css('direction');
                properties = ['border-width', 'font-family', 'font-size', 'font-style',
                    'font-variant', 'font-weight', 'height', 'letter-spacing',
                    'word-spacing', 'line-height', 'text-decoration', 'text-align',
                    'width', 'padding-top', 'padding-right', 'padding-bottom',
                    'padding-left', 'margin-top', 'margin-right', 'margin-bottom',
                    'margin-left'
                ];
                css = $.extend({
                    position: 'absolute',
                    overflow: 'auto',
                    'white-space': 'pre-wrap',
                    top: 0,
                    left: -9999,
                    direction: dir
                }, getStyles(this.$el, properties));

                $div = $('<div></div>').css(css).text(this.getTextFromHeadToCaret());
                $span = $('<span></span>').text('.').appendTo($div);
                this.$el.before($div);
                position = $span.position();
                position.top += $span.height() - this.$el.scrollTop();
                if (dir === 'rtl') { position.left -= this.listView.$el.width(); }
                $div.remove();
                return position;
            },

            getTextFromHeadToCaret: function () {
                var text, selectionEnd, range;
                selectionEnd = this.el.selectionEnd;
                if (typeof selectionEnd === 'number') {
                    text = this.el.value.substring(0, selectionEnd);
                } else if (document.selection) {
                    range = this.el.createTextRange();
                    range.moveStart('character', 0);
                    range.moveEnd('textedit');
                    text = range.text;
                }
                return text;
            },

            /**
             * Parse the value of textarea and extract search query.
             */
            extractSearchQuery: function (text) {
                // If a search query found, it returns used strategy and the query
                // term. If the caret is currently in a code block or search query does
                // not found, it returns an empty array.

                var i, l, strategy, match;
                for (i = 0, l = this.strategies.length; i < l; i++) {
                    strategy = this.strategies[i];
                    match = text.match(strategy.match);
                    if (match) { return [strategy, match[strategy.index]]; }
                }
                return [];
            },

            search: lock(function (free, searchQuery) {
                var term;
                this.strategy = searchQuery[0];
                term = searchQuery[1];
                this.strategy.search(term, this.searchCallbackFactory(free));
            })
        });

        /**
         * Completer's private functions
         */
        var wrapElement = function ($el) {
            return $el.wrap($baseWrapper.clone().css('display', $el.css('display')));
        };

        return Completer;
    })();

    /**
     * Dropdown menu manager class.
     */
    var ListView = (function () {

        function ListView($el, completer) {
            this.data = [];
            this.$el = $el;
            this.index = 0;
            this.completer = completer;

            this.$el.on('click.textComplete', 'li.textcomplete-item',
                $.proxy(this.onClick, this));
        }

        $.extend(ListView.prototype, {
            shown: false,

            render: function (data) {
                var html, i, l, index, val;

                html = '';
                for (i = 0, l = data.length; i < l; i++) {
                    val = data[i];
                    if (include(this.data, val)) continue;
                    index = this.data.length;
                    this.data.push(val);
                    html += '<li class="textcomplete-item" data-index="' + index + '"><a>';
                    html +=   this.strategy.template(val);
                    html += '</a></li>';
                    if (this.data.length === this.strategy.maxCount) break;
                }
                this.$el.append(html);
                if (!this.data.length) {
                    this.deactivate();
                } else {
                    this.activateIndexedItem();
                }
            },

            clear: function () {
                this.data = [];
                this.$el.html('');
                this.index = 0;
                return this;
            },

            activateIndexedItem: function () {
                this.$el.find('.active').removeClass('active');
                this.getActiveItem().addClass('active');
            },

            getActiveItem: function () {
                return $(this.$el.children().get(this.index));
            },

            activate: function () {
                if (!this.shown) {
                    this.$el.show();
                    this.completer.$el.trigger('textComplete:show');
                    this.shown = true;
                }
                return this;
            },

            deactivate: function () {
                if (this.shown) {
                    this.$el.hide();
                    this.completer.$el.trigger('textComplete:hide');
                    this.shown = false;
                    this.data = this.index = null;
                }
                return this;
            },

            setPosition: function (position) {
                this.$el.css(position);
                return this;
            },

            select: function (index) {
                var self = this;
                this.completer.onSelect(this.data[index]);
                // Deactive at next tick to allow other event handlers to know whether
                // the dropdown has been shown or not.
                setTimeout(function () { self.deactivate(); }, 0);
            },

            onKeydown: function (e) {
                if (!this.shown) return;
                if (e.keyCode === 38) {         // UP
                    e.preventDefault();
                    if (this.index === 0) {
                        this.index = this.data.length-1;
                    } else {
                        this.index -= 1;
                    }
                    this.activateIndexedItem();
                } else if (e.keyCode === 40) {  // DOWN
                    e.preventDefault();
                    if (this.index === this.data.length - 1) {
                        this.index = 0;
                    } else {
                        this.index += 1;
                    }
                    this.activateIndexedItem();
                } else if (e.keyCode === 13 || e.keyCode === 9) {  // ENTER or TAB
                    e.preventDefault();
                    this.select(parseInt(this.getActiveItem().data('index'), 10));
                }
            },

            onClick: function (e) {
                var $e = $(e.target);
                e.originalEvent.keepTextCompleteDropdown = true;
                if (!$e.hasClass('textcomplete-item')) {
                    $e = $e.parents('li.textcomplete-item');
                }
                this.select(parseInt($e.data('index'), 10));
            },

            destroy: function () {
                this.deactivate();
                this.$el.off('click.textComplete').remove();
                this.$el = null;
            }
        });

        return ListView;
    })();

    $.fn.textcomplete = function (strategies) {
        var i, l, strategy, dataKey;

        dataKey = 'textComplete';

        if (strategies === 'destroy') {
            return this.each(function () {
                var completer = $(this).data(dataKey);
                if (completer) { completer.destroy(); }
            });
        }

        for (i = 0, l = strategies.length; i < l; i++) {
            strategy = strategies[i];
            if (!strategy.template) {
                strategy.template = identity;
            }
            if (strategy.index == null) {
                strategy.index = 2;
            }
            if (strategy.cache) {
                strategy.search = memoize(strategy.search);
            }
            strategy.maxCount || (strategy.maxCount = 10);
        }

        return this.each(function () {
            var $this, completer;
            $this = $(this);
            completer = $this.data(dataKey);
            if (!completer) {
                completer = new Completer($this);
                $this.data(dataKey, completer);
            }
            completer.register(strategies);
        });
    };

})(window.jQuery || window.Zepto);
