'use strict';

var directivesModule = require('./_index.js');
var arrayUtil = require('../util/array-util');

var d3Ease = require('d3-ease');
var d3Interpolate = require('d3-interpolate');
var d3Scale = require('d3-scale');
var d3TimeFormat = require('d3-time-format');

/**
 * @ngInject
 */
function timelineViewport($document, $window) {
  var link = function(scope, el, attrs, timelineController) {
    // local display variables
    var margin = timelineController.margin;
    var statusColorMap = timelineController.statusColorMap;
    var height = 200;
    var loaded = false;

    // axes and timeline-global variables
    var y = d3Scale.scaleLinear();
    var absolute = timelineController.axes.absolute;
    var xSelected = timelineController.axes.selection;
    var cursorTimeFormat = d3TimeFormat.timeFormat('%X');
    var tickFormat = timelineController.axes.x.tickFormat();

    // animation variables
    var currentViewExtents = null;
    var viewInterpolator = null;
    var easeOutCubic = d3Ease.easeCubicOut;
    var easeStartTimestamp = null;
    var easeDuration = 500;

    // selection and hover variables
    var mousePoint = null;
    var selection = null;
    var hover = null;

    // canvases and layers
    var lanes = timelineController.createCanvas();
    var regions = [];
    var cursor = timelineController.createCanvas();
    var main = timelineController.createCanvas(null, null, false);
    el.append(main.canvas);

    /**
     * Initializes rects from a list of parsed subunit log entries, setting
     * initial sizes and positions based on the current view extents.
     * @param {Array} data A list of parsed subunit log entries
     */
    function createRects(data) {
      var rects = [];

      var h = 0.8 * y(1);
      for (var i = 0; i < data.length; i++) {
        var entry = data[i];
        var start = absolute(+entry.startDate);
        rects.push({
          x: start,
          y: y(entry.worker),
          width: absolute(+entry.endDate) - start,
          height: h,
          entry: entry
        });
      }

      return rects;
    }

    /**
     * Generate the list of active regions or "chunks". These regions span a
     * fixed area of the timeline's full "virtual" width, and contain only a
     * small subset of data points that fall within the area. This function only
     * initializes a list of regions, but does not actually attempt to draw
     * anything. Drawing can be handled lazily and will only occur when a
     * region's 'dirty' property is set. If a list of regions already exists,
     * it will be thrown away and replaced with a new list; this should occur
     * any time the full "virtual" timeline width changes (such as a extent
     * resize), or if the view extents no longer fall within the generated list
     * of regions.
     *
     * This function will limit the number of generated regions. If this is not
     * sufficient to cover the entire area spanned by the timeline's virtual
     * width, regions will be generated around the user's current viewport.
     *
     * Note that individual data points will exist within multiple regions if
     * they span region borders. In this case, each containing region will have
     * a unique rect instance pointing to the same data point.
     */
    function createRegions() {
      regions = [];

      var fullWidth = absolute(timelineController.timeExtents[1]);
      var chunkWidth = 500;
      var chunks = Math.ceil(fullWidth / chunkWidth);
      var offset = 0;

      // avoid creating lots of chunks - cap and only generate around the
      // current view
      // if we scroll out of bounds of the chunks we *do* have, we can throw
      // away our regions + purge regions in memory
      if (chunks > 30) {
        var startX = absolute(timelineController.viewExtents[0]);
        var endX = absolute(timelineController.viewExtents[1]);
        var midX = startX + (endX - startX) / 2;

        chunks = 50;
        offset = Math.max(0, midX - (chunkWidth * 15));
      }

      for (var i = 0; i < chunks; i++) {
        // for each desired chunk, find the bounds and managed data points
        // then, calculate positions for each data point
        var w = Math.min(fullWidth - offset, chunkWidth);
        var min = absolute.invert(offset);
        var max = absolute.invert(offset + w);
        var data = timelineController.dataInBounds(min, max);
        var rects = createRects(data);

        regions.push({
          x: offset, width: w, min: min, max: max,
          data: data, rects: rects,
          c: null,
          dirty: true,
          index: regions.length
        });

        offset += w;
      }
    }

    /**
     * Marks all regions as dirty so they can be redrawn for the next frame.
     */
    function markAllDirty() {
      regions.forEach(function(region) {
        region.dirty = true;
      });
    }

    /**
     * Finds all regions falling within the given minimum and maximum absolute
     * x coordinates.
     * @param  {number} minX the minimum x coordinate (exclusive)
     * @param  {number} maxX the maximum x coording (exclusive)
     * @return {object[]}    a list of matching regions
     */
    function getContainedRegions(minX, maxX) {
      return regions.filter(function(region) {
        return (region.x + region.width) > minX && region.x < maxX;
      });
    }

    /**
     * Get all regions containing the given data point.
     * @param  {object} entry the datapoint
     * @return {object[]}     a list of regions containing this entry
     */
    function getRegionsForEntry(entry) {
      var min = absolute(entry.startDate);
      var max = absolute(entry.endDate);
      return getContainedRegions(min, max);
    }

    /**
     * Get the rect corresponding to the given entry within a particular region.
     * @param  {object} region the region to search in
     * @param  {object} entry  the entry to search for
     * @return {object|null}   the matching rect, if any
     */
    function getRectForEntry(region, entry) {
      return region.rects.find(function(r) {
        return r.entry === entry;
      });
    }

    /**
     * Find the region managing the given canvas-local X coordinate. If the x
     * value is outside of the actual canvas area where regions are rendered,
     * this may return null. Rarely, null may also be returned if, while
     * animating, the view moves to a position outside of capped view bounds
     * (i.e. when the view extents are small); if this happens, it can be
     * ignored and createRegions() will generate the necessary area when the
     * animation finishes.
     * @param  {number} screenX the canvas-local x coordinate
     * @return {object|null}    the matching region or null
     */
    function getRegionAt(screenX) {
      if (screenX < margin.left || screenX > main.canvas.width - margin.right) {
        return null;
      }
      var absX = absolute(currentViewExtents[0]) + (screenX - margin.left);
      return regions.find(function(r) {
        return absX >= r.x && absX <= (r.x + r.width);
      });
    }

    /**
     * Get the rect at the given canvas-local coordinates, if any exists. This
     * function has the same limitations as getRegionAt() and will return null
     * when the coordinates are out of bounds or rarely when animating, but also
     * returns null when no rect exists at the given coords.
     *
     * Note that this will only search within the region containing the
     * coordinates so it should be fairly performant, though it will only return
     * one of possibly many matching rects.
     * @param  {number} screenX the canvas-local x coordinate
     * @param  {number} screenY the canvas-local y coordinate
     * @return {object|null}    the matching rect in the region or null
     */
    function getRectAt(screenX, screenY) {
      if (screenY < margin.top || screenY > main.canvas.height - margin.bottom) {
        return null;
      }

      var region = getRegionAt(screenX);
      if (!region) {
        return null;
      }

      // find the absolute coords in rect-space
      var absX = absolute(currentViewExtents[0]) + (screenX - margin.left);
      var absY = screenY - margin.top;

      for (var i = 0; i < region.rects.length; i++) {
        var rect = region.rects[i];

        if (absX >= rect.x && absX <= (rect.x + rect.width) &&
            absY >= rect.y && absY <= (rect.y + rect.height)) {
          // make sure the point is contained inside the rect
          return rect;
        }
      }

      return null;
    }

    /**
     * Draw lane lines and their labels into the offscreen lanes canvas.
     */
    function drawLanes() {
      // make sure the canvas is the correct size and clear it
      lanes.resize(timelineController.width + margin.left + margin.right);
      lanes.ctx.clearRect(0, 0, lanes.canvas.width, lanes.canvas.height);

      lanes.ctx.strokeStyle = 'lightgray';
      lanes.ctx.textBaseline = 'middle';
      lanes.ctx.font = '14px Arial';

      // draw lanes for each worker
      var laneHeight = y(1);
      for (var worker = 0; worker < timelineController.data.length; worker++) {
        var yPos = margin.top + y(worker - 0.1);

        // draw horizontal lines between lanes
        lanes.ctx.beginPath();
        lanes.ctx.moveTo(margin.left, yPos);
        lanes.ctx.lineTo(margin.left + timelineController.width, yPos);
        lanes.ctx.stroke();

        // draw labels middle-aligned to the left of each lane
        lanes.ctx.fillText(
            'Worker #' + worker,
            5, yPos + (laneHeight / 2),
            margin.left - 10);
      }
    }

    /**
     * Draw a single rect within a region. This may be called independently of
     * drawRegion() to update only a single rect, if needed.
     * @param {object}  region  the region to draw within
     * @param {object}  rect    the rect to draw
     * @param {boolean} [clear] if true, clear the rect first (default: false)
     */
    function drawSingleRect(region, rect, clear) {
      var ctx = region.c.ctx;

      if (rect.entry === selection) {
        ctx.fillStyle = statusColorMap.selected;
      } else if (rect.entry === hover) {
        ctx.fillStyle = statusColorMap.hover;
      } else {
        ctx.fillStyle = statusColorMap[rect.entry.status];
      }

      if (clear) {
        ctx.clearRect(rect.x - region.x, rect.y, rect.width, rect.height);
      }

      var filter = timelineController.filterFunction;
      if (!filter || filter(rect.entry)) {
        ctx.globalAlpha = 1.0;
      } else {
        ctx.globalAlpha = 0.15;
      }

      ctx.fillRect(rect.x - region.x, rect.y, rect.width, rect.height);
      ctx.strokeRect(rect.x - region.x, rect.y, rect.width, rect.height);
    }

    /**
     * Redraw all matching rects among all regions that contain this entry.
     * @param  {object} entry the entry to redraw
     */
    function drawAllForEntry(entry) {
      getRegionsForEntry(entry).forEach(function(region) {
        if (!region.c) {
          return;
        }

        var r = getRectForEntry(region, entry);
        if (r) {
          drawSingleRect(region, r, true);
        }
      });
    }

    /**
     * Draw the given region into its own canvas. The region will only be drawn
     * if it is marked as dirty. If its canvas has not yet been created, it will
     * be initialized automatically. Note that this does not actually draw
     * anything to the screen (i.e. main canvas), as this result only populates
     * each region's local offscreen image with content. drawAll() will actually
     * draw to the screen (and implicitly call this function as well.
     * @param {object} region the region to draw
     */
    function drawRegion(region) {
      if (!region.dirty) {
        // only redraw if dirty
        return;
      }

      if (!region.c) {
        // create the actual image buffer lazily - don't waste memory if it will
        // never be seen
        region.c = timelineController.createCanvas(
            region.width, height + margin.bottom);
      }

      var ctx = region.c.ctx;
      ctx.clearRect(0, 0, region.width, height);
      ctx.strokeStyle = 'rgb(175, 175, 175)';
      ctx.lineWidth = 1;

      for (var i = 0; i < region.rects.length; i++) {
        var rect = region.rects[i];
        drawSingleRect(region, rect);
      }

      // draw axis ticks + labels
      // main axis line -- offset y by 0.5 to draw crisp lines
      ctx.strokeStyle = 'lightgray';
      ctx.fillStyle = '#888';
      ctx.font = '9px sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'top';
      ctx.beginPath();
      ctx.moveTo(0, height + 0.5);
      ctx.lineTo(region.width, height + 0.5);
      ctx.stroke();

      // make a scale for the position of this region, but shrink it slightly so
      // no labels overlap region boundaries and get cut off
      var tickScale = d3Scale.scaleTime().domain([
        absolute.invert(region.x + 10),
        absolute.invert(region.x + region.width - 10)
      ]);

      // 1 tick per 125px
      var ticks = tickScale.ticks(Math.floor(region.width / 125));

      for (var tickIndex = 0; tickIndex < ticks.length; tickIndex++) {
        var tick = ticks[tickIndex];
        var tickX = Math.floor(absolute(tick) - region.x) + 0.5;

        ctx.beginPath();
        ctx.moveTo(tickX, height);
        ctx.lineTo(tickX, height + 6);
        ctx.stroke();

        ctx.fillText(tickFormat(tick), tickX, height + 7);
      }

      ctx.strokeStyle = 'rgb(175, 175, 175)';
      region.dirty = false;
    }

    function drawCursor() {
      if (!mousePoint || !mousePoint.inBounds) {
        return;
      }

      var r = main.ratio;
      var ctx = main.ctx;
      ctx.scale(main.ratio, main.ratio);
      ctx.textAlign = 'center';
      ctx.textBaseline = 'top';
      ctx.fillStyle = 'dimgrey';
      ctx.strokeStyle = 'blue';

      // draw the cursor line
      ctx.beginPath();
      ctx.moveTo(mousePoint.x, margin.top);
      ctx.lineTo(mousePoint.x, margin.top + height);
      ctx.stroke();

      // draw the time label
      ctx.font = '9px sans-serif';
      var date = new Date(xSelected.invert(mousePoint.x - margin.left));
      ctx.fillText(cursorTimeFormat(date), mousePoint.x, 16);

      // draw the hovered item info
      if (hover) {
        var leftEdge = margin.left;
        var rightEdge = leftEdge + timelineController.width;

        ctx.font = 'bold 12px sans-serif';
        var name = hover.name.split('.').pop();
        var tw = ctx.measureText(name).width;

        var cx = mousePoint.x;
        if (mousePoint.x + (tw / 2) > rightEdge) {
          cx -= mousePoint.x - (rightEdge - tw / 2);
        } else if (mousePoint.x - (tw / 2) < leftEdge) {
          cx += (leftEdge + tw / 2) - mousePoint.x;
        }
        ctx.fillText(name, cx, 1);
      }

      // reset scale
      ctx.setTransform(1, 0, 0, 1, 0, 0);
    }

    /**
     * Draw all layers and visible regions on the screen.
     */
    function drawAll() {
      // update size of main canvas
      var w = timelineController.width + margin.left + margin.right;
      var e = angular.element(main.canvas);
      main.resize(w);

      var s = function(v) {
        return v * main.ratio;
      };

      main.ctx.clearRect(0, 0, main.canvas.width, main.canvas.height);
      main.ctx.drawImage(lanes.canvas, 0, 0);

      // draw all visible regions
      var startX = absolute(currentViewExtents[0]);
      var endX = absolute(currentViewExtents[1]);
      var viewRegions = getContainedRegions(startX, endX);

      var effectiveWidth = 0;
      viewRegions.forEach(function(region) {
        effectiveWidth += region.width;
      });

      if (effectiveWidth < timelineController.width) {
        // we had to cap the region generation previously, but moved outside of
        // the generated area, so regenerate regions around the current view
        createRegions();
        viewRegions = getContainedRegions(startX, endX);
      }

      viewRegions.forEach(function(region) {
        drawRegion(region);

        // calculate the cropping area and offsets needed to place the region
        // in the main canvas
        var sx1 = Math.max(0, startX - region.x);
        var sx2 = Math.min(region.width, endX - region.x);
        var sw = sx2 - sx1;
        var dx = Math.max(0, startX - region.x);
        if (Math.floor(sw) === 0) {
          return;
        }

        main.ctx.drawImage(
            region.c.canvas,
            s(sx1), 0,
            Math.floor(s(sw)), s(height + margin.bottom),
            s(margin.left + region.x - startX + sx1), s(margin.top),
            s(sw), s(height + margin.bottom));
      });

      drawCursor();
    }

    timelineController.animateCallbacks.push(function(timestamp) {
      if (!loaded) {
        return false;
      }

      if (viewInterpolator) {
        // start the animation
        var currentSize = currentViewExtents[1] - currentViewExtents[0];
        var newSize = timelineController.viewExtents[1] - timelineController.viewExtents[0];
        var diffSize = currentSize - newSize;
        var diffTime = timestamp - easeStartTimestamp;
        var pct = diffTime / easeDuration;

        // interpolate the current view bounds according to the easing method
        currentViewExtents = viewInterpolator(easeOutCubic(pct));

        if (Math.abs(diffSize) > 1) {
          // size has changed, recalculate regions
          createRegions();
        }

        drawAll();

        if (pct >= 1) {
          // finished, clear the state vars
          easeStartTimestamp = null;
          viewInterpolator = null;
          return false;
        } else {
          // request more frames until finished
          return true;
        }
      } else {
        // if there is no view interpolator function, just do a plain redraw
        drawAll();
        return false;
      }
    });

    /**
     * Gets the canvas-local mouse point for the given mouse event, accounting
     * for all relevant offsets and margins. The returned object will include an
     * additional `inBounds` property indicating whether or not the point falls
     * within the bounds of the main canvas.
     * @param  {MouseEvent} evt the mouse event
     * @return {object}     a point object
     */
    function getMousePoint(evt) {
      var r = main.canvas.getBoundingClientRect();
      var ret = {
        xRaw: evt.clientX - r.left,
        x: evt.clientX - r.left,
        y: evt.clientY - r.top
      };

      ret.inBounds = ret.x > margin.left &&
          ret.x < (margin.left + timelineController.width) &&
          ret.y > margin.top && ret.y < (margin.top + height);

      return ret;
    }

    main.canvas.addEventListener('mousedown', function(evt) {
      evt.preventDefault();

      mousePoint = getMousePoint(evt);
      var rect = getRectAt(mousePoint.x, mousePoint.y);
      if (rect) {
        timelineController.selectItem(rect.entry);
        scope.$apply();
      }
    });

    main.canvas.addEventListener('mousemove', function(evt) {
      mousePoint = getMousePoint(evt);
      var rect = getRectAt(mousePoint.x, mousePoint.y);
      var oldHover = hover;
      if (rect && rect.entry !== hover) {
        main.canvas.style.cursor = 'pointer';
        hover = rect.entry;

        drawAllForEntry(rect.entry);
        if (oldHover) {
          drawAllForEntry(oldHover);
        }
      } else if (!rect && hover) {
        main.canvas.style.cursor = 'default';
        hover = null;
        drawAllForEntry(oldHover);
      }

      timelineController.animate();
    });

    main.canvas.addEventListener('mouseout', function(evt) {
      mousePoint = null;
      main.canvas.style.cursor = 'default';
      timelineController.animate();
    });

    scope.$on('dataLoaded', function(event, data) {
      y.domain([0, data.length]).range([0, height]);
      createRegions();
      drawLanes();

      loaded = true;
    });

    scope.$on('update', function() {
      if (!loaded) {
        return;
      }

      createRegions();
      drawLanes();
      timelineController.animate();
    });

    scope.$on('updateViewSize', function() {
      if (!loaded) {
        return;
      }

      if (currentViewExtents) {
        // if we know where the view is already, try to animate the transition
        viewInterpolator = d3Interpolate.interpolateArray(
            currentViewExtents,
            timelineController.viewExtents);
        easeStartTimestamp = performance.now();
      } else {
        // otherwise, move directly to the new location/size
        currentViewExtents = timelineController.viewExtents;
        createRegions();
      }

      timelineController.animate();
    });

    scope.$on('updateViewPosition', function() {
      if (!loaded) {
        return;
      }

      if (currentViewExtents) {
        // if we know where the view is already, try to animate the transition
        viewInterpolator = d3Interpolate.interpolateArray(
            currentViewExtents,
            timelineController.viewExtents);
        easeStartTimestamp = performance.now();
      } else {
        // otherwise, move directly to the new location
        currentViewExtents = timelineController.viewExtents;
      }

      timelineController.animate();
    });

    scope.$on('postSelect', function(event, newSelection) {
      var old = selection;
      if (newSelection) {
        selection = newSelection.item;
      } else {
        selection = null;
      }

      if (old) {
        drawAllForEntry(old);
      }

      if (selection) {
        drawAllForEntry(selection);
      }

      timelineController.animate();
    });

    scope.$on('filter', function() {
      if (loaded) {
        markAllDirty();
        timelineController.animate();
      }
    });
  };

  return {
    restrict: 'E',
    require: '^timeline',
    scope: true,
    link: link
  };
}

directivesModule.directive('timelineViewport', timelineViewport);