// Per-frame time sampling infra. Also GC'd: hopefully will not perturb things too badly.
var numSamples = 500;
var delays = new Array(numSamples);
var gcs = new Array(numSamples);
var minorGCs = new Array(numSamples);
var gcBytes = new Array(numSamples);
var mallocBytes = new Array(numSamples);
var sampleIndex = 0;
var sampleTime = 16; // ms
var gHistogram = new Map(); // {ms: count}

var features = {
  trackingSizes: ('mozMemory' in performance),
  showingGCs: ('mozMemory' in performance),
};

// Draw state.
var stopped = 0;
var start;
var prev;
var latencyGraph;
var memoryGraph;
var ctx;
var memoryCtx;

// Current test state.
var activeTest = undefined;
var testDuration = undefined; // ms
var testState = 'idle';  // One of 'idle' or 'running'.
var testStart = undefined; // ms
var testQueue = [];

// Global defaults
var globalDefaultGarbageTotal = "8M";
var globalDefaultGarbagePerFrame = "8K";

function Graph(ctx) {
    this.ctx = ctx;

    var { width, height } = ctx.canvas;
    this.layout = {
        xAxisLabel_Y: height - 20,
    };
}

Graph.prototype.xpos = index => index * 2;

Graph.prototype.clear = function () {
    var { width, height } = this.ctx.canvas;
    this.ctx.clearRect(0, 0, width, height);
};

Graph.prototype.drawScale = function (delay)
{
    this.drawHBar(delay, `${delay}ms`, 'rgb(150,150,150)');
}

Graph.prototype.draw60fps = function () {
    this.drawHBar(1000/60, '60fps', '#00cf61', 25);
}

Graph.prototype.draw30fps = function () {
    this.drawHBar(1000/30, '30fps', '#cf0061', 25);
}

Graph.prototype.drawAxisLabels = function (x_label, y_label)
{
    var ctx = this.ctx;
    var { width, height } = ctx.canvas;

    ctx.fillText(x_label, width / 2, this.layout.xAxisLabel_Y);

    ctx.save();
    ctx.rotate(Math.PI/2);
    var start = height / 2 - ctx.measureText(y_label).width / 2;
    ctx.fillText(y_label, start, -width+20);
    ctx.restore();
}

Graph.prototype.drawFrame = function () {
    var ctx = this.ctx;
    var { width, height } = ctx.canvas;

    // Draw frame to show size
    ctx.strokeStyle = 'rgb(0,0,0)';
    ctx.fillStyle = 'rgb(0,0,0)';
    ctx.beginPath();
    ctx.moveTo(0, 0);
    ctx.lineTo(width, 0);
    ctx.lineTo(width, height);
    ctx.lineTo(0, height);
    ctx.closePath();
    ctx.stroke();
}

function LatencyGraph(ctx) {
    Graph.call(this, ctx);
    console.log(this.ctx);
}

LatencyGraph.prototype = Object.create(Graph.prototype);

Object.defineProperty(LatencyGraph.prototype, 'constructor', {
    enumerable: false,
    value: LatencyGraph });

LatencyGraph.prototype.ypos = function (delay) {
    var { height } = this.ctx.canvas;

    var r = height + 100 - Math.log(delay) * 64;
    if (r < 5) return 5;
    return r;
}

LatencyGraph.prototype.drawHBar = function (delay, label, color='rgb(0,0,0)', label_offset=0)
{
    var ctx = this.ctx;

    ctx.fillStyle = color;
    ctx.strokeStyle = color;
    ctx.fillText(label, this.xpos(numSamples) + 4 + label_offset, this.ypos(delay) + 3);

    ctx.beginPath();
    ctx.moveTo(this.xpos(0), this.ypos(delay));
    ctx.lineTo(this.xpos(numSamples) + label_offset, this.ypos(delay));
    ctx.stroke();
    ctx.strokeStyle = 'rgb(0,0,0)';
    ctx.fillStyle = 'rgb(0,0,0)';
}

LatencyGraph.prototype.draw = function () {
    var ctx = this.ctx;

    this.clear();
    this.drawFrame();

    for (var delay of [ 10, 20, 30, 50, 100, 200, 400, 800 ])
        this.drawScale(delay);
    this.draw60fps();
    this.draw30fps();

    var worst = 0, worstpos = 0;
    ctx.beginPath();
    for (var i = 0; i < numSamples; i++) {
        ctx.lineTo(this.xpos(i), this.ypos(delays[i]));
        if (delays[i] >= worst) {
            worst = delays[i];
            worstpos = i;
        }
    }
    ctx.stroke();

    // Draw vertical lines marking minor and major GCs
    if (features.showingGCs) {
        var { width, height } = ctx.canvas;

        ctx.strokeStyle = 'rgb(255,100,0)';
        var idx = sampleIndex % numSamples;
        var gcCount = gcs[idx];
        for (var i = 0; i < numSamples; i++) {
            idx = (sampleIndex + i) % numSamples;
            if (gcCount < gcs[idx]) {
                ctx.beginPath();
                ctx.moveTo(this.xpos(idx), 0);
                ctx.lineTo(this.xpos(idx), this.layout.xAxisLabel_Y);
                ctx.stroke();
            }
            gcCount = gcs[idx];
        }

        ctx.strokeStyle = 'rgb(0,255,100)';
        idx = sampleIndex % numSamples;
        gcCount = gcs[idx];
        for (var i = 0; i < numSamples; i++) {
            idx = (sampleIndex + i) % numSamples;
            if (gcCount < minorGCs[idx]) {
                ctx.beginPath();
                ctx.moveTo(this.xpos(idx), 0);
                ctx.lineTo(this.xpos(idx), 20);
                ctx.stroke();
            }
            gcCount = minorGCs[idx];
        }
    }

    ctx.fillStyle = 'rgb(255,0,0)';
    if (worst)
        ctx.fillText(`${worst.toFixed(2)}ms`, this.xpos(worstpos) - 10, this.ypos(worst) - 14);

    // Mark and label the slowest frame
    ctx.beginPath();
    var where = sampleIndex % numSamples;
    ctx.arc(this.xpos(where), this.ypos(delays[where]), 5, 0, Math.PI*2, true);
    ctx.fill();
    ctx.fillStyle = 'rgb(0,0,0)';

    this.drawAxisLabels('Time', 'Pause between frames (log scale)');
}

function MemoryGraph(ctx) {
    Graph.call(this, ctx);
    this.worstEver = this.bestEver = performance.mozMemory.zone.gcBytes;
    this.limit = Math.max(this.worstEver, performance.mozMemory.zone.gcAllocTrigger);
}

MemoryGraph.prototype = Object.create(Graph.prototype);

Object.defineProperty(MemoryGraph.prototype, 'constructor', {
    enumerable: false,
    value: MemoryGraph });

MemoryGraph.prototype.ypos = function (size) {
    var { height } = this.ctx.canvas;

    var range = this.limit - this.bestEver;
    var percent = (size - this.bestEver) / range;

    return (1 - percent) * height * 0.9 + 20;
}

MemoryGraph.prototype.drawHBar = function (size, label, color='rgb(150,150,150)')
{
    var ctx = this.ctx;

    var y = this.ypos(size);

    ctx.fillStyle = color;
    ctx.strokeStyle = color;
    ctx.fillText(label, this.xpos(numSamples) + 4, y + 3);

    ctx.beginPath();
    ctx.moveTo(this.xpos(0), y);
    ctx.lineTo(this.xpos(numSamples), y);
    ctx.stroke();
    ctx.strokeStyle = 'rgb(0,0,0)';
    ctx.fillStyle = 'rgb(0,0,0)';
}

function format_gcBytes(bytes) {
    if (bytes < 4000)
        return `${bytes} bytes`;
    else if (bytes < 4e6)
        return `${(bytes / 1024).toFixed(2)} KB`;
    else if (bytes < 4e9)
        return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
    else
        return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
};

MemoryGraph.prototype.draw = function () {
    var ctx = this.ctx;

    this.clear();
    this.drawFrame();

    var worst = 0, worstpos = 0;
    for (var i = 0; i < numSamples; i++) {
        if (gcBytes[i] >= worst) {
            worst = gcBytes[i];
            worstpos = i;
        }
        if (gcBytes[i] < this.bestEver) {
            this.bestEver = gcBytes[i];
        }
    }

    if (this.worstEver < worst) {
        this.worstEver = worst;
        this.limit = Math.max(this.worstEver, performance.mozMemory.zone.gcAllocTrigger);
    }

    this.drawHBar(this.bestEver, `${format_gcBytes(this.bestEver)} min`, '#00cf61');
    this.drawHBar(this.worstEver, `${format_gcBytes(this.worstEver)} max`, '#cc1111');
    this.drawHBar(performance.mozMemory.zone.gcAllocTrigger, `${format_gcBytes(performance.mozMemory.zone.gcAllocTrigger)} trigger`, '#cc11cc');

    ctx.fillStyle = 'rgb(255,0,0)';
    if (worst)
        ctx.fillText(format_gcBytes(worst), this.xpos(worstpos) - 10, this.ypos(worst) - 14);

    ctx.beginPath();
    var where = sampleIndex % numSamples;
    ctx.arc(this.xpos(where), this.ypos(gcBytes[where]), 5, 0, Math.PI*2, true);
    ctx.fill();

    ctx.beginPath();
    for (var i = 0; i < numSamples; i++) {
        if (i == (sampleIndex + 1) % numSamples)
            ctx.moveTo(this.xpos(i), this.ypos(gcBytes[i]));
        else
            ctx.lineTo(this.xpos(i), this.ypos(gcBytes[i]));
        if (i == where)
            ctx.stroke();
    }
    ctx.stroke();

    this.drawAxisLabels('Time', 'Heap Memory Usage');
}

function stopstart()
{
    if (stopped) {
        window.requestAnimationFrame(handler);
        prev = performance.now();
        start += prev - stopped;
        document.getElementById('stop').value = 'Pause';
        stopped = 0;
    } else {
        document.getElementById('stop').value = 'Resume';
        stopped = performance.now();
    }
}

var previous = 0;
function handler(timestamp)
{
    if (stopped)
        return;

    if (testState === 'running' && (timestamp - testStart) > testDuration)
        end_test(timestamp);

    if (testState == 'running')
        document.getElementById("test-progress").textContent = ((testDuration - (timestamp - testStart))/1000).toFixed(1) + " sec";

    activeTest.makeGarbage(activeTest.garbagePerFrame);

    var elt = document.getElementById('data');
    var delay = timestamp - prev;
    prev = timestamp;

    // Take the histogram at 10us intervals so that we have enough resolution to capture.
    // a 16.66[666] target with adequate accuracy.
    update_histogram(gHistogram, Math.round(delay * 100));

    var t = timestamp - start;
    var newIndex = Math.round(t / sampleTime);
    while (sampleIndex < newIndex) {
        sampleIndex++;
        var idx = sampleIndex % numSamples;
        delays[idx] = delay;
        if (features.trackingSizes)
            gcBytes[idx] = performance.mozMemory.gcBytes;
        if (features.showingGCs) {
            gcs[idx] = performance.mozMemory.gcNumber;
            minorGCs[idx] = performance.mozMemory.minorGCCount;
        }
    }

    latencyGraph.draw();
    if (memoryGraph)
        memoryGraph.draw();
    window.requestAnimationFrame(handler);
}

function summarize(arr) {
    if (arr.length == 0)
        return [];

    var result = [];
    var run_start = 0;
    var prev = arr[0];
    for (var i = 1; i <= arr.length; i++) {
        if (i == arr.length || arr[i] != prev) {
            if (i == run_start + 1) {
                result.push(arr[i]);
            } else {
                result.push(prev + " x " + (i - run_start));
            }
            run_start = i;
        }
        if (i != arr.length)
            prev = arr[i];
    }

    return result;
}

function update_histogram(histogram, delay)
{
    var current = histogram.has(delay) ? histogram.get(delay) : 0;
    histogram.set(delay, ++current);
}

function reset_draw_state()
{
    for (var i = 0; i < numSamples; i++)
        delays[i] = 0;
    start = prev = performance.now();
    sampleIndex = 0;
}

function onunload()
{
    if (activeTest)
        activeTest.unload();
    activeTest = undefined;
}

function onload()
{
    // Load initial test duration.
    duration_changed();

    // Load initial garbage size.
    garbage_total_changed();
    garbage_per_frame_changed();

    // Populate the test selection dropdown.
    var select = document.getElementById("test-selection");
    for (var [name, test] of tests) {
        test.name = name;
        var option = document.createElement("option");
        option.id = name;
        option.text = name;
        option.title = test.description;
        select.add(option);
    }

    // Load the initial test.
    change_active_test('noAllocation');

    // Polyfill rAF.
    var requestAnimationFrame =
        window.requestAnimationFrame || window.mozRequestAnimationFrame ||
        window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
    window.requestAnimationFrame = requestAnimationFrame;

    // Acquire our canvas.
    var canvas = document.getElementById('graph');
    latencyGraph = new LatencyGraph(canvas.getContext('2d'));

    if (!performance.mozMemory) {
        document.getElementById('memgraph-disabled').style.display = 'block';
        document.getElementById('track-sizes-div').style.display = 'none';
    }

    trackHeapSizes(document.getElementById('track-sizes').checked);

    // Start drawing.
    reset_draw_state();
    window.requestAnimationFrame(handler);
}

function run_one_test()
{
    start_test_cycle([activeTest.name]);
}

function run_all_tests()
{
    start_test_cycle(tests.keys());
}

function start_test_cycle(tests_to_run)
{
    // Convert from an iterable to an array for pop.
    testQueue = [];
    for (var key of tests_to_run)
        testQueue.push(key);
    testState = 'running';
    testStart = performance.now();
    gHistogram.clear();

    start_test(testQueue.shift());
    reset_draw_state();
}

function start_test(testName)
{
    change_active_test(testName);
    console.log(`Running test: ${testName}`);
    document.getElementById("test-selection").value = testName;
}

function end_test(timestamp)
{
    document.getElementById("test-progress").textContent = "(not running)";
    report_test_result(activeTest, gHistogram);
    gHistogram.clear();
    console.log(`Ending test ${activeTest.name}`);
    if (testQueue.length) {
        start_test(testQueue.shift());
        testStart = timestamp;
    } else {
        testState = 'idle';
        testStart = 0;
    }
    reset_draw_state();
}

function report_test_result(test, histogram)
{
    var resultList = document.getElementById('results-display');
    var resultElem = document.createElement("div");
    var score = compute_test_score(histogram);
    var sparks = compute_test_spark_histogram(histogram);
    var params = `(${format_units(test.garbagePerFrame)},${format_units(test.garbageTotal)})`;
    resultElem.innerHTML = `${score.toFixed(3)} ms/s : ${sparks} : ${test.name}${params} - ${test.description}`;
    resultList.appendChild(resultElem);
}

// Compute a score based on the total ms we missed frames by per second.
function compute_test_score(histogram)
{
    var score = 0;
    for (var [delay, count] of histogram) {
        delay = delay / 100;
        score += Math.abs((delay - 16.66) * count);
    }
    score = score / (testDuration / 1000);
    return Math.round(score * 1000) / 1000;
}

// Build a spark-lines histogram for the test results to show with the aggregate score.
function compute_test_spark_histogram(histogram)
{
    var ranges = [
        [-99999999, 16.6],
        [16.6, 16.8],
        [16.8, 25],
        [25, 33.4],
        [33.4, 60],
        [60, 100],
        [100, 300],
        [300, 99999999],
    ];
    var rescaled = new Map();
    for (var [delay, count] of histogram) {
        delay = delay / 100;
        for (var i = 0; i < ranges.length; ++i) {
            var low = ranges[i][0];
            var high = ranges[i][1];
            if (low <= delay && delay < high) {
                update_histogram(rescaled, i);
                break;
            }
        }
    }
    var total = 0;
    for (var [i, count] of rescaled)
        total += count;
    var sparks = "▁▂▃▄▅▆▇█";
    var colors = ['#aaaa00', '#007700', '#dd0000', '#ff0000',
                  '#ff0000', '#ff0000', '#ff0000', '#ff0000'];
    var line = "";
    for (var i = 0; i < ranges.length; ++i) {
        var amt = rescaled.has(i) ? rescaled.get(i) : 0;
        var spark = sparks.charAt(parseInt(amt/total*8));
        line += `<span style="color:${colors[i]}">${spark}</span>`;
    }
    return line;
}

function reload_active_test()
{
    activeTest.unload();
    activeTest.load(activeTest.garbageTotal);
}

function change_active_test(new_test_name)
{
    if (activeTest)
        activeTest.unload();
    activeTest = tests.get(new_test_name);

    if (!activeTest.garbagePerFrame)
        activeTest.garbagePerFrame = parse_units(activeTest.defaultGarbagePerFrame || globalDefaultGarbagePerFrame);
    if (!activeTest.garbageTotal)
        activeTest.garbageTotal = parse_units(activeTest.defaultGarbageTotal || globalDefaultGarbageTotal);

    document.getElementById("garbage-per-frame").value = format_units(activeTest.garbagePerFrame);
    document.getElementById("garbage-total").value = format_units(activeTest.garbageTotal);

    activeTest.load(activeTest.garbageTotal);
}

function duration_changed()
{
    var durationInput = document.getElementById('test-duration');
    testDuration = parseInt(durationInput.value) * 1000;
    console.log(`Updated test duration to: ${testDuration / 1000} seconds`);
}

function test_changed()
{
    var select = document.getElementById("test-selection");
    console.log(`Switching to test: ${select.value}`);
    change_active_test(select.value);
    gHistogram.clear();
    reset_draw_state();
}

function parse_units(v)
{
    if (v.length == 0)
        return NaN;
    var lastChar = v[v.length - 1].toLowerCase();
    if (!isNaN(parseFloat(lastChar)))
        return parseFloat(v);
    var units = parseFloat(v.substr(0, v.length - 1));
    if (lastChar == "k")
        return units * 1e3;
    if (lastChar == "m")
        return units * 1e6;
    if (lastChar == "g")
        return units * 1e9;
    return NaN;
}

function format_units(n)
{
    n = String(n);
    if (n.length > 9 && n.substr(-9) == "000000000")
        return n.substr(0, n.length - 9) + "G";
    else if (n.length > 9 && n.substr(-6) == "000000")
        return n.substr(0, n.length - 6) + "M";
    else if (n.length > 3 && n.substr(-3) == "000")
        return n.substr(0, n.length - 3) + "K";
    else
        return String(n);
}

function garbage_total_changed()
{
    var value = parse_units(document.getElementById('garbage-total').value);
    if (isNaN(value))
        return;
    if (activeTest) {
        activeTest.garbageTotal = value;
        console.log(`Updated garbage-total to ${activeTest.garbageTotal} items`);
        reload_active_test();
    }
    gHistogram.clear();
    reset_draw_state();
}

function garbage_per_frame_changed()
{
    var value = parse_units(document.getElementById('garbage-per-frame').value);
    if (isNaN(value))
        return;
    if (activeTest) {
        activeTest.garbagePerFrame = value;
        console.log(`Updated garbage-per-frame to ${activeTest.garbagePerFrame} items`);
    }
}

function trackHeapSizes(track)
{
    features.trackingSizes = track;

    var canvas = document.getElementById('memgraph');

    if (features.trackingSizes) {
        canvas.style.display = 'block';
        memoryGraph = new MemoryGraph(canvas.getContext('2d'));
    } else {
        canvas.style.display = 'none';
        memoryGraph = null;
    }
}