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 { line } from 'd3-shape';
import * as _merge from 'lodash.merge';

@Injectable()
export class GraphService {
  el;
  svg;
  data: any = [];
  settings = {
    props: { x: 'x', y: 'y' },
    margin: { left: 48, right: 10, top: 10, bottom: 48 },
    axis: {
      x: { position: 'bottom', label: 'x', ticks: 5, tickSize: 5, tickFormat: ',.0f', invert: false },
      y: { position: 'left', label: 'y', ticks: 5, tickSize: 5, tickFormat: ',.0f', invert: false }
    },
    transition: { ease: easePoly, duration: 1000 },
    zoom: { enabled: false, min: 1, max: 10 }
  };
  barHover = new EventEmitter();
  barClick = new EventEmitter();
  private type;
  private zoomBehaviour;
  private width;
  private height;
  private d3el;
  private container;
  private dataContainer;
  private clip;
  private scales;
  private transform = zoomIdentity;
  private created = false;
  private bisectX = bisector((d) => d[this.settings.props.x]).left;

  /**
   * 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.transform = zoomTransform(this.svg.node()) || zoomIdentity;
    this.scales = this.getScales();
    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));
    }
    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();

    if (this.type === 'bar') {
      // update bars with new data
      bars.attr('class', (d, i) => 'bar bar-' + i)
        .attr('height', (d) => 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]))
        .attr('x', (d) => this.scales.x(d.data[0][this.settings.props.x]))
        .attr('width', this.scales.x.bandwidth());

      // add bars for new data
      bars.enter().append('rect')
        .attr('class', (d, i) => 'bar bar-enter bar-' + i)
        .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)
        .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 }); })
        .transition().ease(this.settings.transition.ease)
        .duration(this.settings.transition.duration)
        .attr('height', (d) => 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.getExtent();
    const lines = this.dataContainer.selectAll('.line').data(lineData, (d) => d.id);
    const flatLine = line()
      .defined((d: any) => !isNaN(d[this.settings.props.y]))
      .x((d: any, index: any, da: any) => this.scales.x(d.x))
      .y(this.scales.y(extent.y[0]));

    const valueLine = line().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]));

    const update = () => {
      lines
        .attr('class', (d, i) => 'line line-' + i)
        .attr('transform', 'translate(' + transform.x + ',0)scale(' + transform.k + ',1)')
        .attr('vector-effect', 'non-scaling-stroke')
        .transition().ease(this.settings.transition.ease)
        .duration(this.settings.transition.duration)
        .attr('d', (d) => valueLine(d.data));
    };

    // 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)
      .attr('d', (d) => flatLine(d.data))
      .remove();

    if (this.type === 'line') {
      // update lines with new data
      update();

      // add lines for new data
      lines.enter().append('path')
        .attr('class', (d, i) => 'line line-enter line-' + i)
        .attr('transform', 'translate(' + transform.x + ',0)scale(' + transform.k + ',1)')
        .attr('vector-effect', 'non-scaling-stroke')
        .attr('d', (d) => flatLine(d.data))
        .transition()
        .ease(this.settings.transition.ease)
        .duration(this.settings.transition.duration)
        .attr('d', (d) => valueLine(d.data));
    }
    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);
  }

  /**
   * 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;
    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.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('dy', -10)
        .text(this.settings.axis.x.label);
    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', 10)
        .text(this.settings.axis.y.label);
    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('.line').each(function (d: any) {
      const i = self.bisectX(d.data, currentX, 1);
      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));
      values.push(self.getLineEventValue(d, boundedIndex, this));
    });
    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;
  }

  private getLineEventValue(dataItem, pointIndex, el) {
    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(dataItem.data[pointIndex][this.settings.props.x])),
      el: el
    };
  }

  private getBarEventValue(data) {

  }

  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'))
    };
  }

  private createSvg() {
    this.svg = this.d3el.append('svg');
    // clip area
    this.clip = this.svg.append('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');
  }

  /**
   * 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;
    }
    if (this.type === 'line') {
      return axisGen(scale).ticks(settings.ticks)
        .tickSize(settings.tickSize)
        .tickFormat(format(settings.tickFormat));
    } else if (this.type === 'bar') {
      if (settings.position === 'top' || settings.position === 'bottom') {
        return axisGen(scale);
      } else if (settings.position === 'left' || settings.position === 'right') {
        return axisGen(scale).ticks(settings.ticks);
      }
    }
  }

  /**
   * 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 x and y extent of the data
   * @param data
   */
  private getExtent(): { x: Array<number>, y: Array<number> } {
    if (!this.data.length) { return { x: [0, 1], y: [0, 1] }; }
    const extents: any = {};
    for (const dp of this.data) {
      const setExtent = {
        x: extent(dp.data, (d) => parseFloat(d[this.settings.props.x])),
        y: extent(dp.data, (d) => parseFloat(d[this.settings.props.y]))
      };
      extents.x = extents.x ? extent([...extents.x, ...setExtent.x]) : setExtent.x;
      extents.y = extents.y ? extent([...extents.y, ...setExtent.y]) : setExtent.y;
    }
    return extents;
  }

  /**
   * Returns the scales based on the graph type
   */
  private getScales() {
    if (this.type === 'line') {
      const ranges = this.getRange();
      const extents = this.getExtent();
      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])
      };
      scales.x.domain(this.data.map((d) => d.data[0][this.settings.props.x]));
      scales.y.domain([0, max(this.data, (d: any) => parseFloat(d.data[0][this.settings.props.y]))]);
      return scales;
    }
  }

  /**
   * 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';
  }
}
