jmaki.namespace("jmaki.widgets.ml.tagExpression");

/**
 * Add a trim() method to the standard string object.
 */
String.prototype.trim = function () {
    return this.replace(/^\s*/, "").replace(/\s*$/, "");
}

/**
 * Add an indexOf() method to the standard Array object.
 */
Array.prototype.indexOf = function (el) {
    var len = this.length;
    for (var i = 0; i < len; i++) {
        if (this[i.toString()] === el) {
            return i;
        }
    }
    return -1;
}

/**
 * Case-insensitive string comparator.
 */
jmaki.widgets.ml.tagExpression.noCaseComparator = function(strA, strB) {
    var strALower = strA.toLowerCase();
    var strBLower = strB.toLowerCase();

    if (strALower < strBLower) {
        return -1;
    } else if (strALower > strBLower) {
        return 1;
    } else {
        return 0;
    }
};
 
/**
 * TagCloud
 * Displays community tags with formatting:
 *     size - frequency of tag
 *     color - affect of tag
 *
 * id is HTML DOM element id
 */
jmaki.widgets.ml.tagExpression.TagCloud = function(id, config) {
    this.constructor.superclass.constructor.apply(this, arguments);

    var TE = jmaki.widgets.ml.tagExpression;

    this.el = YAHOO.util.Dom.get(id);
    this.widget = config.widget;
    var uuid = this.widget.uuid;

    this.trash = new TE.RatingBin(uuid + "Trash", {title: "Trash", rating: -1,
        displayId: uuid + "TrashDisplay", movieId: this.widget.movieId,
        service: this.widget.tagService, tagCloud: this, toggleLinkId: uuid + "TrashLink"});

    this.tagLiterals = [];
    this.tags = [];
};

YAHOO.lang.extend(jmaki.widgets.ml.tagExpression.TagCloud, YAHOO.util.DDTarget, {
    /**
     * Refreshes the display.
     */
    refresh: function() {
        while (this.tags.length > 0) {
            this.removeTag(this.tags[0]);
        }
        // clean out the trash
        var trashTags = this.trash.tags;
        while (trashTags.length > 0) {
            this.trash.removeTag(trashTags[0], /* unrate = */ false);
        }
        // mark as empty
        this.trash.updateDisplay();

        var numTags = this.tagLiterals.length;
        // can use Tag comparator because object literals have "tag" members
        this.tagLiterals.sort(jmaki.widgets.ml.tagExpression.Tag.comparator);
        for (var i = 0; i < numTags; i++) {
            this.addTag(this.tagLiterals[i]);
        }
    },

    /**
     * Adds a new Tag to the TagCloud.
     *
     * tagObj is object literal with following keys:
     *     tag : string of tag,
     *     color: CSS color string,
     *     size: CSS font-size value,
     *     rating: -1, 0, 1
     */
    addTag : function(tagObj) {
        var Dom = YAHOO.util.Dom;
        var Tag = jmaki.widgets.ml.tagExpression.Tag;
        var tagEl = document.createElement("span");
    
        tagEl.innerHTML = " &nbsp;" + tagObj.tag.replace(" ", "&nbsp;") + " ";
        tagEl.id = Tag.getNextId();
        Dom.addClass(tagEl, "tagExprTagCloudTag");
        Dom.setStyle(tagEl, "font-size", tagObj.size);
        Dom.setStyle(tagEl, "color", tagObj.color);
    
        // create a new tag with a null bin
        var tag = new Tag(tagEl.id, {tag: tagObj.tag, bin: null});
    
        if (tagObj.rating < 0) {
            // just loading tags into the trash, do not need to re-rate
            this.trash.addTag(tag, /* rate = */ false);
        } else {
            this.tags.push(tag);
            this.el.appendChild(tagEl);
        }
    },

    /*
     * Removes a Tag from the cloud.
     * (optional) unreg indicates whether it should be erased from memory (default true)
     */
    removeTag: function(tag, unreg) {
        unreg = unreg || true;
    
        var index = this.tags.indexOf(tag);
        if (index === -1) {
            return;
        }
        // remove DOM element
        this.el.removeChild(tag.getEl());
        // remove tag object
        this.tags.splice(index, 1);
        if (unreg) {
            tag.unreg();
        }
    }

});

/**
 * RatingBin
 * Manages rated tags (tag quality: i.e. thumbs up and thumbs down)
 * 
 * id is DOM id of HTML element
 * config is an object literal with following keys:
 *     rating : what rating (-1 or 1) bin represents
 *     displayId : DOM id of display DIV
 *     movieId : id of current movie
 *     service : URI for rating
 *     tagCloud : TagCloud instance
 *     toggleLinkId : link to show/hide display
 */
jmaki.widgets.ml.tagExpression.RatingBin = function(id, config) {
    this.constructor.superclass.constructor.apply(this, arguments);

    this.title = config.title;
    this.rating = config.rating;
    this.service = config.service;
    this.movieId = config.movieId;
    this.tagCloud = config.tagCloud;
    this.toggleLink = YAHOO.util.Dom.get(config.toggleLinkId);

    var teId = this.tagCloud.widget.id;
    this.displayDDTarget = new YAHOO.util.DDTarget(config.displayId);
    this.display = new YAHOO.widget.Panel(config.displayId, { visible: false,
        width: "150px", height: "275px", context: [teId, "tl", "tr"], underlay: "none",
        draggable: false, close: true, zIndex: 1 });

    // hack to sync view/hide text with clicking the 'X'
    this.display.newHide = this.display.hide;
    var that = this;
    this.display.hide = function() {
        that.toggleDisplay();
    };

    YAHOO.util.Event.on(window, "resize", function() {
        var Dom = YAHOO.util.Dom;
        var widgetId = this.tagCloud.widget.id;
            // make sure the trash stays attached to the widget
            Dom.setXY(this.display.id, [Dom.getRegion(widgetId).right,
                Dom.getY(widgetId)]);
        }, this, true);

    this.display.render();

    this.tags = [];
    var that = this;

    /**
     * Rates a Tag. (private)
     *
     * rating (-1, 0, 1)
     */
    var rateTag = function(tag, rating) {
        var params = "?movieId=" + encodeURIComponent(that.movieId) +
            "&rating=" + encodeURIComponent(rating) +
            "&tag=" + encodeURIComponent(tag.tag) +
            "&dragged=true&page=detail";

        var callback = {
            // after tell server, update TagCloud
                // need to wait a small amount of time for it to work
                success : function() {
                    setTimeout("jmaki.getWidget('" + this.uuid +
                        "').downloadCommunityTags()", 100);
                    },
            failure: this.handleError,
            timeout: 5000,
            cache: false,
            scope: that.tagCloud.widget
        };
        YAHOO.util.Connect.asyncRequest("GET", that.service + params, callback);
    };

    /**
     * Adds a Tag to the bin.
     * Use this when dragging a tag from cloud into the trash.
     * (optional) rate specifies whether it should be rated (default true)
     */
    this.addTag = function(tag, rate /* = true */) {
        if (rate !== false) {
            rate = true;
        }
        var index = that.tags.indexOf(tag);
        if (index !== -1) {
            return;
        }
        if (rate) {
            rateTag(tag, that.rating);
            that.tagCloud.removeTag(tag, /* unreg = */ false);
        }
        that.tags.push(tag);
        that.updateDisplay();
    };

    /**
     * Removes a Tag from the bin.
     * tag can be the id of a Tag also (for onClick Event)
     * (optional) unrate specifies whether it should be unrated (default)
     */
    this.removeTag = function(tag, unrate /* = true */) {
        if (unrate !== false) {
            unrate = true;
        }
        // if this is being called from an onclick event
        if (!(tag instanceof jmaki.widgets.ml.tagExpression.Tag)) {
            tag = YAHOO.util.DragDropMgr.getDDById(tag);
        }

        var index = that.tags.indexOf(tag);
        if (index === -1) {
            return;
        }
        if (unrate) {
            rateTag(tag, 0);
        }
        that.tags.splice(index, 1);
        that.updateDisplay();
    };
};

YAHOO.lang.extend(jmaki.widgets.ml.tagExpression.RatingBin, YAHOO.util.DDTarget, {
    /**
     * Show/Hide the display. The panel DIV is inserted inside of another
     * DIV at runtime, which holds the visibility style.
     */
    toggleDisplay: function() {
        var Dom = YAHOO.util.Dom;
        var el = Dom.get(this.display.id).parentNode;
        var style = Dom.getStyle(el, "visibility");
        if (style === "visible") {
            this.display.newHide();
            this.toggleLink.innerHTML = "view";
        } else {
            this.display.show();
            this.toggleLink.innerHTML = "hide";
        }
    },

    updateDisplay: function() {
        this.tags.sort(jmaki.widgets.ml.tagExpression.Tag.comparator);
        var that = this;
        var createEntry = function(tag) {
            return "<div class=\"tagExprTrashEntry\">" + tag.tag + "<br />(<a href=\"#\" class=\"tagExprNotTrashLink\" onclick=\"YAHOO.util.DragDropMgr.getDDById('" +
                that.id + "').removeTag('" + tag.id + "'); return false;\">not trash</a>)</div>";
        };

        var body = [];
        var numTags = this.tags.length;
        if (numTags === 0) {
            body.push("<span style='font-style:italic;'>Empty</span>");
        } else {
            for (var i = 0; i < numTags; i++) {
                body[i] = createEntry(this.tags[i]);
            }
        }

        this.display.setBody(body.join(" "));
    }
});

/**
 * TagBin
 * A bin with an affect for dragging and dropping Tags.
 * 
 * id is HTML <div> id
 * config is object literal with following keys:
 *     affect : -1 (dislike), 0 (neutral), or 1 (like)
 *     defaultColor : background color,
 *     highlightColor : background color onDrag,
 *     listEl, formEl, inputEl, editLinkEl, cancelLinkEl: references to bin's HTML elements
 *     movieId, service : passed along from widget for server comm.
 */
jmaki.widgets.ml.tagExpression.TagBin = function(id, config) {
    this.constructor.superclass.constructor.apply(this, arguments);

    this.affect = config.affect;
    this.defaultColor = config.defaultColor;
    this.highlightColor = config.highlightColor;
    this.listEl = config.listEl;
    this.formEl = config.formEl;
    this.inputEl = config.inputEl;
    this.acContainerEl = config.acContainerEl;
    this.editLinkEl = config.editLinkEl;
    this.cancelLinkEl = config.cancelLinkEl;
    this.widget = config.widget;

    this.addMessage = "Add...";
    this.inputEl.value = this.addMessage;

    this.tags = [];
    this.formState = "add";
};
    
YAHOO.lang.extend(jmaki.widgets.ml.tagExpression.TagBin, YAHOO.util.DDTarget, {

    /**
    * Adds a new tag (not already in another bin) to a user's tags.
    * serverConfig is metadata needed to update the server. If undefined, server not updated.
    */
    addTag: function (tagStr, serverConfig) {
        var Tag = jmaki.widgets.ml.tagExpression.Tag;
                    
        var tagEl = document.createElement("span");
        tagEl.id = Tag.getNextId();

        var delId = tagEl.id + "X";

        tagEl.innerHTML = "<img src=\"/resources/ml/tagExpression/images/closeicon.png\" class=\"tagExprDeleteTag\" onclick=\"YAHOO.util.DragDropMgr.getDDById('" +
            tagEl.id + "').bin.deleteTag('" + tagEl.id + "')\" id=\"" + delId + "\" title=\"Remove from my tags\" /> " + tagStr + "<br />";
        tagEl.className="tagExprTagListTag";

        var newTag = new Tag(tagEl.id, {tag: tagStr, bin: this});
        newTag.delToolTip = new YAHOO.widget.Tooltip("tt" + delId, {
            context: delId, showDelay: 500, autodismissdelay: 2000});

        this.tags.push(newTag);

        // add to DOM
        this.insertTagEl(tagEl);

        // if the form adds this tag, let it update the server
        if (serverConfig) {
            this.updateServer(tagStr, "add", serverConfig);
        }

        // update form positions
        this.constructor.alignForms(this);
        if (this.formState === "set") {
            this.populateForm();
        }
    },

    /**
    * Inserts tagEl (a list element) alphabetically into this bins listEl.
    * Assumes already in alphabetic.
    */
    insertTagEl: function (tagEl) {
        var inserted = false;
        var tagElHTML = tagEl.innerHTML;
        var tagLower = tagElHTML.substring(tagElHTML.indexOf(">") +
            2).toLowerCase();
        var tags = this.listEl.getElementsByTagName("span");
        var numTags = tags.length;

        for (var i = 0; i < numTags; i++) {
            var currentTagHTML = tags[i].innerHTML;
            var otherTagLower = currentTagHTML.substring(
                currentTagHTML.indexOf(">") + 2).toLowerCase();
            if (tagLower < otherTagLower) {
                YAHOO.util.Dom.insertBefore(tagEl, tags[i]);
                inserted = true;
                break;
            }
        }
        if (!inserted) {
            this.listEl.appendChild(tagEl);
        }
    },

    /**
     * Deletes a user tag (client and server side)
     * Used as function for event handler for x-icons.
     */
    deleteTag: function (tagId) {
        var tag = YAHOO.util.DragDropMgr.getDDById(tagId);
        this.removeTag(tag);
        this.updateServer(tag.tag, "del");
    },

    /**
    * Removes (Tag) tag from the bin
    */
    removeTag: function (tag) {
        var tagEl = tag.getEl();
        tagEl.parentNode.removeChild(tagEl);
        var index = this.tags.indexOf(tag);
        if (index === -1) {
            alert("removeTag: invalid index");
            return;
        }

        tag.delToolTip && tag.delToolTip.destroy();

        this.tags.splice(index, 1);
        // remove YAHOO's references
        tag.unreg();

        // update form positions
        this.constructor.alignForms(this);
    },

    /**
    * Deletes all tags in this bin
    */
    clearTags: function () {
        while(this.tags.length > 0) {
            this.removeTag(this.tags[0]);
        }
    },

    /**
    * Moves (Tag) tag from this bin to (TagBin) destTagBin
    * serverConfig contains metadata for server updates.
    * If undefined, server is not informed of this change.
    */
    moveTag: function (tag, destTagBin, serverConfig) {
        destTagBin.insertTagEl(tag.getEl());
        tag.bin = destTagBin;
        var index = this.tags.indexOf(tag);
        if (index === -1) {
            alert("moveTag: invalid index");
            return;
        }
        this.tags.splice(index, 1);
        destTagBin.tags.push(tag);

        // if the form adds this tag, let it update the server
        if (serverConfig) {
            destTagBin.updateServer(tag.tag, "add", serverConfig);
        }

        this.constructor.alignForms(destTagBin);
        if (destTagBin.formState === "set") {
            destTagBin.populateForm();
        }
        if (this.formState === "set") {
            this.populateForm();
        }
    },

    /**
    * Sets the form up for editing tags.
    */
    populateForm: function() {
        this.formState = "set";

        var tags = [];
        var numTags = this.tags.length;
        this.tags.sort(jmaki.widgets.ml.tagExpression.Tag.comparator);
        for (var i = 0; i < numTags; i++) {
            tags.push(this.tags[i].tag);
        }
        this.inputEl.value = tags.join(", ");
        if (numTags !== 0) {
            this.inputEl.value += ", ";
        }
        this.inputEl.focus();

        YAHOO.util.Dom.setStyle(this.editLinkEl, "display", "none");
        YAHOO.util.Dom.setStyle(this.cancelLinkEl, "display", "inline");
    },

    /**
    * Hides the HTML form associated with this bin.
    */
    cancelForm: function() {
        this.formState = "add";
        this.inputEl.value = this.addMessage;

        YAHOO.util.Dom.setStyle(this.cancelLinkEl, "display", "none");
        YAHOO.util.Dom.setStyle(this.editLinkEl, "display", "inline");
    },

    /**
    * Clears the input field for adding tags. Event handler for input field onfocus.
    */ 
    focusForm: function() {
            if (this.inputEl.value === this.addMessage) {
              this.inputEl.value = "";
        }
    },

    /**
    * Resets the form if appropriate. Event handler for input field onblur.
    */ 
    blurForm: function() {
            var value = this.inputEl.value;
            if (value.trim() === "") {
                this.cancelForm();
        }
    },

    /**
    * Parses the data in the form field depending on the formState.
    */
    submitForm: function() {
        var DDM = YAHOO.util.DragDropMgr;
        var inputTagsCSV = this.inputEl.value;
        var tagBins = DDM.getRelated(this, true);

        if (this.formState === "set") {
            this.clearTags();
        } else if (this.formState === "add") {
            // prevent a blank tag from being added
            if (inputTagsCSV.trim() === "" || inputTagsCSV === this.addMessage) {
                this.cancelForm();
                return;
            }
        }

        this.updateServer(inputTagsCSV, this.formState, {dragged:false,
            stolen:false});

        var inputTags = inputTagsCSV.split(",");
        var numInputTags = inputTags.length;

        // check if each tag submitted already exists
        for (var i = 0; i < numInputTags; i++) {
            var tagStr = inputTags[i].trim();
            if (tagStr === "") {
                continue;
            }
            var result = this.constructor.findTagInBins(tagStr, tagBins);
            var tagBin = result && result.tagBin;
            if (tagBin !== null) {
                if (tagBin !== this) {
                    var index = result.index;
                    tagBin.moveTag(tagBin.tags[index], this);
                }
            } else {
                this.addTag(tagStr);
            }
        }
        this.inputEl.value = "";
        this.inputEl.blur();
        this.blurForm();
    },

    /**
     * Tell the server about tag "add"s, "set"s, or "del"s associated with this bin.
     * config is an object literal with the following possible keys:
     *     stolen: indicates whether user dragged from tag cloud
     *     oldAffect: indicates the previous affect of this tag if any (not a tag cloud tag color)
     *     dragged: whether a drag and drop even triggered this action
     *     tcTagColor: color of tag dragged from tag Cloud
     *     tcTagSize: size of tag dragged from tag Cloud
     */
    updateServer: function(tagCSV, action, config) {
        var movieId = this.widget.movieId;
        var service = this.widget.tagService;

        // make undefined values false/null
        var stolen = (config && config.stolen) || false;
        var dragged = (config && config.dragged) || false;
        var oldAffect = config && config.oldAffect;
        var tcTagColor = config && config.tcTagColor;
        var tcTagSize = config && config.tcTagSize;

            var params = "?movieId=" + encodeURIComponent(movieId) +
                "&page=detail&affect=" + this.affect +
                "&stolen=" + stolen + "&dragged=" + dragged +
                "&" + action + "=" + encodeURIComponent(tagCSV);

        if (oldAffect) {
            params += "&oldaffect=" + oldAffect;
        }
        if (tcTagColor && tcTagSize) {
            params += "&tagcolor=" + encodeURIComponent(tcTagColor) +
                "&tagsize=" + encodeURIComponent(tcTagSize);
        }

            var callback = {
                // after tell server, update TagCloud
                // need to wait a small amount of time for it to work
                success : function() {
                    setTimeout("jmaki.getWidget('" + this.uuid +
                        "').downloadCommunityTags()", 100);
                },
                failure : this.handleError,
                timeout : 5000,
                cache : false,
                scope : this.widget
            };
            YAHOO.util.Connect.asyncRequest("GET", service + params, callback);
    }
});

/**
* Static method that tries to locate tagStr in one of the (TagBin[]) tagBins.
* Returns object with TagBin and index or null if not found.
*/
jmaki.widgets.ml.tagExpression.TagBin.findTagInBins = function(tagStr,
    tagBins) {

    var numTagBins = tagBins.length;
    var result = null;

    outer: for (var i = 0; i < numTagBins; i++) {
        if (!(tagBins[i] instanceof jmaki.widgets.ml.tagExpression.TagBin)) {
            // all drag drop targets show up in tagBins
            // don't care about the tag cloud or the rating bins
            continue;
        }
        var tags = tagBins[i].tags;
        var numTags = tags.length;
        for (var j = 0; j < numTags; j++) {
            if (tagStr === tags[j].tag) {
                result = { tagBin: tagBins[i], index: j};
                break outer;
            }
        }
    }
    return result;
};

/**
 * Static method to help keep all bin headers aligned.
 * bins is the array of TagBin objects.
 */
jmaki.widgets.ml.tagExpression.TagBin.alignHeaders = function(bins) {
    var Dom = YAHOO.util.Dom;
    var tallestBin = bins[0];

    var binsGroupId = tallestBin.widget.uuid + "Bins";
    var binsGroupY = Dom.getY(binsGroupId);
    // this prevents a weird IE error
    Dom.setY(tallestBin.id, binsGroupY);
    var dislikeHeaderRegion = Dom.getRegion(tallestBin.id + "Header");
    var maxHeaderHeight = dislikeHeaderRegion.bottom -
        dislikeHeaderRegion.top;
    var numBins = bins.length;
    for (var i = 1; i < numBins; i++) {
        var headerId = bins[i].id + "Header";
        Dom.setStyle(headerId, "height", maxHeaderHeight + "px");
    }
};

/**
 * Static method to help keep all bin-forms on same y-coordinate.
 */
jmaki.widgets.ml.tagExpression.TagBin.alignForms = function(bin) {
    var Dom = YAHOO.util.Dom;
    var TB = jmaki.widgets.ml.tagExpression.TagBin;

    var maxHeight = Number.NEGATIVE_INFINITY;
    var maxBin = null;

    var bins = YAHOO.util.DragDropMgr.getRelated(bin, true);
    var numBins = bins.length;
    for (var i = 0; i < numBins; i++) {
        var currentBin = bins[i];
        if (!(currentBin instanceof TB)) {
            continue;
        }
        var height = Dom.getRegion(currentBin.listEl).bottom;
        if (height > maxHeight) {
            maxHeight = height;
            maxBin = currentBin;
        }
    }

    Dom.setY(maxBin.formEl, maxHeight + 20);
    for (i = 0; i < numBins; i++) {
        var currentBin = bins[i];
        if (!(currentBin instanceof TB)) {
            continue;
        }
        if (currentBin !== maxBin) {
            Dom.setY(currentBin.formEl, Dom.getY(maxBin.formEl));
        }
        Dom.setY(currentBin.acContainerEl,
            Dom.getRegion(currentBin.inputEl).bottom);
    }
}

/**
 * Tag
 * A draggable and droppable tag.
 *
 * id is HTML <li> id (from a bin) or HTML <span> id (from cloud)
 * config is object literal with following keys:
 *     tag : the tag as a string,
 *     bin : the TagBin that holds this Tag (null if in cloud)
 */
jmaki.widgets.ml.tagExpression.Tag = function(id, config) {
    this.constructor.superclass.constructor.apply(this, arguments);

    this.invalidDrop = false;
    this.isTarget = false;
    this.startPos;

    this.centerFrame = true;
    this.tag = config.tag;
    this.bin = config.bin;

		var Event = YAHOO.util.Event;
    var that = this;
    var hrefHandler = function(e) {
        var targetEl = Event.getTarget(e);
        var tagName = targetEl.tagName;
    		if (tagName == "SPAN") {
            location.href = "/tagSearch?tag=" + encodeURIComponent(that.tag) +
                "&referrer=detail";
        }
    };

    Event.addListener(id, "click", hrefHandler); 
};

YAHOO.lang.extend(jmaki.widgets.ml.tagExpression.Tag, YAHOO.util.DDProxy, {

    /**
     * Tooltip for explaining the x-icon.
     */
    delToolTip: null,

    /**
     * Overload event handlers to change bins colors based on tag position.
     * We would like that the tagBins sense tags hovering over them.
     * This will not work until YUI 3.0.
     */
    onDragEnter: function (e, id) {
        var Dom = YAHOO.util.Dom;
        var dragEl = this.getDragEl();
        var tagBin = YAHOO.util.DragDropMgr.getDDById(id);
        var TE = jmaki.widgets.ml.tagExpression;

        if (tagBin instanceof TE.TagBin) {
            if (tagBin !== this.bin) {
                Dom.setStyle(id, "backgroundColor", tagBin.highlightColor);
                Dom.setStyle(dragEl, "cursor", "crosshair");
            }
        } else if (tagBin instanceof TE.RatingBin) {
            if (this.bin === null) {
                Dom.setStyle(dragEl, "cursor", "crosshair");
                var trashIcon = Dom.get(tagBin.id + "Icon");
                trashIcon.src = "/resources/ml/tagExpression/images/trashiconhover.png";
            } else {
                Dom.setStyle(dragEl, "cursor", "no-drop");
            }
        } else if (tagBin instanceof TE.TagCloud) {
            if (this.bin !== null) {
                Dom.setStyle(dragEl, "cursor", "no-drop");
            }
        }
    },

    onDragOut: function (e, id) {
        var Dom = YAHOO.util.Dom;
        var dragEl = this.getDragEl();
        var tagBin = YAHOO.util.DragDropMgr.getDDById(id);
        var TE = jmaki.widgets.ml.tagExpression;

        if (tagBin instanceof TE.TagBin) {
            if (tagBin !== this.bin) {
                Dom.setStyle(id, "backgroundColor", tagBin.defaultColor);
            }
            Dom.setStyle(dragEl, "cursor", "move");
        } else if (tagBin instanceof TE.RatingBin) {
            if (this.bin === null) {
                var trashIcon = Dom.get(tagBin.id + "Icon");
                trashIcon.src = "/resources/ml/tagExpression/images/trashicon.png";
                Dom.setStyle(dragEl, "cursor", "move");
            }
            else {
                Dom.setStyle(dragEl, "cursor", "no-drop");
            }
        } else {
            Dom.setStyle(dragEl, "cursor", "move");
        }
    },

    /**
     * Event handler that styles the proxy element (tag that follows cursor)
     * and hides the base element (tag in list)
     */
    startDrag: function (e) {
        var Dom = YAHOO.util.Dom;
        var dragEl = this.getDragEl();
        var el = this.getEl();
        this.startPos = Dom.getXY(el);

        // if in a bin, hide the base element
        if (this.bin !== null) {
            Dom.setStyle(el, "visibility", "hidden");
        }

        // style the proxy element (same used for all drag and drop objects)
        Dom.addClass(dragEl, el.className);
        Dom.setStyle(dragEl, "text-align", "center");
        Dom.setStyle(dragEl, "border-width", "0px");
        Dom.setStyle(dragEl, "color", el.style.color);
        Dom.setStyle(dragEl, "font-size", el.style.fontSize);

        var txt = el.innerHTML;
        if (this.bin === null) {
            dragEl.innerHTML = txt;
        } else {
            // do not want to include the x-icon in drag element
            dragEl.innerHTML = txt.substring(txt.indexOf(">") + 2);
        }
    },

    onInvalidDrop: function (e) {
        this.invalidDrop = true;
    },

    /**
     * Event handler for dropping a dragged Tag.
     */
    onDragDrop: function (e, id) {
        var DDM = YAHOO.util.DragDropMgr;
        var el = this.getEl();
        var targetBin = DDM.getDDById(id);
        var TE = jmaki.widgets.ml.tagExpression;
        if (targetBin !== this.bin) {
            // if being dragged from tag cloud
            if (this.bin === null) {
                if (targetBin instanceof TE.TagCloud) {
                    this.invalidDrop = true;
                } else if (targetBin instanceof TE.RatingBin) {
                    this.invalidDrop = false;
                    targetBin.addTag(this);
                    var trashIcon = YAHOO.util.Dom.get(targetBin.id + "Icon");
                    trashIcon.src = "/resources/ml/tagExpression/images/trashicon.png";
                } else if (targetBin instanceof TE.TagBin) {
                    this.invalidDrop = false;
                    var tagBins = DDM.getRelated(targetBin, true);
                    var result = targetBin.constructor.findTagInBins(this.tag, tagBins);
                    var otherBin = result && result.tagBin;
                    if (otherBin === null) {
                        var config = {stolen: true, dragged: true,
                            tcTagColor: this.getEl().style.color,
                            tcTagSize: this.getEl().style.fontSize
                        };
                        targetBin.addTag(this.tag, config);
                    } else {
                        // this tag has already been dragged from cloud into otherBin
                        // need to move it to targetBin
                        if (otherBin !== targetBin) {
                            var index = result.index;
                            var config = {
                                stolen: true, dragged: true,
                                oldAffect: otherBin.affect,
                                tcTagColor: this.getEl().style.color,
                                tcTagSize: this.getEl().style.fontSize
                            };
                            otherBin.moveTag(otherBin.tags[index], targetBin, config);
                        }
                    }
                }
            } else {
                if (targetBin instanceof TE.TagBin) {
                    // the tag has changed bins
                    this.invalidDrop = false;
                    var config = {stolen: false, dragged: true, oldAffect: this.bin.affect};
                    this.bin.moveTag(this, targetBin, config);
                } else if (targetBin instanceof TE.RatingBin) {
                    // cannot drag a user tag here
                    this.invalidDrop = true;
                } else if (targetBin instanceof TE.TagCloud) {
                    // tag is being dragged from a bin back to the cloud
                    this.invalidDrop = true;
                }
            }
            YAHOO.util.Dom.setStyle(id, "backgroundColor", targetBin.defaultColor);
        } else {
            // tag is already in the targeted bin
            this.invalidDrop = true;
        }
    },

    /**
     * Need to overload endDrag to prevent the default behavior of
     * moving base element to proxy element's location.
     */
    endDrag: function (e) {
        this.returnToStartPos(this.invalidDrop);
        this.invalidDrop = false;
    },

    /**
     * Manages the animation of a dropped Tag.
     * Behavior depends on whether the drop was valid.
     */
    returnToStartPos: function(isInvalidDrop) {

        var el = this.getEl();
        var Dom = YAHOO.util.Dom;
        var dragEl = this.getDragEl();

        Dom.setStyle(dragEl, "cursor", "move");
        if (isInvalidDrop) {
            // by default, the proxy element gets hidden on a drop
            Dom.setStyle(dragEl, "visibility", "");
            var a = new YAHOO.util.Motion(dragEl,
                {
                  points: {
                    to: this.startPos
                  }
                }, 0.3, YAHOO.util.Easing.easeOut);

            a.onComplete.subscribe(function() {
                Dom.setStyle(dragEl, "visibility", "hidden");
                Dom.setStyle(el, "visibility", "");
            });

            a.animate();
        } else {
            // overloading endDrag already prevents the base element
            // from being moved, so just need to hide proxy
            Dom.setStyle(dragEl, "visibility", "hidden");
            Dom.setStyle(this.getEl(), "visibility", "");
        }
    }
});

/**
 * Static member and method for keeping track of IDs
 */
jmaki.widgets.ml.tagExpression.Tag.tagCtr = 0;
jmaki.widgets.ml.tagExpression.Tag.getNextId = function () {
    return "tagExprTag" + this.tagCtr++;
};

/**
 * Static Tag comparator method
 */
jmaki.widgets.ml.tagExpression.Tag.comparator = function(tagA, tagB) {
    return jmaki.widgets.ml.tagExpression.noCaseComparator(tagA.tag, tagB.tag);
};

/**
 * MovieLens Tag Expression widget
 * Matt Soukup
 */
jmaki.widgets.ml.tagExpression.Widget = function (wargs) {

    if (!wargs.uuid) {
        alert("No uuid defined.");
    }

    var args = wargs.args;
    if (!args) {
        alert("No user args defined.");
    } else if (!args.tagExpressionService) {
        alert("Tag Expression service URI undefined.");
    } else if (!args.tagService) {
        alert("No tag service URI defined");
    } else if (!args.acService) {
        alert("No tag auto completer service URI defined");
    } else if (!args.movieId) {
        alert("No Movie ID defined.");
    }

    this.uuid = wargs.uuid;
    this.tagExpressionService = args.tagExpressionService;
    this.tagService = args.tagService;
    this.acService = args.acService;
    this.movieId = args.movieId;

    this.id = this.uuid + "TagExpression";
    var TE = jmaki.widgets.ml.tagExpression;
    var Dom = YAHOO.util.Dom;

    this.tagCloud = new TE.TagCloud(this.uuid + "TagCloud", {widget: this});

    this.tagBins = [];

    this.ac = [];

    // give inner functions access to this
    var that = this;

    // Initialize tool tips. Messages are stored in HTML "title" attributes.
    var Tooltip = YAHOO.widget.Tooltip;
    this.toolTips = [
        new Tooltip("tt1", { context: this.uuid + "CommunityTagsToolTip",
                             width: "300px", autodismissdelay: 60000 }),
        new Tooltip("tt2", { context: this.uuid + "UserTagsToolTip",
                             width: "300px", autodismissdelay: 60000 }),
        new Tooltip("tt3", { context: this.uuid + "TrashIcon",
                             autodismissdelay: 5000 })
    ];

    this.mask = Dom.get(this.uuid + "Mask");
    this.maskMsg = Dom.get(this.uuid + "MaskMsg");
    Dom.setStyle(this.mask, "opacity", "0.3");

    this.componentsLoaded = 0;
    this.numComponents = 2;
    this.showMask("Loading tags...<br /><img src='/images/gray_busy.gif' />");

    /**
     * Methods called by jMaki
     */
    this.postLoad = function() {
        that.downloadCommunityTags();
        that.downloadUserTags();
    };

    this.destroy = function() {
    };
};

/**
 * Get community tags from server. This is called when the tag cloud needs to be "refreshed".
 */
jmaki.widgets.ml.tagExpression.Widget.prototype.downloadCommunityTags = function() {
        var Connect = YAHOO.util.Connect;
        var callback = {
            success : this.updateTagCloud,
            failure : this.handleError,
            timeout : 5000,
            cache : false,
            scope : this
        };
        var body = YAHOO.lang.JSON.stringify({movieId: this.movieId, page: "detail"});
    Connect.setDefaultPostHeader(false);
    Connect.initHeader("Content-Type", "application/json; charset=utf-8");
    Connect.asyncRequest("POST", this.tagExpressionService, callback, body);
};

/**
 * Get user tags from server.
 */
jmaki.widgets.ml.tagExpression.Widget.prototype.downloadUserTags = function() {
        var Connect = YAHOO.util.Connect;
        var callback = {
            success : this.initTagBins,
            failure : this.handleError,
            timeout : 5000,
            cache : false,
            scope : this
        };

    // get the user's tags
        var params = "?movieId=" + encodeURIComponent(this.movieId) +
            "&page=detail&log=true";
        Connect.asyncRequest("GET", this.tagService + params, callback);
};

/**
 * Fills the TagCloud with downloaded communityTags.
 *
 * communityTagsResponse is tagExpressionService response:
 * an array of object literals expected by TagCloud.addTag()
 */
jmaki.widgets.ml.tagExpression.Widget.prototype.updateTagCloud =
    function(communityTagsResponse) {

    var obj = YAHOO.lang.JSON.parse(
            communityTagsResponse.responseText);

    if (obj.hasOwnProperty("communityTags")) {

        var communityTags = obj["communityTags"];
        
        if (communityTags === null) {
            this.tagCloud.tagLiterals = [];
        } else {
            this.tagCloud.tagLiterals = communityTags;
        }
        this.tagCloud.refresh();

        // need to sync up with downloading of user tags
        // when page first accessed
        this.componentsLoaded++;
        if (this.componentsLoaded === this.numComponents) {
            this.hideMask();
        }
    } else {
        showMask("Unable to retrieve community tags.");
    }
};

/**
 * Fills the TagBins with downloaded userTags. Inits autoComplete.
 *
 * userTags is array of object literals with following keys:
 *     tag : string of user tag,
 *     bin : 0, 1, 2 - index into this.tagBins
 */
jmaki.widgets.ml.tagExpression.Widget.prototype.initTagBins =
    function(userTagsResponse) {

    // need to sync up with downloading of community tags
    // when page first accessed
    this.componentsLoaded++;
    if (this.componentsLoaded === this.numComponents) {
        this.hideMask();
    }

    var TE = jmaki.widgets.ml.tagExpression;
    var Dom = YAHOO.util.Dom;
    // In the HTML, to match DOM ids with array indeces:
    // DislikeBin is 0, NeutralBin is 1, and LikeBin is 2
    // We can send affect to DOM ids by adding 1, i.e. -1 becomes 0, etc.
    var tagBinIds = [
        this.uuid + "DislikeBin",
        this.uuid + "NeutralBin",
        this.uuid + "LikeBin"
    ];
    var numBins = tagBinIds.length;

    var tagBinArgs = [
        { defaultColor: Dom.get(tagBinIds[0]).style.backgroundColor,
          highlightColor: "rgb(255, 180, 180)"},
        { defaultColor: Dom.get(tagBinIds[1]).style.backgroundColor,
          highlightColor: "rgb(180, 180, 180)"},
        { defaultColor: Dom.get(tagBinIds[2]).style.backgroundColor,
          highlightColor: "rgb(170, 180, 255)"}
    ];
    for (var i = 0; i < numBins; i++) {
        var currentArgs = tagBinArgs[i];
        currentArgs.listEl = Dom.get(this.uuid + "List" + i);
        currentArgs.formEl = Dom.get(this.uuid + "Form" + i);
        currentArgs.editLinkEl = Dom.get(this.uuid + "EditLink" + i);
        currentArgs.cancelLinkEl = Dom.get(this.uuid + "CancelLink" + i);
        currentArgs.inputEl = Dom.get(this.uuid + "Input" + i);
        currentArgs.acContainerEl = Dom.get(this.uuid + "AC" + i);
        currentArgs.affect = i - 1;
        currentArgs.widget = this;
    }

    for (var i = 0; i < numBins; i++) {
      this.tagBins[i] = new TE.TagBin(tagBinIds[i], tagBinArgs[i]);
    }

    jmaki.widgets.ml.tagExpression.TagBin.alignHeaders(this.tagBins);
        // there are a number of things that need to happen when
        // browser window resizes to keep things looking pretty
        YAHOO.util.Event.on(window, "resize", function() {
        // align the headers
        jmaki.widgets.ml.tagExpression.TagBin.alignHeaders(this.tagBins);
            // adjust y-coords of forms in bins
            jmaki.widgets.ml.tagExpression.TagBin.alignForms(this.tagBins[0]);
        }, this, true);

    // fill the tag bins
    var userTags = this.constructor.parseTagServiceResponse(userTagsResponse);
    var numTags = userTags.length;
    for (var i = 0; i < numTags; i++) {
        var currentTag = userTags[i];
        this.tagBins[currentTag.bin].addTag(currentTag.tag);
    }
    
    // initialize autocomplete
    var DS = YAHOO.util.XHRDataSource;
    var acDS = new DS(this.acService);
    acDS.responseType = DS.TYPE_JSON;
    acDS.responseSchema = {
        resultsList: "results",
        fields: ["tag", "num"]
    };

    var AC = YAHOO.widget.AutoComplete;
    var inputPrefix = this.uuid + "Input";
    var containerPrefix = this.uuid + "AC";
    for (var i = 0; i < numBins; i++) {
        var inputId = inputPrefix + i;
        var containerId = containerPrefix + i;
        this.ac[i] = new AC(inputId, containerId, acDS);
        var ac = this.ac[i];
        ac.formatResult = function(result, query) {
            return result[0] + " (" + result[1] + ")";
        };
        ac.delimChar = ",";
        ac.queryDelay = 0.5;
        ac.autoHighlight = false;

        /*
          Need to position the autocomplete containers below inputs
          Can't put them next to the input in the HTML because
          a CSS property required for making the tagBin coloring work
          (overflow=hidden) disrupts the functionality of the AC
        */
        var containerEl = ac.getContainerEl();
        var inputEl = ac.getInputEl();
        Dom.setX(containerEl, Dom.getX(inputEl));
        Dom.setY(containerEl, Dom.getRegion(inputEl).bottom);
    }
};

jmaki.widgets.ml.tagExpression.Widget.prototype.showMask = function(msg) {
    YAHOO.util.Dom.setStyle([this.mask, this.maskMsg], "visibility", "visible");
    this.maskMsg.innerHTML = msg;
};
jmaki.widgets.ml.tagExpression.Widget.prototype.hideMask = function() {
    YAHOO.util.Dom.setStyle([this.mask, this.maskMsg], "visibility", "hidden");
    this.maskMsg.innerHTML = "";
};

jmaki.widgets.ml.tagExpression.Widget.prototype.handleError =
    function(response) {

    this.showMask("An error has occurred.<br />" +
        "Please refresh your browser or try again later.<br />");
};

/**
 * Static method that parses response from movielens /Tag service.
 * Returns an array of tag object literals with keys:
 *     tag : string of tag
 *     bin : bin number
 *
 */
jmaki.widgets.ml.tagExpression.Widget.parseTagServiceResponse =
    function(xml) {

    if (!xml) {
        return null;
    }

    var tagsNodes = xml.responseXML.getElementsByTagName("userTags");
    if(!tagsNodes || tagsNodes.length < 1) {
        return null;
    }

    var tagNodeList = tagsNodes.item(0).getElementsByTagName("tag");
    if (!tagNodeList) {
        return null;
    }
  
    var tags = [];
    var numTags = tagNodeList.length;
    for(var i = 0; i < numTags; i++) {
        var tagNode = tagNodeList.item(i);
        var tag = { "tag" : tagNode.firstChild.nodeValue,
            "bin" : +tagNode.getAttribute("affect") + 1 };
        tags.push(tag);
    }
    return tags;
};
