/**
 * @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.SuperAnnotations = (function() {

"use strict";

/**
 * These are just the basic requirements -- annotations can have whatever other
 * properties the code that displays them wants them to have.
 *
 * @typedef {
 *   xval:  number,      // x-value (i.e. millis or a raw number)
 *   series: string,     // series name
 *   yFrac: ?number,     // y-positioning. Default is a few px above the point.
 *   lineDiv: !Element   // vertical div connecting point to info div.
 *   infoDiv: !Element   // div containing info about the annotation.
 * } Annotation
 */

var annotations = function(opt_options) {
  /* @type {!Array.<!Annotation>} */
  this.annotations_ = [];
  // Used to detect resizes (which require the divs to be repositioned).
  this.lastWidth_ = -1;
  this.lastHeight = -1;
  this.dygraph_ = null;

  opt_options = opt_options || {};
  this.defaultAnnotationProperties_ = $.extend({
    'text': 'Description'
  }, opt_options['defaultAnnotationProperties']);
};

annotations.prototype.toString = function() {
  return "SuperAnnotations Plugin";
};

annotations.prototype.activate = function(g) {
  this.dygraph_ = g;
  this.annotations_ = [];

  return {
    didDrawChart: this.didDrawChart,
    pointClick: this.pointClick  // TODO(danvk): implement in dygraphs
  };
};

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

annotations.prototype.annotationWasDragged = function(a, event, ui) {
  var g = this.dygraph_;
  var area = g.getArea();
  var oldYFrac = a.yFrac;

  var infoDiv = a.infoDiv;
  var newYFrac = ((infoDiv.offsetTop + infoDiv.offsetHeight) - area.y) / area.h;
  if (newYFrac == oldYFrac) return;

  a.yFrac = newYFrac;

  this.moveAnnotationToTop(a);
  this.updateAnnotationDivPositions();
  this.updateAnnotationInfo();
  $(this).triggerHandler('annotationMoved', {
    annotation: a,
    oldYFrac: oldYFrac,
    newYFrac: a.yFrac
  });
  $(this).triggerHandler('annotationsChanged', {});
};

annotations.prototype.makeAnnotationEditable = function(a) {
  if (a.editable == true) return;
  this.moveAnnotationToTop(a);

  // Note: we have to fill out the HTML ourselves because
  // updateAnnotationInfo() won't touch editable annotations.
  a.editable = true;
  var editableTemplateDiv = $('#annotation-editable-template').get(0);
  a.infoDiv.innerHTML = this.getTemplateHTML(editableTemplateDiv, a);
  $(a.infoDiv).toggleClass('editable', !!a.editable);
  $(this).triggerHandler('beganEditAnnotation', a);
};

// This creates the hairline object and returns it.
// It does not position it and does not attach it to the chart.
annotations.prototype.createAnnotation = function(a) {
  var self = this;

  var color = this.getColorForSeries_(a.series);

  var $lineDiv = $('<div/>').css({
    'width': '1px',
    'left': '3px',
    'background': 'black',
    'height': '100%',
    'position': 'absolute',
    // TODO(danvk): use border-color here for consistency?
    'background-color': color,
    'z-index': 10
  }).addClass('dygraph-annotation-line');

  var $infoDiv = $('#annotation-template').clone().removeAttr('id').css({
      'position': 'absolute',
      'border-color': color,
      'z-index': 10
    })
    .show();

  $.extend(a, {
    lineDiv: $lineDiv.get(0),
    infoDiv: $infoDiv.get(0)
  });

  var that = this;

  $infoDiv.draggable({
    'start': function(event, ui) {
      $(this).css({'bottom': ''});
      a.isDragging = true;
    },
    'drag': function(event, ui) {
      self.annotationWasDragged(a, event, ui);
    },
    'stop': function(event, ui) {
      $(this).css({'top': ''});
      a.isDragging = false;
      self.updateAnnotationDivPositions();
    },
    'axis': 'y',
    'containment': 'parent'
  });

  // TODO(danvk): use 'on' instead of delegate/dblclick
  $infoDiv.on('click', '.annotation-kill-button', function() {
    that.removeAnnotation(a);
    $(that).triggerHandler('annotationDeleted', a);
    $(that).triggerHandler('annotationsChanged', {});
  });

  $infoDiv.on('dblclick', function() {
    that.makeAnnotationEditable(a);
  });
  $infoDiv.on('click', '.annotation-update', function() {
    self.extractUpdatedProperties_($infoDiv.get(0), a);
    a.editable = false;
    self.updateAnnotationInfo();
    $(that).triggerHandler('annotationEdited', a);
    $(that).triggerHandler('annotationsChanged', {});
  });
  $infoDiv.on('click', '.annotation-cancel', function() {
    a.editable = false;
    self.updateAnnotationInfo();
    $(that).triggerHandler('cancelEditAnnotation', a);
  });

  return a;
};

// Find the index of a point in a series.
// Returns a 2-element array, [row, col], which can be used with
// dygraph.getValue() to get the value at this point.
// Returns null if there's no match.
annotations.prototype.findPointIndex_ = function(series, xval) {
  var col = this.dygraph_.getLabels().indexOf(series);
  if (col == -1) return null;

  var lowIdx = 0, highIdx = this.dygraph_.numRows() - 1;
  while (lowIdx <= highIdx) {
    var idx = Math.floor((lowIdx + highIdx) / 2);
    var xAtIdx = this.dygraph_.getValue(idx, 0);
    if (xAtIdx == xval) {
      return [idx, col];
    } else if (xAtIdx < xval) {
      lowIdx = idx + 1;
    } else {
      highIdx = idx - 1;
    }
  }
  return null;
};

annotations.prototype.getColorForSeries_ = function(series) {
  var colors = this.dygraph_.getColors();
  var col = this.dygraph_.getLabels().indexOf(series);
  if (col == -1) return null;

  return colors[(col - 1) % colors.length];
};

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

  var idx = this.annotations_.indexOf(a);
  this.annotations_.splice(idx, 1);
  this.annotations_.push(a);
};

// Positions existing hairline divs.
annotations.prototype.updateAnnotationDivPositions = function() {
  var layout = this.dygraph_.getArea();
  var chartLeft = layout.x, chartRight = layout.x + layout.w;
  var chartTop = layout.y, chartBottom = layout.y + layout.h;
  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);

  var g = this.dygraph_;

  var that = this;
  $.each(this.annotations_, function(idx, a) {
    var row_col = that.findPointIndex_(a.series, a.xval);
    if (row_col == null) {
      $([a.lineDiv, a.infoDiv]).hide();
      return;
    } else {
      // TODO(danvk): only do this if they're invisible?
      $([a.lineDiv, a.infoDiv]).show();
    }
    var xy = g.toDomCoords(a.xval, g.getValue(row_col[0], row_col[1]));
    var x = xy[0], pointY = xy[1];

    var lineHeight = 6;  // TODO(danvk): option?

    var y = pointY;
    if (a.yFrac !== undefined) {
      y = layout.y + layout.h * a.yFrac;
    } else {
      y -= lineHeight;
    }

    var lineHeight = y < pointY ? (pointY - y) : (y - pointY - a.infoDiv.offsetHeight);
    $(a.lineDiv).css({
      'left': x + 'px',
      'top': Math.min(y, pointY) + 'px',
      'height': lineHeight + 'px'
    });
    $(a.infoDiv).css({
      'left': x + 'px',
    });
    if (!a.isDragging) {
      // jQuery UI draggable likes to set 'top', whereas superannotations sets
      // 'bottom'. Setting both will make the annotation grow and contract as
      // the user drags it, which looks bad.
      $(a.infoDiv).css({
        'bottom': (div.offsetHeight - y) + 'px'
      })  //.draggable("option", "containment", box);

      var visible = (x >= chartLeft && x <= chartRight) &&
                    (pointY >= chartTop && pointY <= chartBottom);
      $([a.infoDiv, a.lineDiv]).toggle(visible);
    }
  });
};

// Fills out the info div based on current coordinates.
annotations.prototype.updateAnnotationInfo = function() {
  var g = this.dygraph_;

  var that = this;
  var templateDiv = $('#annotation-template').get(0);
  $.each(this.annotations_, function(idx, a) {
    // We should never update an editable div -- doing so may kill unsaved
    // edits to an annotation.
    $(a.infoDiv).toggleClass('editable', !!a.editable);
    if (a.editable) return;
    a.infoDiv.innerHTML = that.getTemplateHTML(templateDiv, a);
  });
};

/**
 * @param {!Annotation} a Internal annotation
 * @return {!PublicAnnotation} a view of the annotation for the public API.
 */
annotations.prototype.createPublicAnnotation_ = function(a, opt_props) {
  var displayAnnotation = $.extend({}, a, opt_props);
  delete displayAnnotation['infoDiv'];
  delete displayAnnotation['lineDiv'];
  delete displayAnnotation['isDragging'];
  delete displayAnnotation['editable'];
  return displayAnnotation;
};

// Fill out a div using the values in the annotation object.
// The div's html is expected to have text of the form "{{key}}"
annotations.prototype.getTemplateHTML = function(div, a) {
  var g = this.dygraph_;
  var row_col = this.findPointIndex_(a.series, a.xval);
  if (row_col == null) return;  // perhaps it's no longer a real point?
  var row = row_col[0];
  var col = row_col[1];

  var yOptView = g.optionsViewForAxis_('y1');  // TODO: support secondary, too
  var xOptView = g.optionsViewForAxis_('x');
  var xvf = g.getOptionForAxis('valueFormatter', 'x');

  var x = xvf.call(g, a.xval, xOptView);
  var y = g.getOption('valueFormatter', a.series).call(
      g, g.getValue(row, col), yOptView);

  var displayAnnotation = this.createPublicAnnotation_(a, {x:x, y:y});
  var html = div.innerHTML;
  for (var k in displayAnnotation) {
    var v = displayAnnotation[k];
    if (typeof(v) == 'object') continue;  // e.g. infoDiv or lineDiv
    html = html.replace(new RegExp('\{\{' + k + '\}\}', 'g'), v);
  }
  return html;
};

// Update the annotation object by looking for elements with a 'dg-ann-field'
// attribute. For example, <input type='text' dg-ann-field='text' /> will have
// its value placed in the 'text' attribute of the annotation.
annotations.prototype.extractUpdatedProperties_ = function(div, a) {
  $(div).find('[dg-ann-field]').each(function(idx, el) {
    var k = $(el).attr('dg-ann-field');
    var v = $(el).val();
    a[k] = v;
  });
};

// After a resize, the hairline divs can get dettached from the chart.
// This reattaches them.
annotations.prototype.attachAnnotationsToChart_ = function() {
  var div = this.dygraph_.graphDiv;
  $.each(this.annotations_, function(idx, a) {
    // Re-attaching an editable div to the DOM can clear its focus.
    // This makes typing really difficult!
    if (a.editable) return;

    $([a.lineDiv, a.infoDiv]).appendTo(div);
  });
};

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

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

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

  this.updateAnnotationDivPositions();
  this.attachAnnotationsToChart_();
  this.updateAnnotationInfo();
};

annotations.prototype.pointClick = function(e) {
  // Prevent any other behavior based on this click, e.g. creation of a hairline.
  e.preventDefault();

  var a = $.extend({}, this.defaultAnnotationProperties_, {
    series: e.point.name,
    xval: e.point.xval
  });
  this.annotations_.push(this.createAnnotation(a));

  this.updateAnnotationDivPositions();
  this.updateAnnotationInfo();
  this.attachAnnotationsToChart_();

  $(this).triggerHandler('annotationCreated', a);
  $(this).triggerHandler('annotationsChanged', {});

  // Annotations should begin life editable.
  this.makeAnnotationEditable(a);
};

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

// Public API

/**
 * This is a restricted view of this.annotations_ which doesn't expose
 * implementation details like the line / info divs.
 *
 * @typedef {
 *   xval:  number,      // x-value (i.e. millis or a raw number)
 *   series: string,     // series name
 * } PublicAnnotation
 */

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

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

    if (this.annotations_.length > i) {
      // Only the divs need to be preserved.
      var oldA = this.annotations_[i];
      this.annotations_[i] = $.extend({
        infoDiv: oldA.infoDiv,
        lineDiv: oldA.lineDiv
      }, a);
    } else {
      this.annotations_.push(this.createAnnotation(a));
      anyCreated = true;
    }
  }

  // If there are any remaining annotations, destroy them.
  while (annotations.length < this.annotations_.length) {
    this.removeAnnotation(this.annotations_[annotations.length]);
  }

  this.updateAnnotationDivPositions();
  this.updateAnnotationInfo();
  if (anyCreated) {
    this.attachAnnotationsToChart_();
  }

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

return annotations;

})();
