/**
 * @license
 * Copyright 2013 Dan Vanderkam (danvdk@gmail.com)
 * MIT-licenced: https://opensource.org/licenses/MIT
 *
 * Note: This plugin requires jQuery and jQuery UI Draggable.
 *
 * See high-level documentation at ../../docs/hairlines-annotations.pdf
 */

/*global Dygraph:false */

Dygraph.Plugins.Hairlines = (function() {

"use strict";

/**
 * @typedef {
 *   xval:  number,      // x-value (i.e. millis or a raw number)
 *   interpolated: bool,  // alternative is to snap to closest
 *   lineDiv: !Element    // vertical hairline div
 *   infoDiv: !Element    // div containing info about the nearest points
 *   selected: boolean    // whether this hairline is selected
 * } Hairline
 */

// We have to wait a few ms after clicks to give the user a chance to
// double-click to unzoom. This sets that delay period.
var CLICK_DELAY_MS = 300;

var hairlines = function(opt_options) {
  /* @type {!Array.<!Hairline>} */
  this.hairlines_ = [];

  // Used to detect resizes (which require the divs to be repositioned).
  this.lastWidth_ = -1;
  this.lastHeight = -1;
  this.dygraph_ = null;

  this.addTimer_ = null;
  opt_options = opt_options || {};

  this.divFiller_ = opt_options['divFiller'] || null;
};

hairlines.prototype.toString = function() {
  return "Hairlines Plugin";
};

hairlines.prototype.activate = function(g) {
  this.dygraph_ = g;
  this.hairlines_ = [];

  return {
    didDrawChart: this.didDrawChart,
    click: this.click,
    dblclick: this.dblclick,
    dataDidUpdate: this.dataDidUpdate
  };
};

hairlines.prototype.detachLabels = function() {
  for (var i = 0; i < this.hairlines_.length; i++) {
    var h = this.hairlines_[i];
    $(h.lineDiv).remove();
    $(h.infoDiv).remove();
    this.hairlines_[i] = null;
  }
  this.hairlines_ = [];
};

hairlines.prototype.hairlineWasDragged = function(h, event, ui) {
  var area = this.dygraph_.getArea();
  var oldXVal = h.xval;
  h.xval = this.dygraph_.toDataXCoord(ui.position.left);
  this.moveHairlineToTop(h);
  this.updateHairlineDivPositions();
  this.updateHairlineInfo();
  this.updateHairlineStyles();
  $(this).triggerHandler('hairlineMoved', {
    oldXVal: oldXVal,
    newXVal: h.xval
  });
  $(this).triggerHandler('hairlinesChanged', {});
};

// This creates the hairline object and returns it.
// It does not position it and does not attach it to the chart.
hairlines.prototype.createHairline = function(props) {
  var h;
  var self = this;

  var $lineContainerDiv = $('<div/>').css({
      'width': '6px',
      'margin-left': '-3px',
      'position': 'absolute',
      'z-index': '10'
    })
    .addClass('dygraph-hairline');

  var $lineDiv = $('<div/>').css({
    'width': '1px',
    'position': 'relative',
    'left': '3px',
    'background': 'black',
    'height': '100%'
  });
  $lineDiv.appendTo($lineContainerDiv);

  var $infoDiv = $('#hairline-template').clone().removeAttr('id').css({
      'position': 'absolute'
    })
    .show();

  // Surely there's a more jQuery-ish way to do this!
  $([$infoDiv.get(0), $lineContainerDiv.get(0)])
    .draggable({
      'axis': 'x',
      'drag': function(event, ui) {
        self.hairlineWasDragged(h, event, ui);
      }
      // TODO(danvk): set cursor here
    });

  h = $.extend({
    interpolated: true,
    selected: false,
    lineDiv: $lineContainerDiv.get(0),
    infoDiv: $infoDiv.get(0)
  }, props);

  var that = this;
  $infoDiv.on('click', '.hairline-kill-button', function(e) {
    that.removeHairline(h);
    $(that).triggerHandler('hairlineDeleted', {
      xval: h.xval
    });
    $(that).triggerHandler('hairlinesChanged', {});
    e.stopPropagation();  // don't want .click() to trigger, below.
  }).on('click', function() {
    that.moveHairlineToTop(h);
  });

  return h;
};

// Moves a hairline's divs to the top of the z-ordering.
hairlines.prototype.moveHairlineToTop = function(h) {
  var div = this.dygraph_.graphDiv;
  $(h.infoDiv).appendTo(div);
  $(h.lineDiv).appendTo(div);

  var idx = this.hairlines_.indexOf(h);
  this.hairlines_.splice(idx, 1);
  this.hairlines_.push(h);
};

// Positions existing hairline divs.
hairlines.prototype.updateHairlineDivPositions = function() {
  var g = this.dygraph_;
  var layout = this.dygraph_.getArea();
  var chartLeft = layout.x, chartRight = layout.x + layout.w;
  var div = this.dygraph_.graphDiv;
  var pos = Dygraph.findPos(div);
  var box = [layout.x + pos.x, layout.y + pos.y];
  box.push(box[0] + layout.w);
  box.push(box[1] + layout.h);

  $.each(this.hairlines_, function(idx, h) {
    var left = g.toDomXCoord(h.xval);
    h.domX = left;  // See comments in this.dataDidUpdate
    $(h.lineDiv).css({
      'left': left + 'px',
      'top': layout.y + 'px',
      'height': layout.h + 'px'
    });  // .draggable("option", "containment", box);
    $(h.infoDiv).css({
      'left': left + 'px',
      'top': layout.y + 'px',
    }).draggable("option", "containment", box);

    var visible = (left >= chartLeft && left <= chartRight);
    $([h.infoDiv, h.lineDiv]).toggle(visible);
  });
};

// Sets styles on the hairline (i.e. "selected")
hairlines.prototype.updateHairlineStyles = function() {
  $.each(this.hairlines_, function(idx, h) {
    $([h.infoDiv, h.lineDiv]).toggleClass('selected', h.selected);
  });
};

// Find prevRow and nextRow such that
// g.getValue(prevRow, 0) <= xval
// g.getValue(nextRow, 0) >= xval
// g.getValue({prev,next}Row, col) != null, NaN or undefined
// and there's no other row such that:
//   g.getValue(prevRow, 0) < g.getValue(row, 0) < g.getValue(nextRow, 0)
//   g.getValue(row, col) != null, NaN or undefined.
// Returns [prevRow, nextRow]. Either can be null (but not both).
hairlines.findPrevNextRows = function(g, xval, col) {
  var prevRow = null, nextRow = null;
  var numRows = g.numRows();
  for (var row = 0; row < numRows; row++) {
    var yval = g.getValue(row, col);
    if (yval === null || yval === undefined || isNaN(yval)) continue;

    var rowXval = g.getValue(row, 0);
    if (rowXval <= xval) prevRow = row;

    if (rowXval >= xval) {
      nextRow = row;
      break;
    }
  }

  return [prevRow, nextRow];
};

// Fills out the info div based on current coordinates.
hairlines.prototype.updateHairlineInfo = function() {
  var mode = 'closest';

  var g = this.dygraph_;
  var xRange = g.xAxisRange();
  var that = this;
  $.each(this.hairlines_, function(idx, h) {
    // To use generateLegendHTML, we synthesize an array of selected points.
    var selPoints = [];
    var labels = g.getLabels();
    var row, prevRow, nextRow;

    if (!h.interpolated) {
      // "closest point" mode.
      // TODO(danvk): make findClosestRow method public
      row = g.findClosestRow(g.toDomXCoord(h.xval));
      for (var i = 1; i < g.numColumns(); i++) {
        selPoints.push({
          canvasx: 1,  // TODO(danvk): real coordinate
          canvasy: 1,  // TODO(danvk): real coordinate
          xval: h.xval,
          yval: g.getValue(row, i),
          name: labels[i]
        });
      }
    } else {
      // "interpolated" mode.
      for (var i = 1; i < g.numColumns(); i++) {
        var prevNextRow = hairlines.findPrevNextRows(g, h.xval, i);
        prevRow = prevNextRow[0], nextRow = prevNextRow[1];

        // For x-values outside the domain, interpolate "between" the extreme
        // point and itself.
        if (prevRow === null) prevRow = nextRow;
        if (nextRow === null) nextRow = prevRow;

        // linear interpolation
        var prevX = g.getValue(prevRow, 0),
            nextX = g.getValue(nextRow, 0),
            prevY = g.getValue(prevRow, i),
            nextY = g.getValue(nextRow, i),
            frac = prevRow == nextRow ? 0 : (h.xval - prevX) / (nextX - prevX),
            yval = frac * nextY + (1 - frac) * prevY;

        selPoints.push({
          canvasx: 1,  // TODO(danvk): real coordinate
          canvasy: 1,  // TODO(danvk): real coordinate
          xval: h.xval,
          yval: yval,
          prevRow: prevRow,
          nextRow: nextRow,
          name: labels[i]
        });
      }
    }

    if (that.divFiller_) {
      that.divFiller_(h.infoDiv, {
        closestRow: row,
        points: selPoints,
        hairline: that.createPublicHairline_(h),
        dygraph: g
      });
    } else {
      var html = Dygraph.Plugins.Legend.generateLegendHTML(g, h.xval, selPoints, 10);
      $('.hairline-legend', h.infoDiv).html(html);
    }
  });
};

// After a resize, the hairline divs can get dettached from the chart.
// This reattaches them.
hairlines.prototype.attachHairlinesToChart_ = function() {
  var div = this.dygraph_.graphDiv;
  $.each(this.hairlines_, function(idx, h) {
    $([h.lineDiv, h.infoDiv]).appendTo(div);
  });
};

// Deletes a hairline and removes it from the chart.
hairlines.prototype.removeHairline = function(h) {
  var idx = this.hairlines_.indexOf(h);
  if (idx >= 0) {
    this.hairlines_.splice(idx, 1);
    $([h.lineDiv, h.infoDiv]).remove();
  } else {
    Dygraph.warn('Tried to remove non-existent hairline.');
  }
};

hairlines.prototype.didDrawChart = function(e) {
  var g = e.dygraph;

  // Early out in the (common) case of zero hairlines.
  if (this.hairlines_.length === 0) return;

  this.updateHairlineDivPositions();
  this.attachHairlinesToChart_();
  this.updateHairlineInfo();
  this.updateHairlineStyles();
};

hairlines.prototype.dataDidUpdate = function(e) {
  // When the data in the chart updates, the hairlines should stay in the same
  // position on the screen. didDrawChart stores a domX parameter for each
  // hairline. We use that to reposition them on data updates.
  var g = this.dygraph_;
  $.each(this.hairlines_, function(idx, h) {
    if (h.hasOwnProperty('domX')) {
      h.xval = g.toDataXCoord(h.domX);
    }
  });
};

hairlines.prototype.click = function(e) {
  if (this.addTimer_) {
    // Another click is in progress; ignore this one.
    return;
  }

  var area = e.dygraph.getArea();
  var xval = this.dygraph_.toDataXCoord(e.canvasx);

  var that = this;
  this.addTimer_ = setTimeout(function() {
    that.addTimer_ = null;
    that.hairlines_.push(that.createHairline({xval: xval}));

    that.updateHairlineDivPositions();
    that.updateHairlineInfo();
    that.updateHairlineStyles();
    that.attachHairlinesToChart_();

    $(that).triggerHandler('hairlineCreated', {
      xval: xval
    });
    $(that).triggerHandler('hairlinesChanged', {});
  }, CLICK_DELAY_MS);
};

hairlines.prototype.dblclick = function(e) {
  if (this.addTimer_) {
    clearTimeout(this.addTimer_);
    this.addTimer_ = null;
  }
};

hairlines.prototype.destroy = function() {
  this.detachLabels();
};

// Public API

/**
 * This is a restricted view of this.hairlines_ which doesn't expose
 * implementation details like the handle divs.
 *
 * @typedef {
 *   xval:  number,       // x-value (i.e. millis or a raw number)
 *   interpolated: bool,  // alternative is to snap to closest
 *   selected: bool       // whether the hairline is selected.
 * } PublicHairline
 */

/**
 * @param {!Hairline} h Internal hairline.
 * @return {!PublicHairline} Restricted public view of the hairline.
 */
hairlines.prototype.createPublicHairline_ = function(h) {
  return {
    xval: h.xval,
    interpolated: h.interpolated,
    selected: h.selected
  };
};

/**
 * @return {!Array.<!PublicHairline>} The current set of hairlines, ordered
 *     from back to front.
 */
hairlines.prototype.get = function() {
  var result = [];
  for (var i = 0; i < this.hairlines_.length; i++) {
    var h = this.hairlines_[i];
    result.push(this.createPublicHairline_(h));
  }
  return result;
};

/**
 * Calling this will result in a hairlinesChanged event being triggered, no
 * matter whether it consists of additions, deletions, moves or no changes at
 * all.
 *
 * @param {!Array.<!PublicHairline>} hairlines The new set of hairlines,
 *     ordered from back to front.
 */
hairlines.prototype.set = function(hairlines) {
  // Re-use divs from the old hairlines array so far as we can.
  // They're already correctly z-ordered.
  var anyCreated = false;
  for (var i = 0; i < hairlines.length; i++) {
    var h = hairlines[i];

    if (this.hairlines_.length > i) {
      this.hairlines_[i].xval = h.xval;
      this.hairlines_[i].interpolated = h.interpolated;
      this.hairlines_[i].selected = h.selected;
    } else {
      this.hairlines_.push(this.createHairline({
        xval: h.xval,
        interpolated: h.interpolated,
        selected: h.selected
      }));
      anyCreated = true;
    }
  }

  // If there are any remaining hairlines, destroy them.
  while (hairlines.length < this.hairlines_.length) {
    this.removeHairline(this.hairlines_[hairlines.length]);
  }

  this.updateHairlineDivPositions();
  this.updateHairlineInfo();
  this.updateHairlineStyles();
  if (anyCreated) {
    this.attachHairlinesToChart_();
  }

  $(this).triggerHandler('hairlinesChanged', {});
};

return hairlines;

})();
