/**
 * Copyright (c) HashiCorp, Inc.
 * SPDX-License-Identifier: MPL-2.0
 */

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { schedule, next } from '@ember/runloop';
import d3 from 'd3-selection';
import d3Scale from 'd3-scale';
import d3Axis from 'd3-axis';
import d3Array from 'd3-array';
import d3Format from 'd3-format';
import d3TimeFormat from 'd3-time-format';
import styleString from 'nomad-ui/utils/properties/glimmer-style-string';
import uniquely from 'nomad-ui/utils/properties/uniquely';

// Returns a new array with the specified number of points linearly
// distributed across the bounds
const lerp = ([low, high], numPoints) => {
  const step = (high - low) / (numPoints - 1);
  const arr = [];
  for (var i = 0; i < numPoints; i++) {
    arr.push(low + step * i);
  }
  return arr;
};

// Round a number or an array of numbers
const nice = (val) => (val instanceof Array ? val.map(nice) : Math.round(val));

const defaultXScale = (data, yAxisOffset, xProp, timeseries) => {
  const scale = timeseries ? d3Scale.scaleTime() : d3Scale.scaleLinear();
  const domain = data.length ? d3Array.extent(data, (d) => d[xProp]) : [0, 1];

  scale.rangeRound([10, yAxisOffset]).domain(domain);

  return scale;
};

const defaultYScale = (data, xAxisOffset, yProp) => {
  let max = d3Array.max(data, (d) => d[yProp]) || 1;
  if (max > 1) {
    max = nice(max);
  }

  return d3Scale.scaleLinear().rangeRound([xAxisOffset, 10]).domain([0, max]);
};

export default class LineChart extends Component {
  /** Args
    data = null;
    xProp = null;
    yProp = null;
    curve = 'linear';
    title = 'Line Chart';
    description = null;
    timeseries = false;
    activeAnnotation = null;
    onAnnotationClick() {}
    xFormat;
    yFormat;
    xScale;
    yScale;
  */

  @tracked width = 0;
  @tracked height = 0;
  @tracked isActive = false;
  @tracked activeDatum = null;
  @tracked activeData = [];
  @tracked tooltipPosition = null;
  @tracked element = null;
  @tracked ready = false;

  @uniquely('title') titleId;
  @uniquely('desc') descriptionId;

  get xProp() {
    return this.args.xProp || 'time';
  }
  get yProp() {
    return this.args.yProp || 'value';
  }
  get data() {
    if (!this.args.data) return [];
    if (this.args.dataProp) {
      return this.args.data.mapBy(this.args.dataProp).flat();
    }
    return this.args.data;
  }
  get curve() {
    return this.args.curve || 'linear';
  }

  @action
  xFormat(timeseries) {
    if (this.args.xFormat) return this.args.xFormat;
    return timeseries
      ? d3TimeFormat.timeFormat('%b %d, %H:%M')
      : d3Format.format(',');
  }

  @action
  yFormat() {
    if (this.args.yFormat) return this.args.yFormat;
    return d3Format.format(',.2~r');
  }

  get activeDatumLabel() {
    const datum = this.activeDatum;

    if (!datum) return undefined;

    const x = datum[this.xProp];
    return this.xFormat(this.args.timeseries)(x);
  }

  get activeDatumValue() {
    const datum = this.activeDatum;

    if (!datum) return undefined;

    const y = datum[this.yProp];
    return this.yFormat()(y);
  }

  @styleString
  get tooltipStyle() {
    return this.tooltipPosition;
  }

  get xScale() {
    const fn = this.args.xScale || defaultXScale;
    return fn(this.data, this.yAxisOffset, this.xProp, this.args.timeseries);
  }

  get xRange() {
    const { xProp, data } = this;
    const range = d3Array.extent(data, (d) => d[xProp]);
    const formatter = this.xFormat(this.args.timeseries);

    return range.map(formatter);
  }

  get yRange() {
    const yProp = this.yProp;
    const range = d3Array.extent(this.data, (d) => d[yProp]);
    const formatter = this.yFormat();

    return range.map(formatter);
  }

  get yScale() {
    const fn = this.args.yScale || defaultYScale;
    return fn(this.data, this.xAxisOffset, this.yProp);
  }

  get xAxis() {
    const formatter = this.xFormat(this.args.timeseries);

    return d3Axis
      .axisBottom()
      .scale(this.xScale)
      .ticks(5)
      .tickFormat(formatter);
  }

  get yTicks() {
    const height = this.xAxisOffset;
    const tickCount = Math.ceil(height / 120) * 2 + 1;
    const domain = this.yScale.domain();
    const ticks = lerp(domain, tickCount);
    return domain[1] - domain[0] > 1 ? nice(ticks) : ticks;
  }

  get yAxis() {
    const formatter = this.yFormat();

    return d3Axis
      .axisRight()
      .scale(this.yScale)
      .tickValues(this.yTicks)
      .tickFormat(formatter);
  }

  get yGridlines() {
    // The first gridline overlaps the x-axis, so remove it
    const [, ...ticks] = this.yTicks;

    return d3Axis
      .axisRight()
      .scale(this.yScale)
      .tickValues(ticks)
      .tickSize(-this.canvasDimensions.width)
      .tickFormat('');
  }

  get xAxisHeight() {
    // Avoid divide by zero errors by always having a height
    if (!this.element) return 1;

    const axis = this.element.querySelector('.x-axis');
    return axis && axis.getBBox().height;
  }

  get yAxisWidth() {
    // Avoid divide by zero errors by always having a width
    if (!this.element) return 1;

    const axis = this.element.querySelector('.y-axis');
    return axis && axis.getBBox().width;
  }

  get xAxisOffset() {
    return Math.max(0, this.height - this.xAxisHeight);
  }

  get yAxisOffset() {
    return Math.max(0, this.width - this.yAxisWidth);
  }

  get canvasDimensions() {
    const [left, right] = this.xScale.range();
    const [top, bottom] = this.yScale.range();
    return { left, width: right - left, top, height: bottom - top };
  }

  @action
  onInsert(element) {
    this.element = element;
    this.updateDimensions();

    const canvas = d3.select(this.element.querySelector('.hover-target'));
    const updateActiveDatum = this.updateActiveDatum.bind(this);

    const chart = this;
    canvas.on('mouseenter', function (ev) {
      const mouseX = d3.pointer(ev, this)[0];
      chart.latestMouseX = mouseX;
      updateActiveDatum(mouseX);
      schedule('afterRender', chart, () => (chart.isActive = true));
    });

    canvas.on('mousemove', function (ev) {
      const mouseX = d3.pointer(ev, this)[0];
      chart.latestMouseX = mouseX;
      updateActiveDatum(mouseX);
    });

    canvas.on('mouseleave', () => {
      schedule('afterRender', this, () => (this.isActive = false));
      this.activeDatum = null;
      this.activeData = [];
    });
  }

  updateActiveDatum(mouseX) {
    if (!this.data || !this.data.length) return;

    const { xScale, xProp, yScale, yProp } = this;
    let { dataProp, data } = this.args;

    if (!dataProp) {
      dataProp = 'data';
      data = [{ data: this.data }];
    }

    // Map screen coordinates to data domain
    const bisector = d3Array.bisector((d) => d[xProp]).left;
    const x = xScale.invert(mouseX);

    // Find the closest datum to the cursor for each series
    const activeData = data
      .map((series, seriesIndex) => {
        const dataset = series[dataProp];

        // If the dataset is empty, there can't be an activeData.
        // This must be done here instead of preemptively in a filter to
        // preserve the seriesIndex value.
        if (!dataset.length) return null;

        const index = bisector(dataset, x, 1);

        // The data point on either side of the cursor
        const dLeft = dataset[index - 1];
        const dRight = dataset[index];

        let datum;

        // If there is only one point, it's the activeDatum
        if (dLeft && !dRight) {
          datum = dLeft;
        } else {
          // Pick the closer point
          datum = x - dLeft[xProp] > dRight[xProp] - x ? dRight : dLeft;
        }

        return {
          series,
          datum: {
            formattedX: this.xFormat(this.args.timeseries)(datum[xProp]),
            formattedY: this.yFormat()(datum[yProp]),
            datum,
          },
          index: data.length - seriesIndex - 1,
        };
      })
      .compact();

    // Of the selected data, determine which is closest
    const closestDatum = activeData
      .slice()
      .sort(
        (a, b) =>
          Math.abs(a.datum.datum[xProp] - x) -
          Math.abs(b.datum.datum[xProp] - x)
      )[0];

    // If any other selected data are beyond a distance threshold, drop them from the list
    // xScale is used here to measure distance in screen-space rather than data-space.
    const dist = Math.abs(xScale(closestDatum.datum.datum[xProp]) - mouseX);
    const filteredData = activeData.filter(
      (d) => Math.abs(xScale(d.datum.datum[xProp]) - mouseX) < dist + 10
    );

    this.activeData = filteredData;
    this.activeDatum = closestDatum.datum.datum;
    this.tooltipPosition = {
      left: xScale(this.activeDatum[xProp]),
      top: yScale(this.activeDatum[yProp]) - 10,
    };
  }

  // The renderChart method should only ever be responsible for runtime calculations
  // and appending d3 created elements to the DOM (such as axes).
  renderChart() {
    // There is nothing to do if the element hasn't been inserted yet
    if (!this.element) return;

    // Create the axes to get the dimensions of the resulting
    // svg elements
    this.mountD3Elements();

    next(() => {
      // Since each axis depends on the dimension of the other
      // axis, the axes themselves are recomputed and need to
      // be re-rendered.
      this.mountD3Elements();
      this.ready = true;
      if (this.isActive) {
        this.updateActiveDatum(this.latestMouseX);
      }
    });
  }

  @action
  recomputeXAxis(el) {
    if (!this.isDestroyed && !this.isDestroying) {
      d3.select(el.querySelector('.x-axis')).call(this.xAxis);
    }
  }

  @action
  recomputeYAxis(el) {
    if (!this.isDestroyed && !this.isDestroying) {
      d3.select(el.querySelector('.y-axis')).call(this.yAxis);
    }
  }

  mountD3Elements() {
    if (!this.isDestroyed && !this.isDestroying) {
      d3.select(this.element.querySelector('.x-axis')).call(this.xAxis);
      d3.select(this.element.querySelector('.y-axis')).call(this.yAxis);
      d3.select(this.element.querySelector('.y-gridlines')).call(
        this.yGridlines
      );
    }
  }

  annotationClick(annotation) {
    this.args.onAnnotationClick && this.args.onAnnotationClick(annotation);
  }

  @action
  updateDimensions() {
    const $svg = this.element.querySelector('svg');

    this.height = $svg.clientHeight;
    this.width = $svg.clientWidth;
    this.renderChart();
  }
}
