// JSLitmus.js
//
// Copyright (c) 2010, Robert Kieffer, http://broofa.com
// Available under MIT license (http://en.wikipedia.org/wiki/MIT_License)

(function() {
  // Private methods and state

  // Get platform info but don't go crazy trying to recognize everything
  // that's out there.  This is just for the major platforms and OSes.
  var platform = 'unknown platform', ua = navigator.userAgent;

  // Detect OS
  var oses = ['Windows','iPhone OS','(Intel |PPC )?Mac OS X','Linux'].join('|');
  var pOS = new RegExp('((' + oses + ') [^ \);]*)').test(ua) ? RegExp.$1 : null;
  if (!pOS) pOS = new RegExp('((' + oses + ')[^ \);]*)').test(ua) ? RegExp.$1 : null;

  // Detect browser
  var pName = /(Chrome|MSIE|Safari|Opera|Firefox)/.test(ua) ? RegExp.$1 : null;

  // Detect version
  var vre = new RegExp('(Version|' + pName + ')[ \/]([^ ;]*)');
  var pVersion = (pName && vre.test(ua)) ? RegExp.$2 : null;
  var platform = (pOS && pName && pVersion) ? pName + ' '  + pVersion + ' on ' + pOS : 'unknown platform';

  /**
  * A smattering of methods that are needed to implement the JSLitmus testbed.
  */
  var jsl = {
    /**
    * Enhanced version of escape()
    */
    escape: function(s) {
      s = s.replace(/,/g, '\\,');
      s = escape(s);
      s = s.replace(/\+/g, '%2b');
      s = s.replace(/ /g, '+');
      return s;
    },

    /**
    * Get an element by ID.
    */
    $: function(id) {
      return document.getElementById(id);
    },

    /**
    * Null function
    */
    F: function() {},

    /**
    * Set the status shown in the UI
    */
    status: function(msg) {
      var el = jsl.$('jsl_status');
      if (el) el.innerHTML = msg || '';
    },

    /**
    * Convert a number to an abbreviated string like, "15K" or "10M"
    */
    toLabel: function(n) {
      if (n == Infinity) {
        return 'Infinity';
      } else if (n > 1e9) {
        n = Math.round(n/1e8);
        return n/10 + 'B';
      } else if (n > 1e6) {
        n = Math.round(n/1e5);
        return n/10 + 'M';
      } else if (n > 1e3) {
        n = Math.round(n/1e2);
        return n/10 + 'K';
      }
      return n;
    },

    /**
    * Copy properties from src to dst
    */
    extend: function(dst, src) {
      for (var k in src) dst[k] = src[k]; return dst;
    },

    /**
    * Like Array.join(), but for the key-value pairs in an object
    */
    join: function(o, delimit1, delimit2) {
      if (o.join) return o.join(delimit1);  // If it's an array
      var pairs = [];
      for (var k in o) pairs.push(k + delimit1 + o[k]);
      return pairs.join(delimit2);
    },

    /**
    * Array#indexOf isn't supported in IE, so we use this as a cross-browser solution
    */
    indexOf: function(arr, o) {
      if (arr.indexOf) return arr.indexOf(o);
      for (var i = 0; i < this.length; i++) if (arr[i] === o) return i;
      return -1;
    }
  };

  /**
  * Test manages a single test (created with
  * JSLitmus.test())
  *
  * @private
  */
  var Test = function (name, f) {
    if (!f) throw new Error('Undefined test function');
    if (!/function[^\(]*\(([^,\)]*)/.test(f.toString())) {
      throw new Error('"' + name + '" test: Test is not a valid Function object');
    }
    this.loopArg = RegExp.$1;
    this.name = name;
    this.f = f;
  };

  jsl.extend(Test, /** @lends Test */ {
    /** Calibration tests for establishing iteration loop overhead */
    CALIBRATIONS: [
      new Test('calibrating loop', function(count) {while (count--);}),
      new Test('calibrating function', jsl.F)
    ],

    /**
    * Run calibration tests.  Returns true if calibrations are not yet
    * complete (in which case calling code should run the tests yet again).
    * onCalibrated - Callback to invoke when calibrations have finished
    */
    calibrate: function(onCalibrated) {
      for (var i = 0; i < Test.CALIBRATIONS.length; i++) {
        var cal = Test.CALIBRATIONS[i];
        if (cal.running) return true;
        if (!cal.count) {
          cal.isCalibration = true;
          cal.onStop = onCalibrated;
          //cal.MIN_TIME = .1; // Do calibrations quickly
          cal.run(2e4);
          return true;
        }
      }
      return false;
    }
  });

  jsl.extend(Test.prototype, {/** @lends Test.prototype */
    /** Initial number of iterations */
    INIT_COUNT: 10,
    /** Max iterations allowed (i.e. used to detect bad looping functions) */
    MAX_COUNT: 1e9,
    /** Minimum time a test should take to get valid results (secs) */
    MIN_TIME: .5,

    /** Callback invoked when test state changes */
    onChange: jsl.F,

    /** Callback invoked when test is finished */
    onStop: jsl.F,

    /**
     * Reset test state
     */
    reset: function() {
      delete this.count;
      delete this.time;
      delete this.running;
      delete this.error;
    },

    /**
    * Run the test (in a timeout). We use a timeout to make sure the browser
    * has a chance to finish rendering any UI changes we've made, like
    * updating the status message.
    */
    run: function(count) {
      count = count || this.INIT_COUNT;
      jsl.status(this.name + ' x ' + count);
      this.running = true;
      var me = this;
      setTimeout(function() {me._run(count);}, 200);
    },

    /**
     * The nuts and bolts code that actually runs a test
     */
    _run: function(count) {
      var me = this;

      // Make sure calibration tests have run
      if (!me.isCalibration && Test.calibrate(function() {me.run(count);})) return;
      this.error = null;

      try {
        var start, f = this.f, now, i = count;

        // Start the timer
        start = new Date();

        // Now for the money shot.  If this is a looping function ...
        if (this.loopArg) {
          // ... let it do the iteration itself
          f(count);
        } else {
          // ... otherwise do the iteration for it
          while (i--) f();
        }

        // Get time test took (in secs)
        this.time = Math.max(1,new Date() - start)/1000;

        // Store iteration count and per-operation time taken
        this.count = count;
        this.period = this.time/count;

        // Do we need to do another run?
        this.running = this.time <= this.MIN_TIME;

        // ... if so, compute how many times we should iterate
        if (this.running) {
          // Bump the count to the nearest power of 2
          var x = this.MIN_TIME/this.time;
          var pow = Math.pow(2, Math.max(1, Math.ceil(Math.log(x)/Math.log(2))));
          count *= pow;
          if (count > this.MAX_COUNT) {
            throw new Error('Max count exceeded.  If this test uses a looping function, make sure the iteration loop is working properly.');
          }
        }
      } catch (e) {
        // Exceptions are caught and displayed in the test UI
        this.reset();
        this.error = e;
      }

      // Figure out what to do next
      if (this.running) {
        me.run(count);
      } else {
        jsl.status('');
        me.onStop(me);
      }

      // Finish up
      this.onChange(this);
    },

    /**
    * Get the number of operations per second for this test.
    *
    * @param normalize if true, iteration loop overhead taken into account
    */
    getHz: function(/**Boolean*/ normalize) {
      var p = this.period;

      // Adjust period based on the calibration test time
      if (normalize && !this.isCalibration) {
        var cal = Test.CALIBRATIONS[this.loopArg ? 0 : 1];

        // If the period is within 20% of the calibration time, then zero the
        // it out
        p = p < cal.period*1.2 ? 0 : p - cal.period;
      }

      return Math.round(1/p);
    },

    /**
    * Get a friendly string describing the test
    */
    toString: function() {
      return this.name + ' - '  + this.time/this.count + ' secs';
    }
  });

  // CSS we need for the UI
  var STYLESHEET = '<style> \
    #jslitmus {font-family:sans-serif; font-size: 12px;} \
    #jslitmus a {text-decoration: none;} \
    #jslitmus a:hover {text-decoration: underline;} \
    #jsl_status { \
      margin-top: 10px; \
      font-size: 10px; \
      color: #888; \
    } \
    A IMG  {border:none} \
    #test_results { \
      margin-top: 10px; \
      font-size: 12px; \
      font-family: sans-serif; \
      border-collapse: collapse; \
      border-spacing: 0px; \
    } \
    #test_results th, #test_results td { \
      border: solid 1px #ccc; \
      vertical-align: top; \
      padding: 3px; \
    } \
    #test_results th { \
      vertical-align: bottom; \
      background-color: #ccc; \
      padding: 1px; \
      font-size: 10px; \
    } \
    #test_results #test_platform { \
      color: #444; \
      text-align:center; \
    } \
    #test_results .test_row { \
      color: #006; \
      cursor: pointer; \
    } \
    #test_results .test_nonlooping { \
      border-left-style: dotted; \
      border-left-width: 2px; \
    } \
    #test_results .test_looping { \
      border-left-style: solid; \
      border-left-width: 2px; \
    } \
    #test_results .test_name {white-space: nowrap;} \
    #test_results .test_pending { \
    } \
    #test_results .test_running { \
      font-style: italic; \
    } \
    #test_results .test_done {} \
    #test_results .test_done { \
      text-align: right; \
      font-family: monospace; \
    } \
    #test_results .test_error {color: #600;} \
    #test_results .test_error .error_head {font-weight:bold;} \
    #test_results .test_error .error_body {font-size:85%;} \
    #test_results .test_row:hover td { \
      background-color: #ffc; \
      text-decoration: underline; \
    } \
    #chart { \
      margin: 10px 0px; \
      width: 250px; \
    } \
    #chart img { \
      border: solid 1px #ccc; \
      margin-bottom: 5px; \
    } \
    #chart #tiny_url { \
      height: 40px; \
      width: 250px; \
    } \
    #jslitmus_credit { \
      font-size: 10px; \
      color: #888; \
      margin-top: 8px; \
    } \
    </style>';

  // HTML markup for the UI
  var MARKUP = '<div id="jslitmus"> \
      <button onclick="JSLitmus.runAll(event)">Run Tests</button> \
      <button id="stop_button" disabled="disabled" onclick="JSLitmus.stop()">Stop Tests</button> \
      <br \> \
      <br \> \
      <input type="checkbox" style="vertical-align: middle" id="test_normalize" checked="checked" onchange="JSLitmus.renderAll()""> Normalize results \
      <table id="test_results"> \
        <colgroup> \
          <col /> \
          <col width="100" /> \
        </colgroup> \
        <tr><th id="test_platform" colspan="2">' + platform + '</th></tr> \
        <tr><th>Test</th><th>Ops/sec</th></tr> \
        <tr id="test_row_template" class="test_row" style="display:none"> \
          <td class="test_name"></td> \
          <td class="test_result">Ready</td> \
        </tr> \
      </table> \
      <div id="jsl_status"></div> \
      <div id="chart" style="display:none"> \
      <a id="chart_link" target="_blank"><img id="chart_image"></a> \
      TinyURL (for chart): \
      <iframe id="tiny_url" frameBorder="0" scrolling="no" src=""></iframe> \
      </div> \
      <a id="jslitmus_credit" title="JSLitmus home page" href="http://code.google.com/p/jslitmus" target="_blank">Powered by JSLitmus</a> \
    </div>';

  /**
   * The public API for creating and running tests
   */
  window.JSLitmus = {
    /** The list of all tests that have been registered with JSLitmus.test */
    _tests: [],
    /** The queue of tests that need to be run */
    _queue: [],

    /**
    * The parsed query parameters the current page URL.  This is provided as a
    * convenience for test functions - it's not used by JSLitmus proper
    */
    params: {},

    /**
     * Initialize
     */
    _init: function() {
      // Parse query params into JSLitmus.params[] hash
      var match = (location + '').match(/([^?#]*)(#.*)?$/);
      if (match) {
        var pairs = match[1].split('&');
        for (var i = 0; i < pairs.length; i++) {
          var pair = pairs[i].split('=');
          if (pair.length > 1) {
            var key = pair.shift();
            var value = pair.length > 1 ? pair.join('=') : pair[0];
            this.params[key] = value;
          }
        }
      }

      // Write out the stylesheet.  We have to do this here because IE
      // doesn't honor sheets written after the document has loaded.
      document.write(STYLESHEET);

      // Setup the rest of the UI once the document is loaded
      if (window.addEventListener) {
        window.addEventListener('load', this._setup, false);
      } else if (document.addEventListener) {
        document.addEventListener('load', this._setup, false);
      } else if (window.attachEvent) {
        window.attachEvent('onload', this._setup);
      }

      return this;
    },

    /**
     * Set up the UI
     */
    _setup: function() {
      var el = jsl.$('jslitmus_container');
      if (!el) document.body.appendChild(el = document.createElement('div'));

      el.innerHTML = MARKUP;

      // Render the UI for all our tests
      for (var i=0; i < JSLitmus._tests.length; i++)
        JSLitmus.renderTest(JSLitmus._tests[i]);
    },

    /**
     * (Re)render all the test results
     */
    renderAll: function() {
      for (var i = 0; i < JSLitmus._tests.length; i++)
        JSLitmus.renderTest(JSLitmus._tests[i]);
      JSLitmus.renderChart();
    },

    /**
     * (Re)render the chart graphics
     */
    renderChart: function() {
      var url = JSLitmus.chartUrl();
      jsl.$('chart_link').href = url;
      jsl.$('chart_image').src = url;
      jsl.$('chart').style.display = '';

      // Update the tiny URL
      jsl.$('tiny_url').src = 'http://tinyurl.com/api-create.php?url='+escape(url);
    },

    /**
     * (Re)render the results for a specific test
     */
    renderTest: function(test) {
      // Make a new row if needed
      if (!test._row) {
        var trow = jsl.$('test_row_template');
        if (!trow) return;

        test._row = trow.cloneNode(true);
        test._row.style.display = '';
        test._row.id = '';
        test._row.onclick = function() {JSLitmus._queueTest(test);};
        test._row.title = 'Run ' + test.name + ' test';
        trow.parentNode.appendChild(test._row);
        test._row.cells[0].innerHTML = test.name;
      }

      var cell = test._row.cells[1];
      var cns = [test.loopArg ? 'test_looping' : 'test_nonlooping'];

      if (test.error) {
        cns.push('test_error');
        cell.innerHTML =
        '<div class="error_head">' + test.error + '</div>' +
        '<ul class="error_body"><li>' +
          jsl.join(test.error, ': ', '</li><li>') +
          '</li></ul>';
      } else {
        if (test.running) {
          cns.push('test_running');
          cell.innerHTML = 'running';
        } else if (jsl.indexOf(JSLitmus._queue, test) >= 0) {
          cns.push('test_pending');
          cell.innerHTML = 'pending';
        } else if (test.count) {
          cns.push('test_done');
          var hz = test.getHz(jsl.$('test_normalize').checked);
          cell.innerHTML = hz != Infinity ? hz : '&infin;';
          cell.title = 'Looped ' + test.count + ' times in ' + test.time + ' seconds';
        } else {
          cell.innerHTML = 'ready';
        }
      }
      cell.className = cns.join(' ');
    },

    /**
     * Create a new test
     */
    test: function(name, f) {
      // Create the Test object
      var test = new Test(name, f);
      JSLitmus._tests.push(test);

      // Re-render if the test state changes
      test.onChange = JSLitmus.renderTest;

      // Run the next test if this one finished
      test.onStop = function(test) {
        if (JSLitmus.onTestFinish) JSLitmus.onTestFinish(test);
        JSLitmus.currentTest = null;
        JSLitmus._nextTest();
      };

      // Render the new test
      this.renderTest(test);
    },

    /**
     * Add all tests to the run queue
     */
    runAll: function(e) {
      e = e || window.event;
      var reverse = e && e.shiftKey, len = JSLitmus._tests.length;
      for (var i = 0; i < len; i++) {
        JSLitmus._queueTest(JSLitmus._tests[!reverse ? i : (len - i - 1)]);
      }
    },

    /**
     * Remove all tests from the run queue.  The current test has to finish on
     * it's own though
     */
    stop: function() {
      while (JSLitmus._queue.length) {
        var test = JSLitmus._queue.shift();
        JSLitmus.renderTest(test);
      }
    },

    /**
     * Run the next test in the run queue
     */
    _nextTest: function() {
      if (!JSLitmus.currentTest) {
        var test = JSLitmus._queue.shift();
        if (test) {
          jsl.$('stop_button').disabled = false;
          JSLitmus.currentTest = test;
          test.run();
          JSLitmus.renderTest(test);
          if (JSLitmus.onTestStart) JSLitmus.onTestStart(test);
        } else {
          jsl.$('stop_button').disabled = true;
          JSLitmus.renderChart();
        }
      }
    },

    /**
     * Add a test to the run queue
     */
    _queueTest: function(test) {
      if (jsl.indexOf(JSLitmus._queue, test) >= 0) return;
      JSLitmus._queue.push(test);
      JSLitmus.renderTest(test);
      JSLitmus._nextTest();
    },

    /**
     * Generate a Google Chart URL that shows the data for all tests
     */
    chartUrl: function() {
      var n = JSLitmus._tests.length, markers = [], data = [];
      var d, min = 0, max = -1e10;
      var normalize = jsl.$('test_normalize').checked;

      // Gather test data
      for (var i=0; i < JSLitmus._tests.length; i++) {
        var test = JSLitmus._tests[i];
        if (test.count) {
          var hz = test.getHz(normalize);
          var v = hz != Infinity ? hz : 0;
          data.push(v);
          markers.push('t' + jsl.escape(test.name + '(' + jsl.toLabel(hz)+ ')') + ',000000,0,' +
            markers.length + ',10');
          max = Math.max(v, max);
        }
      }
      if (markers.length <= 0) return null;

      // Build chart title
      var title = document.getElementsByTagName('title');
      title = (title && title.length) ? title[0].innerHTML : null;
      var chart_title = [];
      if (title) chart_title.push(title);
      chart_title.push('Ops/sec (' + platform + ')');

      // Build labels
      var labels = [jsl.toLabel(min), jsl.toLabel(max)];

      var w = 250, bw = 15;
      var bs = 5;
      var h = markers.length*(bw + bs) + 30 + chart_title.length*20;

      var params = {
        chtt: escape(chart_title.join('|')),
        chts: '000000,10',
        cht: 'bhg',                     // chart type
        chd: 't:' + data.join(','),     // data set
        chds: min + ',' + max,          // max/min of data
        chxt: 'x',                      // label axes
        chxl: '0:|' + labels.join('|'), // labels
        chsp: '0,1',
        chm: markers.join('|'),         // test names
        chbh: [bw, 0, bs].join(','),    // bar widths
        // chf: 'bg,lg,0,eeeeee,0,eeeeee,.5,ffffff,1', // gradient
        chs: w + 'x' + h
      };
      return 'http://chart.apis.google.com/chart?' + jsl.join(params, '=', '&');
    }
  };

  JSLitmus._init();
})();