(function(){

    var _nextTipsyId = 0;
    
    pv.Behavior.tipsy = function(opts) {
        var _tipsyId = _nextTipsyId++;
        
        /**
         * One tip is reused per behavior instance.
         * Typically there is one behavior instance per mark,
         * and this is reused across all its mark instances.
         */
        
        if(!opts) {
            opts = {};
        } else {
            opts = Object.create(opts);
        }
        
        /**
         * Trigger must be manual because the mouse entering/leaving
         * the **fake target** is not always adequate.
         * 
         * When followMouse=true, the fake target is always moving, and is not usable
         * for bounds control. What matters is the real SVG target.
         */
        opts.trigger = 'manual';
        
        /**
         * Gravity is intercepted to allow for off screen bounds reaction. 
         */
        var _userGravity = opts.gravity || $.fn.tipsy.defaults.gravity;
        var _userGravityFun;
        if(typeof _userGravity === 'function'){
            _userGravityFun = _userGravity;
            _userGravity = null;
        }
        
        opts.gravity = calculateGravity;
        
        var $fakeTipTarget, // target div
            $targetElem,
            nextOperationId = 0,
            prevMouseX,
            prevMouseY,
            _renderId,
            _mark,
            delayOut = opts.delayOut,
            id,
            usesPoint = opts.usesPoint,
            $canvas,
            isEnabled = opts.isEnabled,
            sharedTipsyInfo;
        
        opts.delayOut = 0; 
            
        function getTooltipText() {
            var instance = _mark.instance();
            var title = (instance && instance.tooltip) ||
                        // A mark method that is not a property?
                        (!_mark.properties.tooltip && typeof _mark.tooltip == 'function' && _mark.tooltip()) ||
                        instance.title ||
                        instance.text;
             
            // Allow deferred tooltip creation! 
            if(typeof title === 'function') {
                title = title();
            }
            
            return title || ""; // Prevent "undefined" from showing up
        }
        
        function getInstanceBounds() {
            var instance = _mark.instance();
            
            /*
             * Compute bounding box. 
             * TODO: support area, lines.
             */
            var left, top, width, height;
            if (_mark.properties.width) {
                // Bar, panel
                var bounds = getVisibleScreenBounds(_mark);
                
                left = bounds.left;
                top  = bounds.top;
                width  = bounds.width;
                height = bounds.height;
                
            } else {
                /* Compute the transform to offset the tooltip position. */
                var t = _mark.toScreenTransform();
                var radius;
                if(_mark.properties.outerRadius){
                    // Wedge
                    var midAngle = instance.startAngle + instance.angle / 2;
                    radius = instance.outerRadius;// - (instance.outerRadius - instance.innerRadius) * 0.05;
                    
                    left = t.x + instance.left + radius * Math.cos(midAngle);
                    top  = t.y + instance.top  + radius * Math.sin(midAngle);
                    
                } else if(_mark.properties.shapeRadius){
                    radius = Math.max(2, instance.shapeRadius);
                    var cx = instance.left;
                    var cy = instance.top;
        
                    switch(instance.shape){
                        case 'diamond':
                            radius *= Math.SQRT2;
                            // NOTE fall through
                            break;
                        
                        case 'circle':
                            // Want the inscribed square
                            radius /= Math.SQRT2;
                            break;
                    }
                    
                    left = (cx - radius) * t.k + t.x;
                    top  = (cy - radius) * t.k + t.y;
                    height = width = 2*radius * t.k;
                    
                    
                } else {
                    left = instance.left * t.k + t.x;
                    top  = instance.top  * t.k + t.y;
                }
            }
            
            var left2 = Math.ceil(left);
            var top2  = Math.ceil(top);
            
            var leftE = left2 - left; // >= 0 / < 1
            var topE  = top2  - top;  // >= 0 / < 1
            
            width  = Math.max(1, Math.floor((width  || 0) - leftE));
            height = Math.max(1, Math.floor((height || 0) - topE ));
            
            return { left: left2, top: top2, width: width, height: height };
        }
        
        // -------------------
        // TIPSY Gravity
        
        /**
         * Gravity is the direction of the tooltip arrow.
         * The arrow points to the target element. 
         *          
         *                                gravity = 'w'
         *    +-----------+                   n
         *    |           |              +----+----+
         *    |           |              |         |
         *    |   target  |          w <=+ Tooltip + e
         *    |           |              |         |
         *    |           |              +----+----+
         *    +-----------+                   s
         *
         */
        
        var _gravities = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'];
        
        function updateUserGravity(){
            if(_userGravityFun){
                _userGravity = _userGravityFun.call(_mark) || $.fn.tipsy.defaults.gravity;
            }
            
            return _userGravity;
        }
        
        function calculateGravity(tipSize, calcPosition){
            /*jshint expr:true */
            
            // <Debug>
            // jquery.tipsy calls this on the element to which it is attached
            (this === $fakeTipTarget[0]) || def.assert();
            // </Debug>
            
            var $win = $(window);
            var scrollOffset = {width: $win.scrollLeft(), height: $win.scrollTop()};
            var pageSize     = {width: $win.width(),      height: $win.height()   };
            
            // Desired gravity (assumes updateUserGravity was called previously)
            var gravity = _userGravity;
            
            // backwards compatibility for special gravity, 'c', 
            // added to jquery.tipsy to avoid the style applying to the arrow, 
            // causing it to not show.
            if(gravity === 'c'){
                gravity = 'w';
            }
            
            var bestScore = scoreGravity(gravity);
            if(!bestScore.isTotal){
                // Find the best scored gravity.
                // Start from the position *after* 'gravity' in the gravities array,
                // turning around when the end is reached.
                
                var g = _gravities.indexOf(gravity);
                for(var n = 1, L = _gravities.length ; n < L ; n++){
                    var i = (g + n) % L;
                    bestScore = chooseScores(bestScore, scoreGravity(_gravities[i]));
                }
                
                if(_tip.debug >= 21 && gravity !== bestScore.gravity){
                    _tip.log("[TIPSY] #" + _tipsyId + " Choosing gravity '" + bestScore.gravity + "' over '" + gravity + "'");
                }
                
                gravity = bestScore.gravity;
            }
            
            if(_tip.debug >= 21){
                _tip.log("[TIPSY] #" + _tipsyId + " Gravity '" + gravity + "'");
            }
            
            return gravity;
            
            function scoreGravity(gravity){
                var tp = calcPosition(gravity);
                return scorePosition(gravity, tp);
            }
            
            function scorePosition(gravity, tp){
                var wScore = calcPosScore(tp.left, 'width' );
                var hScore = calcPosScore(tp.top,  'height');
                var isTotal = wScore.fits && hScore.fits;
                
                return {
                    gravity:   gravity,
                    width:     wScore, 
                    height:    hScore,
                    value:     wScore.value + hScore.value + (2 - gravity.length), // prefer simple gravities
                    isTotal:   isTotal,
                    isPartial: !isTotal && (wScore.fits || hScore.fits)
                };
            }
            
            function calcPosScore(absPos, a_len){
                /*global window:true*/
                var maxLen = pageSize[a_len];
                var len    = tipSize[a_len];
                
                var pos  = absPos - scrollOffset[a_len];
                var opos = maxLen - (pos + len);
                var fits = pos >= 0 && opos >= 0;
                
                // Negative positions (off-screen) are amplified 4 times
                // so that they negatively impact the score more than positive ones.
                var value = (pos  >= 0 ?  pos : (4 *  pos)) + 
                            (opos >= 0 ? opos : (4 * opos));
                  
                return {fits: fits, value: value};
            }
        }
        
        function chooseScores(score1, score2){
            if(score1.isTotal){
                if(!score2.isTotal){
                    return score1;
                }
            } else if(score2.isTotal){
                if(!score1.isTotal){
                    return score2;
                }
            } else if(score1.isPartial){
                if(!score2.isPartial){
                    return score1;
                }
            } else if(score2.isPartial){
                if(!score1.isPartial){
                    return score2;
                }
            }
            
            // Are of same type. Can compare values.
            return score2.value > score1.value ? score2 : score1;
        }
        
        /* 
         * Places and sizes the tip target div
         * on the specified bounds.
         * 
         * Tipsy gravities point to this div.
         */
        function setFakeTipTargetBounds(bounds) {
            $fakeTipTarget.css({
                left:   bounds.left + parseFloat($canvas.css("padding-left")),
                top:    bounds.top  + parseFloat($canvas.css("padding-top" )),
                width:  bounds.width,
                height: bounds.height
            });
        }
        
        function createTipsy(mark) {
            var c = mark.root.canvas();
            
            $canvas = $(c);
            
            c.style.position = "relative";
            $canvas.mouseleave(hideTipsy);
            
            // ------------
            
            initTipsyCanvasSharedInfo();
            
            // ------------
            
            /* Use the specified div id or create a hopefully unique one */
            if(!id){
                id = "tipsyPvBehavior_" + new Date().getTime();
            }
            
            /*global document:true*/
            var fakeTipTarget = document.getElementById(id);
            if(!fakeTipTarget) {
                fakeTipTarget = document.createElement("div");
                fakeTipTarget.id = id;
                c.appendChild(fakeTipTarget);
            }
            
            var fakeStyle = fakeTipTarget.style;
            fakeStyle.padding = '0px';
            fakeStyle.margin  = '0px';
            fakeStyle.position = 'absolute';
            fakeStyle.pointerEvents = 'none'; // ignore mouse events (does not work on IE)
            fakeStyle.display = 'block';
            fakeStyle.zIndex = -10;
            
            $fakeTipTarget = $(fakeTipTarget);
            
            updateTipDebug();
            
            // Create the tipsy instance
            $fakeTipTarget.data('tipsy', null); // Otherwise a new tipsy is not created, if there's one there already
            $fakeTipTarget.tipsy(opts);
        }
        
        function initTipsyCanvasSharedInfo(){
            sharedTipsyInfo = $canvas.data('tipsy-pv-shared-info');
            if(sharedTipsyInfo){
                var createId = ($canvas[0].$pvCreateId || 0);
                
                if(sharedTipsyInfo.createId === createId){
                    sharedTipsyInfo.behaviors.push(hideTipsyOther);
                    return;
                }
                
                // Protovis has recreated the whole structure
                // So all existing tipsies (but this one) are invalid...
                // Hide them and let GC do the rest
                sharedTipsyInfo.behaviors.forEach(function(aHideTipsy){
                    aHideTipsy();
                });
            }
            
            
            sharedTipsyInfo = {
                createId:  ($canvas[0].$pvCreateId || 0),
                behaviors: [hideTipsyOther]
            };
            
            $canvas.data('tipsy-pv-shared-info', sharedTipsyInfo);
        }
        
        function updateTipDebug(){
            if($fakeTipTarget){
                if(_tip.debug >= 22){
                    $fakeTipTarget.css({
                        borderColor: 'red',
                        borderWidth: '1px',
                        borderStyle: 'solid',
                        zIndex:      1000
                    });
                } else {
                    $fakeTipTarget.css({
                        borderWidth: '0px',
                        zIndex:      -10
                    });
                }
            }
        }
        
        function getMouseBounds(ev){
            if(!ev){ ev = pv.event; }
            
            var delta = 5;
            var offset = $canvas.offset();
            return {
                left:   ev.pageX - offset.left - delta,
                top:    ev.pageY - offset.top  - delta,
                width:  10 + 2*delta,
                height: 20
            };
        }
        
        function setTarget(targetElem, mark){
            
            if((!$targetElem && targetElem) || 
               ( $targetElem && $targetElem[0] !== targetElem)){
                if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Changing target element."); }
                
                if($targetElem){
                    $targetElem.unbind('mousemove', updateTipsy);
                    
                    if(!usesPoint) {
                        $targetElem.unbind('mouseleave', hideTipsy);
                    }
                }
                
                // ---------
                
                $targetElem = targetElem ? $(targetElem) : null;
                _mark = targetElem  ? mark : null;
                
                prevMouseX = prevMouseY = _renderId = null;
                
                // ---------
                
                if($targetElem){
                    $targetElem.mousemove(updateTipsy);
                    
                    if(!usesPoint) {
                        $targetElem.mouseleave(hideTipsy);
                    }
                }
            }
        }
        
        function getNewOperationId(){
            return nextOperationId++;
        }
        
        function checkCanOperate(opId){
            return opId === nextOperationId - 1;
        }
        
        function hideTipsy() {
            var opId = getNewOperationId();
            
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Delayed Hide Begin opId=" + opId); }
            
            if(delayOut > 0){
                window.setTimeout(function(){
                    if(checkCanOperate(opId)){
                        if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Hiding opId=" + opId + " nextOperationId=" + nextOperationId); }
                        hideTipsyCore(opId);
                    } else {
                        if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Delayed Hide Cancelled opId=" + opId); }
                    }
                }, delayOut);
                
                return;
            }
            
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Hiding Immediately opId=" + opId); }
            hideTipsyCore(opId);
        }
        
        function hideTipsyOther() {
            var opId = getNewOperationId();
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Hiding as Other opId=" + opId); }
            hideTipsyCore(opId);
        }
        
        function hideTipsyCore(opId) {
            // Release real target
            setTarget(null, null);
          
            if ($fakeTipTarget) {
                $fakeTipTarget.tipsy("leave");
            }
        }
        
        function hideOtherTipsies(){
            var hideTipsies = sharedTipsyInfo && sharedTipsyInfo.behaviors;
            if(hideTipsies && hideTipsies.length > 1){
                hideTipsies.forEach(function(aHideTipsy){
                    if(aHideTipsy !== hideTipsyOther){
                        aHideTipsy();
                    }
                });
            }
        }
        
        function updateTipsy(ev){
            if(!$fakeTipTarget) {
                return;
            }
            
            /* Don't know why: 
             * the mouseover event is triggered at a fixed interval
             * as long as the mouse is over the element, 
             * even if the mouse position does not change... 
             */
            if(prevMouseX != null && 
               Math.abs(ev.clientX - prevMouseX) < 3  && 
               Math.abs(ev.clientY - prevMouseY) < 3){ 
                 return;
            }
            
            var tag = this.$scene;
            // tag = {scenes: scenes, index: index}
            // instance = scenes[index];
            // mark     = scenes.mark;
            
            var scenes;
            if(!tag || !(scenes = tag.scenes) || !scenes.mark || (scenes.mark !== _mark)){
                return;
            }
            
            var renderId = _mark.renderId();
            var renderIdChanged = (renderId !== _renderId);
            var followMouse = opts.followMouse;
            
            if(!followMouse && !renderIdChanged){
                return;
            }
            
            var opId = getNewOperationId();
                    
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Updating opId=" + opId); }
            
            prevMouseX = ev.clientX;
            prevMouseY = ev.clientY;
            
            // -------------
            
            var bounds;
            if(followMouse){
                bounds = getMouseBounds(ev);
            }
            
            if(renderIdChanged){
                // => update bounds, text and gravity
                
                _renderId = renderId;
                
                // Position is updated because, 
                // otherwise, animations that re-render
                // the hovering mark causing it to move,
                // in a way that the mouse is still kept inside it,
                // we have to update the position of the tooltip as well.
                
                _mark.context(scenes, tag.index, function(){
                    
                    if(!followMouse){
                        bounds = getInstanceBounds();
                    }
                    
                    var text = getTooltipText();
                    
                    if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Update text. Was hidden. Text: " + text); }
                    
                    $fakeTipTarget.tipsy('setTitle', text); // does not update the tooltip UI
                    
                    updateUserGravity();
                });
            }
            
            setFakeTipTargetBounds(bounds);
            
            hideOtherTipsies();
            
            $fakeTipTarget.tipsy("update");
        }
        
        function initBehavior(mark){
            // First time
            
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Creating"); }
            
            createTipsy(mark);
            
            /* Cleanup the tooltip span on mouseout.
             * This is necessary for dimensionless marks.
             *
             * Note that the tip has pointer-events disabled
             * (so as to not interfere with other mouse events, such as "click");
             * thus the mouseleave event handler is registered on
             * the event target rather than the tip overlay.
             */
            if(usesPoint){
                // Behavior is being used as a 'point' event handler
                // Should hide the tipsy only in the unpoint event
                mark.event('unpoint', hideTipsy);
            }
        }
        
        function showTipsy(mark) {
            var opId = getNewOperationId();
            
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Show IN opId=" + opId); }
            
            if (!$canvas) {
                initBehavior(mark);
            }
            
            var isHidden = !$targetElem;
            
            setTarget(pv.event.target, mark);
            
            var text = getTooltipText();
            
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Text: " + text); }
            
            $fakeTipTarget.tipsy('setTitle', text);
            
            setFakeTipTargetBounds(opts.followMouse ? getMouseBounds() : getInstanceBounds());
            
            updateUserGravity();
            
            hideOtherTipsies();
            
            if(isHidden){
                $fakeTipTarget.tipsy('enter');
            } else {
                $fakeTipTarget.tipsy('update');
            }
            
            if(_tip.debug >= 20){ _tip.log("[TIPSY] #" + _tipsyId + " Show OUT"); }
        }
        
        // On 'point' or 'mouseover' events, according to usesPoint option
        function tipsyBehavior() {
            // The mark that the tipsy-behavior is attached to
            var mark = this;
            
            if(!isEnabled || isEnabled(tipsyBehavior, mark)){
                showTipsy(mark);
            }
        }
    
        return tipsyBehavior;
    }; // END pv.Behavior.tipsy
    
    var _tip = pv.Behavior.tipsy;
    _tip.debug = 0;
    _tip.setDebug = function(level){
        _tip.debug = level;
    };
    
    _tip.log = function(m){
        /*global console:true*/
        if(typeof console !== "undefined"){
            console.log('' + m);
        }
    };
    
    function toParentTransform(parentPanel){
        return pv.Transform.identity.
                    translate(parentPanel.left(), parentPanel.top()).
                    times(parentPanel.transform());
    }
    
    function getVisibleScreenBounds(mark){
    
        var instance = mark.instance(),
            left   = instance.left,
            top    = instance.top,
            width  = instance.width,
            height = instance.height,
            right,
            bottom,
            parent;
    
        while ((parent = mark.parent)){
    
            // Does 'mark' fit in its parent?
            if(left < 0){
                width += left;
                left = 0;
            }
    
            if(top < 0){
                height += top;
                top = 0;
            }
            
            right  = instance.right;
            if(right < 0){
                width += right;
            }
    
            bottom = instance.bottom;
            if(bottom < 0){
                height += bottom;
            }
    
            // Transform to parent coordinates
            var t = toParentTransform(parent),
                s = t.k;
    
            left   = t.x + (s * left);
            top    = t.y + (s * top );
            width  = s * width;
            height = s * height;
    
            mark = parent;
            instance = mark.instance();
        }
    
        return {
            left:   left,
            top:    top,
            width:  width,
            height: height
        };
    }

}());
