import { Injectable, EventEmitter } from '@angular/core';
import { select, selectAll } from 'd3-selection';
import { transition } from 'd3-transition';
import { scaleLinear, scaleBand } from 'd3-scale';
import { easePoly } from 'd3-ease';
import { axisRight, axisBottom, axisLeft, axisTop } from 'd3-axis';
import { extent, max, bisector, scan } from 'd3-array';
import { format } from 'd3-format';
import { zoom, zoomIdentity, zoomTransform } from 'd3-zoom';
import { lineChunked } from 'd3-line-chunked';
import * as _merge from 'lodash.merge';

@Injectable()
export class GraphService {
  el;
  svg;
  data: any = [];
  settings = {
    id: 'd3graph',
    props: { x: 'x', y: 'y' },
    margin: { left: 48, right: 10, top: 10, bottom: 48 },
    axis: {
      x: { position: 'bottom', label: 'x', invert: false, extent: [], minVal: null, maxVal: null },
      y: { position: 'left', label: 'y', invert: false, extent: [], minVal: null, maxVal: null }
    },
    transition: { ease: easePoly, duration: 1000 },
    zoom: { enabled: false, min: 1, max: 10 },
    debug: false
  };
  barHover = new EventEmitter();
  barClick = new EventEmitter();
  private type;
  private zoomBehaviour;
  private width;
  private height;
  private d3el;
  private container;
  private dataContainer;
  private dataRect; // rectangle around the bounds of the graph data
  private clip;
  private defs;
  private scales;
  private transform = zoomIdentity;
  private created = false;
  private bisectX = bisector((d) => d[this.settings.props.x]).left;
  private title;
  private desc;

  /**
   * initializes the SVG element for the graph
   * @param settings graph settings
   */
  create(el, data, settings = {}): GraphService {
    this.el = el;
    this.d3el = select(this.el);
    this.updateSettings(settings);
    // build the SVG if it doesn't exist yet
    if (!this.svg && this.d3el) {
      this.createSvg();
    }
    this.setDimensions();
    this.addZoomBehaviour();
    this.created = true;
    if (data) { this.update(data); }
    return this;
  }

  isCreated() { return this.created; }
  isLineGraph() { return this.type === 'line'; }

  /**
   * Sets the data for the graph and updates the view
   * @param data new data for the graph
   * @param type override the type of graph to render
   */
  update(data, type?) {
    this.setType(type ? type : this.detectTypeFromData(data));
    this.svg.attr('class', this.type === 'line' ? 'line-graph' : 'bar-graph');
    this.data = data;
    this.updateView();
  }

  /**
   * Adds axis and lines
   * If any arguments are passed the rendered elements will not transition into place
   */
  updateView(...args) {
    this.setDimensions();
    this.transform = zoomTransform(this.svg.node()) || zoomIdentity;
    this.scales = this.getScales();
    this.updateTitleDesc();
    this.renderAxis(this.settings.axis.x, this.transform, args.length > 0)
      .renderAxis(this.settings.axis.y, this.transform, args.length > 0);
    this.type === 'line' ? this.renderLines() : this.renderBars();
  }

  /**
   * Transitions the graph to the range provided by x1 and x2
   */
  setVisibleRange(x1, x2): GraphService {
    const pxWidth = Math.abs(this.scales.x(x1) - this.scales.x(x2));
    const spaceAvailable = this.width;
    const scaleAmount = Math.min((spaceAvailable / pxWidth), this.settings.zoom.max);
    const scaledWidth = pxWidth * scaleAmount;
    const emptySpace = ((spaceAvailable - scaledWidth) / 2);
    this.transform = zoomIdentity.scale(scaleAmount)
      .translate((-1 * this.scales.x(x1)) + (emptySpace / scaleAmount), 0);
    this.svg.transition()
      .ease(this.settings.transition.ease)
      .duration(this.settings.transition.duration)
      .call(this.zoomBehaviour.transform, this.transform);
    return this;
  }

  /**
   * Sets the type of graph, 'line' or 'bar'.  If switching from one type to another,
   * the render functions for the old type are called to clear out any rendered data.
   * @param type type of graph to switch to
   */
  setType(type: string) {
    if (this.type !== type) {
      const oldType = this.type;
      this.type = type;
      if (oldType === 'line') { this.renderLines(); }
      if (oldType === 'bar') { this.renderBars(); }
    }
  }

  /**
   * Creates an axis for graph element
   */
  renderAxis(settings, transform = this.transform, blockTransition = false): GraphService {
    const axisType =
      (settings.position === 'top' || settings.position === 'bottom') ? 'x' : 'y';
    const axisGenerator = this.getAxisGenerator(settings);
    // if line graph, scale axis based on transform
    const scale = (axisType === 'x') ?
      (this.type === 'line' ? transform.rescaleX(this.scales.x) : this.scales.x) :
      this.scales.y;

    // if called from a mouse event (blockTransition = true), call the axis generator
    // if transition is programatically triggered, transition to the new axis position
    if (blockTransition) {
      this.container.selectAll('g.axis-' + axisType)
        .call(axisGenerator.scale(scale));
    } else {
      this.container.selectAll('g.axis-' + axisType)
        .transition().duration(this.settings.transition.duration)
        .call(axisGenerator.scale(scale));
    }
    // update axis label
    this.container.selectAll('g.axis-' + axisType + ' .label-' + axisType)
      .text(this.settings.axis[axisType]['label'] || '');
    return this;
  }

  /**
   * Render bars for the data
   */
  renderBars() {
    const barData = (this.type === 'bar' ? this.data : []);
    const bars = this.dataContainer.selectAll('.bar').data(barData, (d) => d.id);
    const self = this;

    // transition out bars no longer present
    bars.exit()
      .attr('class', (d, i) => 'bar bar-exit bar-' + i)
      .transition()
      .ease(this.settings.transition.ease)
      .duration(this.settings.transition.duration)
      .attr('height', 0)
      .attr('y', this.height)
      .remove();

    const update = () => {
      bars.transition().ease(this.settings.transition.ease)
        .duration(this.settings.transition.duration)
        .attr('class', (d, i) => 'bar bar-' + i)
        .attr('height', (d) => Math.max(
          0, this.height - this.scales.y(this.getBarDisplayVal(d.data[0][this.settings.props.y], this.scales.y))
        ))
        .attr('y', (d) => this.scales.y(this.getBarDisplayVal(d.data[0][this.settings.props.y], this.scales.y)))
        .attr('x', (d) => this.scales.x(d.data[0][this.settings.props.x]))
        .attr('width', this.scales.x.bandwidth());
    };

    if (this.type === 'bar') {
      // update bars with new data
      update();

      // add bars for new data
      bars.enter().append('rect')
        .attr('class', (d, i) => 'bar bar-enter bar-' + i)
        .on('mouseover', function(d) { self.barHover.emit({...d, ...self.getBarRect(this), el: this }); })
        .on('mouseout',  function(d) { self.barHover.emit(null); })
        .on('click',  function(d) { self.barClick.emit({...d, ...self.getBarRect(this), el: this }); })
        .attr('x', (d) => this.scales.x(d.data[0][this.settings.props.x]))
        .attr('y', this.height)
        .attr('width', this.scales.x.bandwidth())
        .attr('height', 0)
        .transition().ease(this.settings.transition.ease)
          .duration(this.settings.transition.duration)
          .attr('height', (d) => Math.max(0, this.height - this.scales.y(d.data[0][this.settings.props.y])))
          .attr('y', (d) => this.scales.y(d.data[0][this.settings.props.y]));
    }
    return this;
  }

  /**
   * Renders lines for any data in the data set.
   */
  renderLines(transform = this.transform) {
    const lineData = (this.type === 'line' ? this.data : []);
    const extent = this.getExtents();
    const lines = this.dataContainer.selectAll('g.line').data(lineData, (d) => d.id);
    const linesEnter = lines.enter().append('g')
      .attr('class', (d, i) => 'line line-' + i);

    const flatLine = lineChunked()
      .accessData(d => d.data)
      .defined((d: any) => !isNaN(d[this.settings.props.y]))
      .x((d: any, index: any, da: any) => 0)
      .y(this.scales.y(extent.y[0]))
      .pointAttrs({ r: 0 });

    const valueLine = lineChunked()
      .accessData(d => d.data)
      .defined((d: any) => !isNaN(d[this.settings.props.y]))
      .x((d: any, index: any, da: any) => this.scales.x(d.x))
      .y((d: any) => this.scales.y(d[this.settings.props.y]))
      .lineAttrs({
        class: (d, i) => 'line line-' + i,
        transform: 'translate(' + transform.x + ',0)scale(' + transform.k + ',1)',
        'vector-effect': 'non-scaling-stroke'
      })
      .gapStyles({ 'stroke-opacity': 0 })
      .pointAttrs({ r: 5 });

    // Transition from flat line on enter
    linesEnter.call(flatLine)
      .transition()
      .ease(this.settings.transition.ease)
      .duration(this.settings.transition.duration)
      .call(valueLine);

    lines.transition()
      .ease(this.settings.transition.ease)
      .duration(this.settings.transition.duration)
      .call(valueLine);

    // transition out lines no longer present
    lines.exit()
      .attr('class', (d, i) => 'line line-exit line-' + i)
      .transition()
      .ease(this.settings.transition.ease)
      .duration(this.settings.transition.duration)
      .call(flatLine)
      .remove();

    return this;
  }

  /**
   * Transitions back to the default zoom for the graph
   */
  resetZoom(): GraphService {
    this.svg.transition()
      .ease(this.settings.transition.ease)
      .duration(this.settings.transition.duration)
      .call(this.zoomBehaviour.transform, zoomIdentity);
    return this;
  }

  /**
   * Overrides any provided graph settings
   * @param settings graph settings
   */
  updateSettings(settings = {}) {
    this.settings = _merge(this.settings, settings);
    this.log('updated settings', settings);
  }

  /**
   * Sets the width and height of the graph and updates any containers
   */
  setDimensions(margin = this.settings.margin) {
    this.width = this.el.clientWidth - margin.left - margin.right;
    this.height =
      this.el.getBoundingClientRect().height - margin.top - margin.bottom;
    // only set dimensions if width and height are valid
    if (this.width > 0 && this.height > 0) {
      this.svg
        .attr('width', this.width + margin.left + margin.right)
        .attr('height', this.height + margin.top + margin.bottom);
      this.container.attr('width', this.width).attr('height', this.height)
        .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
      this.dataContainer.attr('width', this.width).attr('height', this.height)
        .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
      this.clip.attr('width', this.width).attr('height', this.height);
      this.dataRect.attr('width', this.width).attr('height', this.height)
          .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
      this.svg.selectAll('g.axis-x')
        .attr('transform', this.getAxisTransform(this.settings.axis.x.position))
        .selectAll('.label-x')
          .attr('transform', 'translate(' + this.width / 2 + ',' + margin.bottom + ')')
          .attr('text-anchor', 'middle')
          .attr('dy', -10)
          ;
      this.svg.selectAll('g.axis-y')
        .attr('transform', this.getAxisTransform(this.settings.axis.y.position))
        .selectAll('.label-y')
          .attr('transform', 'rotate(-90) translate(' + -this.height / 2 + ',' + -margin.left + ')')
          .attr('dy', margin.left / 2)
          .attr('text-anchor', 'middle')
          .text(this.settings.axis.y.label);
      this.log('setting dimensions', this.width, this.height, margin);
    } else {
      this.width = 1;
      this.height = 1;
    }

    return this;
  }

  /**
   * Gets the value of the data at the provided x pixel coordinate
   */
  getValueAtPosition(xPos) {
    if (
      xPos < this.settings.margin.left || xPos > (this.settings.margin.left + this.width)
    ) { return null; }
    const graphX = Math.max(0, Math.min((xPos - this.settings.margin.left), this.width));
    const x0 = this.scales.x.invert(graphX);
    return this.getLineValues(x0, 0);
  }

  /**
   * Gets the line values for a previous or next x value
   * @param currentX
   * @param offset
   */
  getLineValues(currentX, offset = 1) {
    // use the first X value if there is no current
    if (!currentX && currentX !== 0) {
      currentX = this.data[0].data[0][this.settings.props.x];
      offset = 0;
    }
    const self = this;
    const values = [];
    selectAll('g.line').each(function (d: any) {
      const i = self.bisectX(d.data, currentX, 1);
      const NULL_VAL = { id: d.id, x: null, y: null, xPos: null, yPos: null };
      if (d.data.length && d.data[i]) {
        const x0 = d.data[i - 1][self.settings.props.x];
        const x1 = d.data[i][self.settings.props.x];
        const closestIndex = (Math.abs(currentX - x0) > Math.abs(currentX - x1)) ? i : i - 1;
        const boundedIndex = Math.min(d.data.length - 1, Math.max(0, closestIndex + offset));
        // Only push value if less than a full stop away from the closest
        const val = self.getLineEventValue(d, boundedIndex, this);
        values.push(Math.abs(currentX - val.x) < 1 ? val : NULL_VAL);
      } else {
        values.push(NULL_VAL);
      }
    });
    return values;
  }

  /**
   * Gets the bar values for a previous or next x value
   * @param currentX
   * @param offset
   */
  getBarValue(currentX, offset = 1) {
    const self = this;
    let newIndex = -1;
    // get the new index, or start at the beginning if there is no current value
    if (!currentX) {
      newIndex = 0;
    } else {
      selectAll('.bar').each(function(d: any, i: number) {
        if (d.data[0][self.settings.props.x] === currentX) {
          newIndex = (i + offset) % self.data.length;
        }
      });
    }
    // get the bar dimensions for the new index and return the data / position
    if (newIndex > -1) {
      const el = selectAll('.bar').filter((d0: any, i) => i === newIndex).node();
      return [{ id: this.data[newIndex].id, ...this.data[newIndex].data[0], ...this.getBarRect(el), el: el }];
    }
    return null;
  }

  /**
   * Display a minimum line for bar graph values that is 1% of the maximum value
   * @param val
   * @param scale
   */
  private getBarDisplayVal(val, scale) {
    const scaleDomain = scale.domain();
    return val >= 0.1 ? val : scaleDomain[scaleDomain.length - 1] * 0.01;
  }

  /**
   * Builds an object to return for DOM events
   * @param dataItem the line data item
   * @param pointIndex the index of the point to get data at
   * @param el the DOM line element
   */
  private getLineEventValue(dataItem, pointIndex, el) {
    let yVal = dataItem.data[pointIndex][this.settings.props.y];
    yVal = this.settings.axis.y.maxVal > 0 ? Math.min(this.settings.axis.y.maxVal, yVal) : yVal;
    return {
      id: dataItem.id,
      ...dataItem.data[pointIndex],
      xPos: (this.settings.margin.left + this.scales.x(dataItem.data[pointIndex][this.settings.props.x])),
      yPos: (this.settings.margin.top + this.scales.y(yVal)),
      el: el
    };
  }

  /** Gets the position and size of a passed `rect` element */
  private getBarRect(el) {
    return {
      top: parseFloat(el.getAttribute('y')) + this.settings.margin.top,
      left: parseFloat(el.getAttribute('x')) + this.settings.margin.left,
      width: parseFloat(el.getAttribute('width')),
      height: parseFloat(el.getAttribute('height'))
    };
  }

  /** Creates the skeleton DOM structure of the SVG */
  private createSvg() {
    this.svg = this.d3el.append('svg');
    this.title = this.svg.append('title');
    this.desc = this.svg.append('desc');
    // data rect
    this.dataRect = this.svg.append('rect')
      .attr('class', 'graph-rect');
    // defs
    this.defs = this.svg.append('defs');
    // gradients
    for (let i = 0; i < 4; i++) {
      const g = this.defs.append('linearGradient').attr('id', 'g' + i)
        .attr('x1', 0).attr('x2', 0).attr('y1', 0).attr('y2', 1);
      g.append('stop').attr('offset', '0%').attr('class', 'g' + i + '-start');
      g.append('stop').attr('offset', '100%').attr('class', 'g' + i + '-end');
    }
    // clip area
    this.clip = this.defs
      .append('clipPath').attr('id', 'data-container')
      .append('rect').attr('x', 0).attr('y', 0);
    // containers for axis
    this.container = this.svg.append('g').attr('class', 'graph-container');
    this.container.append('g').attr('class', 'axis axis-x')
      .append('text').attr('class', 'label-x');
    this.container.append('g').attr('class', 'axis axis-y')
      .append('text').attr('class', 'label-y');
    // masked container for lines and bars
    this.dataContainer = this.svg.append('g')
      .attr('clip-path', 'url(#data-container)')
      .attr('class', 'data-container');
    this.log('created svg', this.svg);
  }

  /**
   * Creates the zoom behaviour for the graph then sets it up based on
   * dimensions and settings
   */
  private addZoomBehaviour(): GraphService {
    this.zoomBehaviour = zoom()
      .scaleExtent([this.settings.zoom.min, this.settings.zoom.max])
      .translateExtent([[0, 0], [this.width, this.height]])
      .extent([[0, 0], [this.width, this.height]])
      .on('zoom', this.updateView.bind(this));
    if (this.settings.zoom.enabled) { this.svg.call(this.zoomBehaviour); }
    return this;
  }

  /**
   * Get the transform based on the axis position
   * @param position
   */
  private getAxisTransform(position: string) {
    switch (position) {
      case 'top':
        return 'translate(0,0)';
      case 'bottom':
        return 'translate(0,' + this.height + ')';
      case 'left':
        return 'translate(0,0)';
      case 'right':
        return 'translate(' + this.width + ',0)';
      default:
        return 'translate(0,0)';
    }
  }

  /**
   * returns the axis generator based the axis settings and graph type
   * @param settings settings for the axis, including position and tick formatting
   */
  private getAxisGenerator(settings: any) {
    let axisGen;
    let scale;
    switch (settings.position) {
      case 'top':
        axisGen = axisTop;
        scale = this.scales.x;
        break;
      case 'bottom':
        axisGen = axisBottom;
        scale = this.scales.x;
        break;
      case 'left':
        axisGen = axisLeft;
        scale = this.scales.y;
        break;
      case 'right':
        axisGen = axisRight;
        scale = this.scales.y;
        break;
    }
    return this.addTicks(axisGen(scale), settings);
  }

  /** Add ticks to an axis */
  private addTicks(axisGen, settings) {
    let axis = axisGen;
    if (settings.hasOwnProperty('ticks') && settings.ticks) {
      axis = axis.ticks(settings.ticks);
    }
    if (settings.hasOwnProperty('tickSize') && settings.tickSize) {
      axis = axis.tickSize(this.getTickSize(settings.tickSize, settings.position));
    }
    if (settings.hasOwnProperty('tickFormat') && settings.tickFormat) {
      axis = axis.tickFormat(format(settings.tickFormat));
    }
    if (settings.hasOwnProperty('tickPadding') && settings.tickPadding) {
      axis = axis.tickPadding(settings.tickPadding);
    }
    this.log('created axis', axis, settings);
    return axis;
  }

  /** Parse the tick size to see if it is a percentage */
  private getTickSize(value, axisPosition: string) {
    if (typeof value === 'string' && value.slice(-1) === '%') {
      const axisType = axisPosition === 'left' || axisPosition === 'right' ? 'y' : 'x';

      return (parseFloat(value) / 100) * (axisType === 'x' ? this.height : this.width );
    }
    return value;
  }

  /**
   * Returns a range for the axis
   */
  private getRange() {
    return {
      x: (this.settings.axis.x.invert ? [this.width, 0] : [0, this.width]),
      y: (this.settings.axis.y.invert ? [0, this.height] : [this.height, 0])
    };
  }

  /** Gets the extents for the x and y axis */
  private getExtents(): { x: Array<number>, y: Array<number> } {
    return {
      x: this.getExtent('x'),
      y: this.getExtent('y')
    };
  }

  /**
   * Gets either the x or y extent
   * @param prop either 'x' or 'y'
   */
  private getExtent(prop: string): Array<number> {
    const axis = this.settings.axis[prop];
    // return if extent is explicitly set
    if (axis.hasOwnProperty('extent') && axis['extent'].length === 2) {
      return axis['extent'];
    }
    // return if there is no data
    if (!this.data.length) { return [0, 1]; }
    // loop through data and create an extent representative of the entire data set
    let xtnt: Array<number>;
    for (const dp of this.data) {
      const setExtent = extent(dp.data, (d) => parseFloat(d[this.settings.props[prop]]));
      xtnt = xtnt ? extent([...xtnt, ...setExtent]) : setExtent;
    }
    // pad y extent by 10%
    xtnt = prop === 'y' ? this.padExtent(xtnt, 0.1, {top: true, bottom: true}) : xtnt;
    // Set min Y to minVal if needed
    if (!isNaN(parseFloat(axis.minVal))) { xtnt[0] = Math.max(axis.minVal, xtnt[0]); }
    // Cap Y extent to maxVal if present
    if (!isNaN(parseFloat(axis.maxVal))) { xtnt[1] = Math.min(xtnt[1], axis.maxVal); }
    return xtnt;
  }

  /**
   * Returns the scales based on the graph type
   */
  private getScales() {
    if (this.type === 'line') {
      const ranges = this.getRange();
      const extents = this.getExtents();
      return {
        x: scaleLinear().range(ranges.x).domain(extents.x),
        y: scaleLinear().range(ranges.y).domain(extents.y)
      };
    } else if (this.type === 'bar') {
      const scales = {
        x: scaleBand().rangeRound([0, this.width]).padding(0.25),
        y: scaleLinear().rangeRound([this.height, 0])
      };
      // Set max Y value in scale to at least minVal, at most maxVal
      let maxY = max(this.data, (d: any) => parseFloat(d.data[0][this.settings.props.y]));
      if (!isNaN(this.settings.axis.y.minVal)) {
        maxY = Math.max(this.settings.axis.y.minVal, maxY);
      }
      if (!isNaN(this.settings.axis.y.maxVal)) {
        maxY = Math.min(this.settings.axis.y.maxVal, maxY);
      }
      // Cap Y domain to maxVal without padding if present
      let yDomain;
      if (this.settings.axis.y.hasOwnProperty('extent') && this.settings.axis.y.extent.length === 2) {
        yDomain = this.settings.axis.y.extent;
      } else {
        yDomain = this.settings.axis.y.maxVal === maxY ? [0, maxY] : this.padExtent([0, maxY], 0.1, { top: true });
      }
      scales.x.domain(this.data.map((d) => d.data[0][this.settings.props.x]));
      scales.y.domain(yDomain);
      return scales;
    }
  }

  /** Pads the min / max of an extent array by the provided amount */
  private padExtent(extent: Array<number>, amount = 0.1, options: any = {}): Array<number> {
    const padding = (extent[1] - extent[0]) * amount;
    const min = options.bottom ? extent[0] - padding : extent[0];
    const max = options.top ? extent[1] + padding : extent[1];
    return [min, max];
  }

  /**
   * Attempts to determine the type of graph based on the provided data.
   * If each item in the data set only has one data point, it assumes it is a bar graph
   * Anything else is a line graph.
   * @param data The dataset for the graph
   */
  private detectTypeFromData(data): string {
    for (let i = 0; i < data.length; i++) {
      if (data[i].data.length !== 1) {
        return 'line';
      }
    }
    return 'bar';
  }

  /**
   * Set the title and description for the graph based on settings and also add
   * the aria-labelledby attr as recommended by:
   * https://css-tricks.com/accessible-svgs/
   */
  private updateTitleDesc() {
    this.title
      .attr('id', this.settings.id + '_title')
      .text(this.settings['title'] ? this.settings['title'] : '');
    this.desc
      .attr('id', this.settings.id + '_desc')
      .text(this.settings['description'] ? this.settings['description'] : '');
    this.svg
      .attr('aria-labelledby', `${this.settings.id}_title ${this.settings.id}_desc`);
  }

  /** Wrapper for console logging if debug is enabled */
  private log(...logItems) {
    if (this.settings.debug) {
      console.debug.apply(this, logItems);
    }
  }
}
