/*
 * The MIT License
 *
 * Copyright (c) 2009 Alexander von Weiss
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 * 
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

/*
 * sodInput
 *
 * Version: 1.0
 * Dependency: prototype framework 1.6+ "www.prototypejs.org"
 * 
 * Does replace a standard html <select> form with 
 * a tag/suggestions based input field
 *
 * Every <form> submit/load operation works with the 
 * original (from sodInput hidden) <select> form
 */

/*
 * Usage:
 *

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <link rel="stylesheet" type="text/css" href="sodinput.css" />
    <script type="text/javascript" src="prototype.js"></script>
    <script type="text/javascript" src="sodinput.js"></script>
</head>
<body>
    <select id="htmlSelectId">
      <option value="1">Option 1</option>
      <option value="2" selected="selected">Option 2</option>
      <option value="3">Option 3</option>
    </select>
    
    <script>
      new sodInput("htmlSelectId", {});
    </script>
</body>
</html>

 */
 
var sodInput;

sodInput = function(id,opt) {

    /*
     * INIT VARIABLES
     */

    // user ajustable options
    this.options = {
    
        "autoSuggests": true
    
    }
    
    // ul navigation behavior
    this.ulBehavior = {
        "ulTags": { 
            "prev": { "loop": "stop", "actionIfUnSelected": "last"  },
            "next": { "loop": "blur", "actionIfUnSelected": false   }
        },
        "ulSuggests": { 
            "prev": { "loop": "loop", "actionIfUnSelected": "last"  },
            "next": { "loop": "loop", "actionIfUnSelected": "first" }
        }
    }

    // internal object2html variable
    this.input = {};
    
    // ul (html unordered list) liNode state. Works with the html liNode DOM order
    // nothing = false, <ul> first <li> = 0, second <li> = 1, third <li> = 2, ... and so on </ul>
    this.selected = { ulTags: false, ulSuggests: false };
    this.deleted  = { ulTags: false, ulSuggests: false };
    
    // suggests data
    this.data = { "htmlId": [], "dbId": [], "text": [], "selected": [] };
    
    // timeouts
    this.timeout = { "ul-mousedown": false };
    
    // call first method
    this.bind(id,opt);
    
}

sodInput.prototype = {

    /*
     * PRIVATE FUNCTIONS
     */

    // get all elements (frame,tags,input,suggests)
    div: function(what) {
        return $(this.input.id + what);
    },

    // return internal respectively overwritten option
    option: function(opt) {
        return (this.input.options[opt]) ? this.input.options[opt] : this.options[opt];
    },

    // create all the html
    createHTML: function() {
        
        /* 
        <!--- 
          HTML Layout Legend:
            id = l.... = <div> layer tag
            id = f.... = <input> form tag
            id = ul... = <ul> unordered list tag
            id = li... = <li> list item
        --->
        */
        
        var html = '';
        var id = this.input.id;

        /* <div>      */ html += '<div class="sodInput" id="'+id+'lFrame">';
        /*   Tags     */ html += '  <ul id="'+id+'ulTags" class="tags"></ul>';
        /*   Input    */ html += '  <div id="'+id+'lInput"><input id="'+id+'fInput" type="text" /></div>';
        /*   Suggests */ html += '  <div id="'+id+'lSuggests" class="suggests" style="display:none; position:absolute">';
                         html += '    <ul class="down" id="'+id+'ulDirection">';
                         html += '      <li id="'+id+'liNoSuggests" class="noSuggests">Keine Eintr&auml;ge gefunden.</li>';
                         html += '      <li><ul id="'+id+'ulSuggests"></ul></li>';
                         html += '    </ul>';
                         html += '  </div>';
        /* </div>     */ html += '</div>';
        
        // replace DOM
        this.input.old.hide();
        this.input.old.insert({"after": html});
        
        // bind events
        // simulate click, focus and blur for the lFrame, as it was the properly input field
        this.div("lFrame").    observe("mousedown",this.eventHandler.bindAsEventListener(this, "lFrame-mousedown"));
        this.div("fInput").    observe("focus",    this.eventHandler.bindAsEventListener(this, "fInput-focus"));
        this.div("fInput").    observe("blur",     this.eventHandler.bindAsEventListener(this, "fInput-blur"));

        // assign click event from a found label for the old select id => to the new input
        if (this.input.label) {
            this.input.label.  observe("click",    this.eventHandler.bindAsEventListener(this, "label-click"));
        }
        
        // input observer
        this.div("fInput").    observe("keydown",  this.eventHandler.bindAsEventListener(this, "fInput-keydown"));
        this.div("fInput").    observe("keyup",    this.eventHandler.bindAsEventListener(this, "fInput-keyup"));
        
        // tag and suggests observer
        this.div("ulTags").    observe("click",    this.eventHandler.bindAsEventListener(this, "ulTags-click"));
        this.div("ulSuggests").observe("click",    this.eventHandler.bindAsEventListener(this, "ulSuggests-click"));
        
        // stop all fired events on mousedown on ulTags and ulSuggests (it's the fInput blur event!)
        // Shortly after the browser requests the click events on either ulTags or ulSuggests
        this.div("ulTags").    observe("mousedown",this.eventHandler.bindAsEventListener(this, "ulTags-mousedown"));
        this.div("ulSuggests").observe("mousedown",this.eventHandler.bindAsEventListener(this, "ulSuggests-mousedown"));
        
    },
    
    // event Handler
    eventHandler: function(e) {

        var args = $A(arguments); args.shift();
        var what = args[0];

        switch (what) {

            // focus und blur für das lFrame simulieren, als wäre dieses div das eigentliche inputfeld
            case "label-click":
            case "lFrame-mousedown": 
                e.stop();
                this.div("fInput").activate();
            break;

            case "fInput-focus": 
                this.div("lFrame").addClassName('sodInput-focus');
                this.lSuggests("rePosition");
                this.updateSuggestsDisplay();
            break;

            case "fInput-blur":
                // if no mousedown event was fired, blur fInput and hide suggests
                if (!this.timeout["ul-mousedown"]) {
                    this.div("lFrame").removeClassName('sodInput-focus');
                    this.div("lSuggests").hide(); 

                    // deselect tags
                    this.selected["ulTags"] = false;
                    this.updateAllLiNodes("ulTags");
                    
                    // clear fInput
                    this.div("fInput").clear();
                }
            break;

            // call keypress handler:
            case "fInput-keydown":   return this.eventKeyDown(e);
            case "fInput-keyup":     return this.eventKeyUp(e);

            case "ulTags-click":     
                return this.eventLiNodeClick("ulTags",e);
                
            case "ulSuggests-click": 
                return this.eventLiNodeClick("ulSuggests",e);
                
            case "ulTags-mousedown":     
            case "ulSuggests-mousedown": 
                e.stop();
                // store the mousedown event for a while, to abort the fInput-blur event
                // e.stop() would have been enough for all browsers EXCEPT the internet explorer :-(
                // so let's use this workaround
                var self = this;
                this.timeout["ul-mousedown"] = true;
                window.setTimeout(function(){ self.timeout["ul-mousedown"] = false },50);
                return;

        }
    },

    // handle mouse clicks on tags and suggests
    eventLiNodeClick: function(what,e) {
    
        switch(what) {
        
            case "ulTags":
                var tag = e.element().tagName;
                var liNode = (tag == "SPAN") ? e.element().parentNode : e.element();
                var liNodePosition = liNode.previousSiblings().length;
                
                this.div("fInput").activate();
                
                // delete tag
                if (tag == "SPAN" && e.element().innerHTML == " x&nbsp;") {
                    this.selected[what] = false;
                    this.deleted[what] = liNodePosition;
                    
                // select tag
                } else {
                    this.selected[what] = liNodePosition;
                }
                
                this.updateAllLiNodes(what);
                this.updateSuggestsDisplay();
                this.lSuggests("rePosition");
            break;
            
            case "ulSuggests":
                var liNode = e.element();
                if (liNode && liNode.tagName == "LI") {
                    // add clicked suggest as a new tag
                    this.div("fInput").activate();
                    this.addTagFromSuggests(liNode,e);
                    this.lSuggests("rePosition");
                }
            break;
        
        }
    
    },
    
    // intercept some keyCodes to bind special functionality
    eventKeyDown: function(e) {
    
        var what = false;

        switch (e.keyCode) {
        
            /* 
             * tag navigation
             */
        
            // KEY_POS1   |<--
            case  36:
            // KEY_LEFT   <--
            case  37: 
                what = "ulTags";
                // if the cursor in fInput is in the most left, 
                // jump to first or previous tag
                if (this.cursorAtStart()) {
                    e.stop();
                    var func = (e.keyCode == 36) ? "first" : "prev";
                    this.selectChange(what,func);
                }
            break;
            
            // KEY_END   -->|
            case  35:
            // KEY_RIGHT   -->
            case  39:
                what = "ulTags";
                // if already something selected, 
                // jump to last or next tag
                if (this.selected[what] !== false) {
                    e.stop();
                    var func = (e.keyCode == 35) ? "last" : "next";
                    this.selectChange(what,func);
                }
            break;

            // backspace
            case   8:
                what = "ulTags";
                // if the cursor in fInput is in the most left, 
                // delete selected and jump to previous tag
                if (this.cursorAtStart()) {
                    e.stop();
                    this.deleted[what] = this.selected[what];
                    
                    // execute deletion and jump to previous item
                    this.updateAllLiNodes(what);
                    this.selectChange(what,"prev");
                    
                    this.lSuggests("rePosition");
                }
            break;
            
            // entf
            case  46:
                what = "ulTags";
                // if the cursor in fInput is in the most left, 
                if (this.selected[what] !== false && this.cursorAtStart()) {
                    e.stop();
                    this.deleted[what] = this.selected[what];
                    
                    // execute deletion and reselect the new liNode, that took place 
                    // on the deleted liNode id
                    this.updateAllLiNodes(what);
                    this.selectChange(what,"refresh");
                    
                    this.lSuggests("rePosition");
                }
            break;

            
            /* 
             * suggestion navigation
             */
        
            // KEY_PAGE_UP
            case  33:
            // KEY_UP
            case  38:
                e.stop();
                what = "ulSuggests";
                var func = (e.keyCode == 33) ? "first" : "prev";
                this.selectChange(what,func);
            break;
            
            // KEY_PAGE_DOWN
            case  34:
            // KEY_DOWN
            case  40:
                e.stop();
                what = "ulSuggests";
                var func = (e.keyCode == 34) ? "last" : "next";
                this.selectChange(what,func);
            break;


            /* 
             * Other key functionality
             */

            // +
            case 107: e.stop(); break;

            // Enter
            case  13: 
                e.stop();
                var input  = this.div("fInput");
                
                this.deleted["ulTags"] = false;

                // if suggestion selected, add the suggestion as a tag
                if (this.selected.ulSuggests !== false) {

                    // find selected liNode
                    var liNode = this.div("ulSuggests").down("li.selected");
                    if (!liNode) {
                        break;
                    }
                    
                    this.addTagFromSuggests(liNode,e);

                // else, add new tag
                } else {

                    var values = {};
                    var val = input.getValue();
                
                    if (!val) return;
                    values["text"] = val;
                    input.clear();

                    this.addLiNode("ulTags",values); 
                    this.updateSuggestsDisplay();

                }

                this.lSuggests("rePosition");
                this.selectChange("ulTags","blur");

            break;
            
            // ESC
            case  27: 
                this.div("lSuggests").hide();
            break;
            
            // shift, ctrl, alt
            case  16:
            case  17:
            case  18:
            break;
            
            // Other Keys
            default:
                this.selectChange("ulTags","blur");
            break;
        
        }
    },

    // Suggestions here
    eventKeyUp: function(e) {

        if ([ 35,36,37,39,  38,40,33,34,  16,17,18,  27,13 ].indexOf(e.keyCode) !== -1) {
            // We don't want to rebuild the ulSuggests on these keyCodes:
            // nav suggests: 38 KEY_UP,  40 KEY_DOWN, 33 KEY_PAGE_UP, 34 KEY_PAGE_DOWN, 
            // nav tags:     35 KEY_END, 36 KEY_POS1, 37 KEY_LEFT,    39 KEY_RIGHT
            // modifier:     16 shift,   17 ctrl,     18 alt, 
            // other:        27 ESC,     13 enter
            return;
        }

        // on every other key, update the suggestions
        this.updateSuggestsDisplay();
        
    },
    
    addTagFromSuggests: function(liNode,e) {
    
        var values = {};
        
        // get values
        values["id"]   = liNode.getAttribute("value");
        values["text"] = this.dataByDbId(values["id"],"text");
        
        // add tag
        this.addLiNode("ulTags",values); 

        // remove suggestion
        liNode.remove();
        
        // clear fInput
        this.div("fInput").activate();

        this.selectChange("ulSuggests","refresh");
    },
    
    // navigate through <ul> lists
    // what (<ul> id) = tags, suggests 
    // loop (behavior, if we exceed the border) = loop, blur, stop
    selectChange: function(what,action) {

        var val = this.selected[what];
        var loop, actionIfUnSelected = false;

        // Load ulBehaviors, what to do, if we have to loop or nothing is selected
        if (action == "prev" || action == "next") {
            loop = this.ulBehavior[what][action]["loop"];
            actionIfUnSelected = this.ulBehavior[what][action]["actionIfUnSelected"];
            if (val === false && actionIfUnSelected) {
                action = actionIfUnSelected;
            }
        } else if (action == "first" || action == "last") {
            var optAction = (action == "first") ? "prev" : "next";
            loop = this.ulBehavior[what][optAction]["loop"];
        }

        var entries = this.div(what).select("li").length;
        
        if (entries <= 0) {
            action = "blur";
        }

        switch(action) {

            case "first":
                if (loop == "blur" && val == 0) {
                    val = false;
                    break;
                }
                val = 0; 
            break;

            case "refresh":
                // if list is smaller then selected, select last item in the list
                if (entries <= val) {
                    val = entries - 1;
                }
            break;

            case "prev":
                // if not the first entry, then move to previous
                if (val - 1 >= 0) {
                    val--;
                // else (it's already the first entry) if loop == "loop", jump to last entry
                } else if (loop == "loop") {
                    val = (entries > 0) ? entries - 1 : val; 
                // else if loop == "blur", deselect everything
                } else if (loop == "blur") {
                    val = false;
                // else, do nothing
                }
            break;

            case "next":  
                // if not the last entry, then move to next
                if (val + 1 < entries) {
                    val++;
                // else (it's already the last entry) if loop == "loop", jump to the first entry
                } else if (loop == "loop") {
                    val = 0;
                // else if loop == "blur", deselect everything
                } else if (loop == "blur") {
                    val = false;
                // else, do nothing
                }
            break;

            case "last":
                if (loop == "blur" && val == entries -1) {
                    val = false;
                    break;
                }
                val = entries - 1;
            break;

            case "blur":
                val = false;
            break;

        }

        this.selected[what] = val;
        this.updateAllLiNodes(what);

    },
    
    // select, unselect (blur) or delete liNodes
    // uses this.selected and this.deleted
    updateAllLiNodes: function(what) {

        var i, func, liNodes = this.div(what).select("li");
        
        if (what == "ulSuggests") {
            with(this.div("liNoSuggests")) { 
                (liNodes.length > 0) ? hide() : show();
            }
        }
        
        for (i = 0; i < liNodes.length; i++) {
        
            func = "blur";
            
            if (this.selected[what] === i) {
                func = "sel"
            }
            
            if (this.deleted[what] === i) {
                func = "del";
            }

            this.changeLiNode(what,func,liNodes[i]);
            
        }
        
        this.deleted[what] = false;
        
    },
 
    // add a new liNode to "what" (ulTags or ulSuggests)
    // values = { "id": "...", "text": "..." }
    addLiNode: function(what,values,returnHtml) {
    
        var id, text, html = false;
        
        returnHtml = (!returnHtml) ? false : returnHtml;
    
        if (what == "ulTags") {
        
            id   = values["id"]   || "new";
            text = values["text"] || "";
            
            html = '<li value="' + id + '">' + text + '<span> x&nbsp;</span></li>';
            
            if (id != "new") {
                this.dataByDbId(id,"selected","set",true);
                this.htmlSync("sel=true",id);
            }
            
        }
        
        if (what == "ulSuggests") {
        
            id       = values["id"]       || false;
            text     = values["text"]     || false;
            replacer = values["replacer"] || false;

            if (id && text) {
                if (replacer) {
                    text = text.replace(replacer,"<strong>$1</strong>");
                }
                html = '<li value="' + id + '">' + text + '</li>';
            }
        
        }
        
        if (returnHtml) {
            return (html) ? html : '';
        }
        
        if (html) {
            this.div(what).insert({"bottom": html});
        }
    
    },
    
    // apply del, sel or blur on liNode
    changeLiNode: function(what,func,liNode) {
    
        switch(func) {
            case "del":
                if (what == "ulTags") {
                    if (liNode.value) {
                        var id = liNode.value;
                        this.dataByDbId(id,"selected","set",false);
                        this.htmlSync("sel=false",id);
                    }
                    liNode.remove();
                }
            break;
            
            case "sel":  
                liNode.addClassName("selected"); 
            break;
            
            case "blur": 
                liNode.removeClassName("selected"); 
            break;
        }
            
    },
    
    // show or hide suggestions
    updateSuggestsDisplay: function() {

        var show = true;
        var ulSuggests, fInput, values, id, sel, text, html = '', frequency = 1;
        var finder = false, replacer = false;
    
        // if no suggests stored, hide lSuggests
        if (!this.data.htmlId.length) {
            show = false;
        }
        
        ulSuggests = this.div("ulSuggests");
        ulSuggests.update("");
        
        filter = this.div("fInput").value;
        
        // disarm fInput.value to a RegExp friendly string
        filter = filter.replace(/[^\w\d\. ]+/g, ".");
        filter = filter.strip();
        if (filter) {
            filter = filter.replace(/[ ]+/g, "|");
            filter = "(" + filter + ")";
            filter.scan(/\|/,function() { frequency++; });
        
            // init regexp find filter (e.g.: search string "ab cd eee" becomes to
            // ^.*(ab|cd|eee).*(ab|cd|eee).*(ab|cd|eee).*$
            finder = new RegExp("^.*"+ (filter+".*").times(frequency) + "$","i");
            // init regexp replace filter
            replacer = new RegExp(filter,"ig");
        }
        
        // loop through all stored suggests
        for (var i = 0; i < this.data.htmlId.length; i++) {
        
            id   = this.data.dbId[i];
            text = this.data.text[i];
            sel  = this.data.selected[i];
            
            if (sel == false && (!finder || finder.test(text))) {
                show = true;
                values = { "id": id, "text": text, "replacer": replacer }
                html += this.addLiNode("ulSuggests",values,true);
            }
        
        }
        
        // manually insert all suggests at once, instead of every single liNode for
        // some more performance
        this.div("ulSuggests").insert({"bottom": html});

        if (show) {
            // Select the first Element
            this.selectChange("ulSuggests","first");
            this.lSuggests("show");
        } else {
            this.lSuggests("hide");
        }
        
    },
    
    lSuggests: function(func) {

        var lSuggests = this.div("lSuggests");
        
        if (func == "show") {
            lSuggests.show();
        } else if (func == "hide") {
            lSuggests.hide();
        } else if (func == "rePosition") {
        
            var lFrameTop = this.div("lFrame").viewportOffset().top;
            var viewportHeight = document.viewport.getDimensions().height;
            
            var direction = (lFrameTop < viewportHeight / 2) ? "down" : "up";
            
            if (direction == "up") {
                this.div("ulDirection").setStyle({ "bottom": (this.div("lFrame").getHeight() - 2) + "px" });
            }
            
            lSuggests.setStyle({ "left": this.div("lFrame").positionedOffset().left + 100 + "px" });
            this.div("ulDirection").className = direction;
            
        }
    
    },
    
    
    // updates or reads internal data by dbId
    // what = "selected", "text", "dbId" or "htmlId"
    // func = "set" to overwrite the value in dbId/what or empty to get the value
    dataByDbId: function(dbId,what,func,newValue) {
    
        var i = this.data.dbId.indexOf(parseInt(dbId));
        
        if (!this.data[what] || typeof this.data[what][i] == "undefined") return;
        
        // read
        if (typeof func == "undefined") {
            return this.data[what][i];
        
        // write
        } else {
            if (what == "dbId" || what == "htmlId") {
                newValue = parseInt(newValue);
            }
            this.data[what][i] = newValue;
        }
    
    },
    
    // syncs operation to the html form in $(this.input.id)
    // func = "import" // import all values
    // func = "sel=true" // select an item
    // func = "sel=false" // de-select an item
    htmlSync: function(func,id) {

        var i, sel = $(this.input.id);

        switch(func) {

            // import all values from the <select>
            case "import":

                // loop though all option tags
                for (i = 0; i < sel.length; i++) {

                    var id, text, selected = false;

                    // get values
                    id   = sel.options[i].value;
                    text = sel.options[i].text;

                    // if selected => add as a tag
                    if (sel.options[i].selected) {
                        selected = true;
                        this.addLiNode("ulTags",{ "id": id, "text": text });
                    }

                    // add as a new row in the internal this.data
                    this.data.htmlId  .push(parseInt(i));
                    this.data.dbId    .push(parseInt(id));
                    this.data.text    .push(text);
                    this.data.selected.push(selected);
                }

            break;

            // select or de-select an html select option
            case "sel=true":
            case "sel=false":
                var htmlId = this.dataByDbId(id,"htmlId");
                var htmlOpt = sel.options[htmlId];

                if (htmlOpt) {
                    htmlOpt.selected = (func == "sel=true") ? true : false;
                }
            break;

        }

    },

    // checks, if the cursor is on the most left position
    // sadly, the prototype framework doesn't has a build-in crossbrowser solution for this :-/
    cursorAtStart: function() {

        var input = this.div("fInput");

        // if this browser supports it, then check cursor position
        if (typeof input.selectionStart != 'undefined') {
            if (input.selectionStart == 0 && input.selectionEnd == 0) {
                return true;
            }
        // else check if the form input is empty
        } else if (input.value == "") {
            return true;
        }

        return false;

    },
    
    // binds the sodWindow object with the given id
    bind: function(id,opt) {
    
        var label = $(id).previous("label");
        if (!label || label.getAttribute("for") != id) {
            label = false;
        }
    
        // save internal configuration
        this.input = {
            id: id,
            old: $(id),
            label: label,
            options: opt
        };

        // create html
        this.createHTML();

        if (this.option("autoSuggests")) {
            this.htmlSync("import",this.input.id);
        }
        
    },
    
    
    /*
     * PUBLIC FUNCTIONS
     */
    
    // public funtion show(id,opt)
    show: function(id,opt) {
        this.bind(id,opt);
    },

    // public function hide()
    hide: function(event) {
        this.hideOnScreen(event);
    }

}
