(function($) {

var dancer = window.dancer = window.dancer || {};


/**
 * The default color points will turn a percentage into a value between red (0)
 * and green (1), with yellow (.5) inbetween.
 */
var defaultColorPoints = [
   {value: 0.0, color: [255, 0, 0]},
   {value: 0.5, color: [255, 255, 0]},
   {value: 1.0, color: [0, 255, 0]}
];


/**
 * Returns the color of value, interpolated over the values of colorPoints. The argument
 * {@code value} should be a Number. {@code colorPoints} should be an array of maps 
 * (objects). Each map should have 2 properties: 'value' and 'color', which represents
 * the color to use at that particular value. The color itself should be an array of
 * 3 elements, whose values are between 0-255. This function will return an array of 3
 * values between 0-255.
 *
 * @param value A number value
 * @param colorPoints a set of color points to interpolate value over
 * @return An array of 3 integers representing the RGB color
 */
dancer.colorFromScalar = function(value, colorPoints) {
    var lb = colorPoints[0],
        ub = colorPoints[colorPoints.length - 1];
    colorPoints = colorPoints || defaultColorPoints
    
    $.each(colorPoints, function(i, cp) {
        if (cp.value > lb.value && cp.value <= value)
            lb = cp;
        if (cp.value < ub.value && cp.value >= value)
            ub = cp;
    });
    var dist = ub.value - lb.value;
    if (dist == 0)
        return lb.color;
    var lbWeight = (ub.value - value) / dist,
        ubWeight = (value - lb.value) / dist;
    return [
            lb.color[0] * lbWeight + ub.color[0] * ubWeight,
            lb.color[1] * lbWeight + ub.color[1] * ubWeight,
            lb.color[2] * lbWeight + ub.color[2] * ubWeight
        ];
};


/**
 * Given an array of 3 integers, representing the color components R, R, B,
 * this returns a CSS string for that colour. The values will be clamped
 * between [0,255] (anything higher or lower will be clipped at one end).
 */
dancer.cssColorFromArray = function(color) {
    color = color.slice();
    $.each(color, function(i) {
        color[i] = Math.max(0, Math.min(255, Math.floor(color[i])));
    });
    return "rgb(" + color.join(",") + ")";
};


/**
 * This will iterate through all rows in the "table" (any element with tr descendants),
 * obtain the "values" for that row with getValues, and map each value returned to the
 * row it was returned for. The function passed in the first argument should take no
 * arguments and return an array of values. The function will be bound to the current
 * row (ie. this == currentRow).
 * 
 * @param getValues A function that will return an array of values for a row
 * @return A JS Object that maps values to rows
 */
$.fn.valueToRowMap = function(getValues) {
    var valueToRow = {};
    
    this.find("tr").each(function(i, row) {
        var values = getValues.call(row, row);
        $.each(values, function() {
            valueToRow[this] = row
        });
    });
    
    return valueToRow;
};


/**
 * Returns an object that can be used to query information about the gene
 * represented by the context node.
 * 
 * @return A JS Object that can query gene info.
 */
$.fn.gene = function() {
    var gene = this;
    
    return {
        name: function() {
            return gene.find(".geneName").text().trim();
        },
        type: function() {
            return $(".geneType", gene).text().trim();
        },
        diseaseCount: function() {
            return parseInt($(".geneDiseaseCount .count", gene).text().trim(), 10);
        },
        homologCount: function() {
            return parseInt($(".geneHomologCount .count", gene).text().trim(), 10);
        },
        taxonomy: function() {
            return $(".geneTaxonomy", gene).text().trim();
        },
        ids: function() {
            var ids = [];
            $(".geneId", gene).each(function() {
                ids.push($(this).text().trim());
            });
            return ids;
        },
        entrezId: function () {
            return $(".geneId-entrezgene", gene).text().trim();
        },
        cmConfirmed: function() {
            return gene.find(".geneStatusConfirmed").length > 0;
        },
        neighbourCount: function() {
            var nc = parseInt(gene.find(".geneNeighbourCount").text(), 10);
            return isNaN(nc) ? 0 : nc;
        },
        neighbourDiseaseCount: function() {
            var ndc = parseInt(gene.find(".geneNeighbourDiseaseCount").text(), 10);
            return isNaN(ndc) ? 0 : ndc;
        }
    };
};


/**
 * Returns "button" versions of the matched elements with gene information. The
 * button is small and contains only the name, status, homolog count, and
 * disease count.
 */
$.fn.geneButton = function() {
    var buttons = [];
    this.each(function(i, src) {
        var gene = $(this).gene(),
            button = $("<ul class='geneButton' />");
        button.append("<li class='geneName first'>" + gene.name() + "</li>");
        button.append("<li class='geneStatus'>" + (gene.cmConfirmed() ? "&#x2713;" : "?") + "</li>");
        button.append("<li class='geneHomologCount'>" + gene.homologCount() + "</li>");
        button.append("<li class='geneDiseaseCount last'>" + gene.diseaseCount() + "</li>");
        button.click(function() {
            $.scrollTo(src, {duration: 500});
        });
        buttons.push(button.get(0));
    });
    return $(buttons);
};


/**
 * Allows sorting by key, rather than comparison function. For example, case
 * insensitive sorting becomes: 
 * 
 *  $(stringList).sortBy(function(a) { return a.toLowerCase(); })
 * 
 * This is particularly useful when the key function requires a significant
 * amount of time to run, as it is only called n times, vs. O(n log n).
 * For example, if the keyFunc requires O(k) time, then sortBy will sort
 * the list in T(n) = O(k)*n + O(n log n) = O(kn + n log n) time, rather
 * than O(kn log n) by using just sort and calling keyFunc each comparison.
 */
$.fn.sortBy = function(keyFunc) {
    var keys = [];
    this.each(function() { keys.push({k: keyFunc(this), v: this}); });
    keys.sort(function(a, b) {
        return a.k == b.k ? 0 : (a.k < b.k ? -1 : 1);
    });
    
    this.length = 0;
    for (var i = 0, len = keys.length; i < len; i++) {
        this.push(keys[i].v);
    }
    return this;
};


/**
 * Sorts a table by some key function defined for each row. The table given
 * should have a tbody element, otherwise the all rows sorted, even ones that
 * may be part of the header or footer. The function requires a key function
 * that is used to map rows to values (ie. it takes a row as a single argument
 * and returns a value to be sorted on). It can also take an optional map of
 * extra arguments. The available settings are,
 * 
 *  reverse: If true, the sorted order will be reversed (default: false).
 *  body:    The container of the rows, eg. a "tbody" (default: null).
 *  rows:    The rows to sort (default: null).
 * 
 * If body is null, then the tbody child of the table will be used, or the
 * table itself, if it does not have a tbody. If rows is null, then all tr
 * elements in body will be used.
 * 
 * @param keyFunc A function that maps a row to a value for comparison
 * @param opt A map of optional settings (eg. reverse:boolean)
 * @return The table that was sorted
 */
$.fn.sortTableBy = function(keyFunc, opt) {
    opt = $.extend({}, $.fn.sortTableBy.defaults, opt);
    
    this.each(function() {
        var self = $(this),
            body = opt.body || $("tbody", this);
        if (!body.length)
            body = $(this); // Default to the actual "table" element.
        var rows = opt.rows || body.find("tr");
        
        self.trigger("sortstart");
        rows.sortBy(keyFunc);
        self.trigger("sortend");
        
        if (opt.reverse)
            Array.prototype.reverse.call(rows);
        
        rows.remove().appendTo(body);
    });
    
    return this;
};


/**
 * Defaults for .sortTableBy()
 */
$.fn.sortTableBy.defaults = {
    reverse: false,
    body: null,
    rows: null
};


function floatKeyFunc(n) {
    return parseFloat($(n).text());
}

var numberColorPoints = [
        {value: 0, color: [255, 150, 150]},
        {value: .5, color: [255, 255, 150]},
        {value: 1, color: [150, 255, 150]}
    ];

function numberType() {
    return {
        colorPoints: numberColorPoints,
        keyFunc: floatKeyFunc
    };
}

var enumColorPoints = [
        {value: 0, color: [255, 150, 150]},
        {value: .2, color: [150, 255, 150]},
        {value: .4, color: [255, 255, 150]},
        {value: .6, color: [150, 150, 255]},
        {value: .8, color: [255, 150, 255]},
        {value: 1, color: [150, 255, 255]}
    ];

function enumType() {
    var valueMap = {nextId: 0};
    return {
        colorPoints: enumColorPoints,
        keyFunc: function(n) {
            var value = $.trim($(n).text());
            if (typeof valueMap[value] == "undefined")
                valueMap[value] = valueMap.nextId++;
            return valueMap[value];
        }
    };
}


/**
 * Color code a set of elements by their "value". Values that are greater will be
 * colored "higher" than lesser ones. The actual color value is interpolated over
 * the set of color points.
 * 
 * The keyFunc argument is a function that should turn one of the matched nodes
 * into a number value; that is it takes 1 node as an argument and returns a
 * number (keyFunc: Node -> Number).
 * 
 * The actual colorPoints argument is an array of objects.
 * Each object has 2 properties: color and value. The color is an array of 3 ints
 * representing the RGB value of the color for that point. The value is some
 * number which would be converted to that color. The actual final colour of an
 * arbitrary value is found by a weighted mixing its 2 closest neighbors (one on
 * each side). If this argument is omitted, a default mapping will be used that
 * maps the values between red, yellow, and green (0, 0.5, and 1 resp.).
 * 
 * The css argument is used to determine which css property to assign the color.
 * By default, this is "color" (ie. foreground color).
 * 
 * Note that each value is put in [0,1] using a linear scaling. If you wish
 * to use a non-linear scaling (eg. logarithmic, stepped, etc.), you should
 * handle this in the "keyFunc". For example,
 * 
 *  jqObj.colorCode({keyFunc: function(n) {
 *      return Math.log(parseInt($(n).text(), 10))
 *  }});
 * 
 * @param keyFunc The "key" function to map nodes to numbers.
 * @param colorPoints An optional array of "color points".
 * @return The jQuery instance.
 */
$.fn.colorCode = function(type, opts) {
    if (!this.length)
        return this;
    
    var baseOpts = {};
    if (typeof type == "string") {
        if (type == "number")
            baseOpts = numberType();
        else if (type == "enum")
            baseOpts = enumType();
    } else {
        opts = type;
    }
    
    
    opts = $.extend({}, $.fn.colorCode.defaults, baseOpts, opts);
    var userLb = typeof opts.lb == "number",
        userUb = typeof opts.ub == "number",
        lb = userLb ? opts.lb : opts.keyFunc(this.get(0)),
        ub = userUb ? opts.ub : lb;
    
    // Get the lower/upper bound for the node values
    
    this.each(function() {
        var cnt = opts.keyFunc(this);
        if (!userLb && cnt < lb)
            lb = cnt;
        if (!userUb && cnt > ub)
            ub = cnt;
    });
    
    var dist = ub - lb;
    this.each(function() {
        
        // Note: we use a simple linear scaling to put cnt in [0,1]
        
        var cnt = opts.keyFunc(this),
            color = dancer.cssColorFromArray(
                    dancer.colorFromScalar((cnt - lb) / dist, opts.colorPoints)
                );
       // $(this).css(opts.css, color);
    });
    
    return this;
};


$.fn.colorCode.defaults = {
    keyFunc: function(n) {
        return parseInt($.trim($(n).text()), 10);
    },
    colorPoints: [
      {value: 0, color: [255, 0, 0]},
      {value: .5, color: [255, 255, 0]},
      {value: 1, color: [0, 255, 0]}
    ],
    css: 'color',
    lb: null,
    ub: null
};


/**
 * Returns true if the node n is "in view." This means that the node is
 * somewhere within the user's viewable area.
 */
function isInView(n) {
    var $win = $(window),
        winLower = $win.scrollTop(),
        winUpper = winLower + $win.height(),
        nLower = n.offset().top,
        nUpper = nLower + n.height();
    return nUpper >= winLower && nLower <= winUpper;
}


/**
 * Pins the header of a table so that it always remains visible. If the user
 * scrolls down so that the header is out of view, then it will "detach" itself
 * from the table and fix itself at the top of the viewable area. The method
 * should be called on tables only. After a table's header has been pinned, it
 * can be unpinned again by calling unpinHeader() on the table.
 * 
 * Calling this method will cause the table to emit 2 new events; 'pinHeader'
 * and 'unpinHeader' that are called when the table's header is pinned and
 * unpinned resp.
 */
$.fn.pinHeader = function(opts) {
    opts = $.extend({}, $.fn.pinHeader.defaultOpts, opts);
    
    this.each(function() {
        var table = $(this),
            collapsed = table.css("borderCollapse") == "collapse";
        
        // Check if a floater already exists; if so, don't recreate it.
        
        if (table.data("pinHeader"))
            return;
        
        // The header is, ideally, in a thead tag, but any row with headings
        // should do. If none of these exists, then we do nothing.
        
        var header = opts.header || (table.is(":has(thead)") 
                                        ? table.find("thead tr:eq(0)")
                                        : table.find("tr:has(th):eq(0)"));
        if (!header.length)
            return;
        
        var floater = header.clone(true).css({position: "fixed", top: "0", zIndex: "100"}),
            isFloating = false;
        
        var handleScroll = function() {
            if (!isInView(table) || header.offset().top >= $(window).scrollTop()) {
                if (isFloating) {
                    floater.remove();
                    isFloating = false;
                }
                return;
            }
            
            if (!isFloating) {
                header.parent().append(floater);
                isFloating = true;
            }
            
            floater.css("left", header.offset().left - $(window).scrollLeft());
            if (floater.width() != header.width()) {
                
                // FIXME: Bug in jQuery when dealing with collapsed borders causes this to
                // be executed EVERY scroll. That is bad. Deal with the collapsed issue.
                
                var headerCols = $("td,th", header.get(0)),
                    floaterCols = $("td,th", floater.get(0));
                headerCols.each(function(i) {
                    var hCol = $(this),
                        w = hCol.width();
                    var col = $(floaterCols.get(i)).width(w);
                    
                    // TODO: This is actually a fix for a bug in jQuery. If the bug is fixed,
                    // then the "collapsed" condition and corresponding code can be removed.
                    
                    if (col.width() != w && collapsed) {
                        w += Math.round(
                                parseInt(hCol.css("borderLeftWidth")) * 0.5 +
                                parseInt(hCol.css("borderRightWidth")) * 0.5
                            );
                        col.width(w);
                    }
                });
            }
        };
        
        table.bind("pinHeader", function() {
            $(window).bind("scroll", handleScroll);
            header.addClass("pinned");
            floater.addClass("pinned");
        });
        
        table.bind("unpinHeader", function() {
            $(window).unbind("scroll", handleScroll);
            floater.remove();
            isFloating = false;
            header.removeClass("pinned");
            floater.removeClass("pinned");
        });
        
        table.data("pinHeader", true);
        
    }).trigger("pinHeader");
    
    return this;
};


/**
 * Unpin the header of a table that has already been pinned. Once called, the
 * floating header (if visible) will disappear and the table's header will
 * behave normally. If the table's header was not already pinned, this does
 * nothing (it can safely be called on unpinned tables).
 */
$.fn.unpinHeader = function() {
    this.trigger("unpinHeader");
    return this;
};

$.fn.pinHeader.defaultOpts = {
    header: null
};


/**
 * Replace a .stats definition list with a pretty graph based off of box plots.
 * The method takes its arguments as an (optional) map passed in the first
 * argument. The possible properties that can be used in the map are:
 * 
 *  position: One of 'before', 'after', 'replace', 'orphan'. This decides where
 *            to place the stats graph relative to the context. before, after, 
 *            and replace are self-explanatory. orphan means to not add the
 *            graphs to the DOM at all, but simply create/return them.
 *  width:    The width of the graph in pixels (default is width of context).
 *  height:   The height of the graph in pixels (default is 16px).
 *  
 * @param opts An optional map of named arguments.
 * @return A jQuery instance of the graphs created.
 */
$.fn.statsGraph = function(opts) {
    opts = $.extend({}, $.fn.statsGraph.defaultOpts, opts);
    var graphs = [];
    
    this.each(function() {
        var $canvas = $("<canvas class='stats-graph' />"),
            canvas = $canvas.get(0);
        canvas.width = opts.width ? opts.width : $(this).width();
        canvas.height = opts.height ? opts.height : 16;
        
        if (typeof canvas.getContext == "undefined")
            return;
        
        var min = parseFloat($(".min", this).text()),
            max = parseFloat($(".max", this).text()),
            mean = parseFloat($(".mean", this).text()),
            stddev = parseFloat($(".stddev", this).text()),
            lb = Math.min(min, mean - stddev),
            ub = Math.max(max, mean + stddev),
            domainWidth = ub - lb;
        
        // FIXME: This may, or may not, be the "right" thing to do.
        if (domainWidth == 0)
            return;
        
        // Maps points on x-axis in the stat interval -> canvas interval
        function toCanvas(x) {
            return Math.round((canvas.width - 1) * (x - lb) / domainWidth);
        }

        // Maps points on x-axis in the canvas interval -> stat interval
        function toStat(x) {
            return x * domainWidth / (canvas.width - 1) + lb;
        }
        
        // Draws are actually handled by an event on the canvas element ("draw").
        // This lets us easily redraw the graph, if need be, using, simply,
        // $canvas.trigger("draw").
        $canvas.bind("draw", function() {
            var ctxt = canvas.getContext("2d");
            
            // ctxt.fillStyle = "rgb(150,150,150)";
            // ctxt.fillRect(0, 0, canvas.width, canvas.height);

            // Draw the range

            var grad = ctxt.createLinearGradient(0, 0, canvas.width, canvas.height);
            $.each(opts.colorPoints.sort(function(a, b) { return a.value - b.value }), function(i, cp) {
                console.log(cp);
                grad.addColorStop(cp.value, dancer.cssColorFromArray(cp.color));
            });
            /*
            grad.addColorStop(0.0, "rgb(255,150,150)");
            grad.addColorStop(0.5, "rgb(255,255,150)");
            grad.addColorStop(1.0, "rgb(150,255,150)");
            */
                    
            ctxt.fillStyle = grad;
            ctxt.fillRect(toCanvas(min), 0, toCanvas(max) - toCanvas(min), 16);
            
            // Draw the whiskers (+/- 1 std. dev.)
            
            ctxt.fillStyle = "rgb(0,0,0)";
            ctxt.fillRect(toCanvas(mean - stddev), 3, 1, 9);
            ctxt.fillRect(toCanvas(mean + stddev), 3, 1, 9);
            ctxt.fillRect(toCanvas(mean - stddev), 7, toCanvas(2 * stddev + lb), 1);
            
            // Draw the mean
            
            ctxt.fillStyle = "rgb(255, 0, 0)";
            ctxt.fillRect(toCanvas(mean), 0, 1, 16);
        }).trigger("draw");

        $canvas.mousemove(function(e) {
            if (typeof console != "undefined")
                console.log(toStat(e.pageX - $canvas.offset().left));
        });

        // Put the canvas into the DOM
        
        if (opts.position == 'before') {
            $(this).before(canvas);
        } else if (opts.position == 'after') {
            $(this).after(canvas);
        } else if (opts.position == 'replace') {
            $(this).replaceWith(canvas);
        } else if (opts.position == 'orphan') {
            // Leave the graph out of DOM.
        } else {
            throw new Error("Invalid position for stats graph: " + opts.position);
        }
        
        graphs.push(canvas);
    });
    
    return $(graphs);
};

$.fn.statsGraph.defaultOpts = {
    position: 'before',
    width: 0,
    height: 0,
    colorPoints: $.fn.colorCode.defaults.colorPoints
};


function prefixLength(a, b, i) {
      var len = Math.min(a.length, b.length);
      for (i = i || 0; i < len && a[i] == b[i]; i++);
      return i;
}
  
var word = /\w/;
  
function wordAt(str, pos) {
    var l, r, len = str.length;
    for (l = pos; l >= 0 && str[l].match(word); l--);
    for (r = pos; r < len && str[r].match(word); r++);
    return { start: l + 1, end: r, term: str.substring(l + 1, r) };
}

var simpleQueryService = "/dancer/search/simple";

dancer.addAutocompleteToGeneIdList = function(node) {
    var prevTerm = "";
      
    $(node).autocomplete({
        source: function(request, response) {
            var chunk = wordAt(request.term, prefixLength(prevTerm, request.term));
              
            $.getJSON(simpleQueryService, { q: chunk.term }, function(data) {
                response($.map(data.result, function(item) {
                    return {
                        label: item.id + " (" + item.name + ")",
                        value: {
                            chunk: chunk,
                            geneId: item.id
                        }
                    };
                }));
            });
              
            prevTerm = request.term;
        },
        select: function(e, ui) {
            var chunk = ui.item.value.chunk;
            this.value = [
                    this.value.substring(0, chunk.start),
                    ui.item.value.geneId,
                    this.value.substring(chunk.end)
                ].join("");
            return false;
        }
    });
};

$(".geneIdList.autocomplete").each(function() {
    dancer.addAutocompleteToGeneIdList(this);
});

$(".idListEditor").each(function() {
    var editor = $(this);
  
    function getIdList() {
        var idListNode = editor.find(".idList"),
        idList = $.trim(idListNode.is(":input") ? idListNode.val() : idListNode.text());
        return idList;
    }
  
    function getIdListType() {
        var idListTypeNode = editor.find(".idListType"),
        idListType = $.trim(idListTypeNode.is(":input") ? idListTypeNode.val() : idListTypeNode.text());
        return idListType;
    }
  
    function makeEditable() {
        $.getJSON("/dancer/geneIdList/" + encodeURI(getIdListType()) + "/" + encodeURI(getIdList()), function(data) {
            if (data.error) {
                alert(data.error);
                return;
            }
  
            var ids = $("<textarea name='ids' class='ids' />").text(data.ids);
            dancer.addAutocompleteToGeneIdList(ids);
            editor.empty().append(ids);
            ids.autoResize({ extraSpace: 0 }).trigger("change");
        });
    }
  
    var edit = $("<a href='#'>Edit</a>");
    edit.click(function(e) {
        e.preventDefault();
        makeEditable();
    });
    editor.append(edit);
});

$("textarea.ids").autoResize({extraSpace: 0});

function topk(a, k, cmp) {
    var capacity = k * 2,
        buffer = new Array(capacity),
        size = 0;
    a.forEach(function(x) {
        if (size == capacity) {
            buffer.sort(cmp);
            size = k;
        } 
        buffer[size++] = x;
    });
    return buffer.sort(cmp).slice(0, Math.min(size, k));
}

})(jQuery);

