import animator from './core.animator';
import defaults, {overrides} from './core.defaults';
import Interaction from './core.interaction';
import layouts from './core.layouts';
import {_detectPlatform} from '../platform';
import PluginService from './core.plugins';
import registry from './core.registry';
import Config, {determineAxis, getIndexAxis} from './core.config';
import {retinaScale, _isDomSupported} from '../helpers/helpers.dom';
import {each, callback as callCallback, uid, valueOrDefault, _elementsEqual, isNullOrUndef, setsEqual, defined, isFunction, _isClickEvent} from '../helpers/helpers.core';
import {clearCanvas, clipArea, createContext, unclipArea, _isPointInArea} from '../helpers';
// @ts-ignore
import {version} from '../../package.json';
import {debounce} from '../helpers/helpers.extras';

/**
 * @typedef { import('../../types/index.esm').ChartEvent } ChartEvent
 */

const KNOWN_POSITIONS = ['top', 'bottom', 'left', 'right', 'chartArea'];
function positionIsHorizontal(position, axis) {
  return position === 'top' || position === 'bottom' || (KNOWN_POSITIONS.indexOf(position) === -1 && axis === 'x');
}

function compare2Level(l1, l2) {
  return function(a, b) {
    return a[l1] === b[l1]
      ? a[l2] - b[l2]
      : a[l1] - b[l1];
  };
}

function onAnimationsComplete(context) {
  const chart = context.chart;
  const animationOptions = chart.options.animation;

  chart.notifyPlugins('afterRender');
  callCallback(animationOptions && animationOptions.onComplete, [context], chart);
}

function onAnimationProgress(context) {
  const chart = context.chart;
  const animationOptions = chart.options.animation;
  callCallback(animationOptions && animationOptions.onProgress, [context], chart);
}

/**
 * Chart.js can take a string id of a canvas element, a 2d context, or a canvas element itself.
 * Attempt to unwrap the item passed into the chart constructor so that it is a canvas element (if possible).
 */
function getCanvas(item) {
  if (_isDomSupported() && typeof item === 'string') {
    item = document.getElementById(item);
  } else if (item && item.length) {
    // Support for array based queries (such as jQuery)
    item = item[0];
  }

  if (item && item.canvas) {
    // Support for any object associated to a canvas (including a context2d)
    item = item.canvas;
  }
  return item;
}

const instances = {};
const getChart = (key) => {
  const canvas = getCanvas(key);
  return Object.values(instances).filter((c) => c.canvas === canvas).pop();
};

function moveNumericKeys(obj, start, move) {
  const keys = Object.keys(obj);
  for (const key of keys) {
    const intKey = +key;
    if (intKey >= start) {
      const value = obj[key];
      delete obj[key];
      if (move > 0 || intKey > start) {
        obj[intKey + move] = value;
      }
    }
  }
}

/**
 * @param {ChartEvent} e
 * @param {ChartEvent|null} lastEvent
 * @param {boolean} inChartArea
 * @param {boolean} isClick
 * @returns {ChartEvent|null}
 */
function determineLastEvent(e, lastEvent, inChartArea, isClick) {
  if (!inChartArea || e.type === 'mouseout') {
    return null;
  }
  if (isClick) {
    return lastEvent;
  }
  return e;
}

class Chart {

  // eslint-disable-next-line max-statements
  constructor(item, userConfig) {
    const config = this.config = new Config(userConfig);
    const initialCanvas = getCanvas(item);
    const existingChart = getChart(initialCanvas);
    if (existingChart) {
      throw new Error(
        'Canvas is already in use. Chart with ID \'' + existingChart.id + '\'' +
				' must be destroyed before the canvas can be reused.'
      );
    }

    const options = config.createResolver(config.chartOptionScopes(), this.getContext());

    this.platform = new (config.platform || _detectPlatform(initialCanvas))();
    this.platform.updateConfig(config);

    const context = this.platform.acquireContext(initialCanvas, options.aspectRatio);
    const canvas = context && context.canvas;
    const height = canvas && canvas.height;
    const width = canvas && canvas.width;

    this.id = uid();
    this.ctx = context;
    this.canvas = canvas;
    this.width = width;
    this.height = height;
    this._options = options;
    // Store the previously used aspect ratio to determine if a resize
    // is needed during updates. Do this after _options is set since
    // aspectRatio uses a getter
    this._aspectRatio = this.aspectRatio;
    this._layers = [];
    this._metasets = [];
    this._stacks = undefined;
    this.boxes = [];
    this.currentDevicePixelRatio = undefined;
    this.chartArea = undefined;
    this._active = [];
    this._lastEvent = undefined;
    this._listeners = {};
    /** @type {?{attach?: function, detach?: function, resize?: function}} */
    this._responsiveListeners = undefined;
    this._sortedMetasets = [];
    this.scales = {};
    this._plugins = new PluginService();
    this.$proxies = {};
    this._hiddenIndices = {};
    this.attached = false;
    this._animationsDisabled = undefined;
    this.$context = undefined;
    this._doResize = debounce(mode => this.update(mode), options.resizeDelay || 0);
    this._dataChanges = [];

    // Add the chart instance to the global namespace
    instances[this.id] = this;

    if (!context || !canvas) {
      // The given item is not a compatible context2d element, let's return before finalizing
      // the chart initialization but after setting basic chart / controller properties that
      // can help to figure out that the chart is not valid (e.g chart.canvas !== null);
      // https://github.com/chartjs/Chart.js/issues/2807
      console.error("Failed to create chart: can't acquire context from the given item");
      return;
    }

    animator.listen(this, 'complete', onAnimationsComplete);
    animator.listen(this, 'progress', onAnimationProgress);

    this._initialize();
    if (this.attached) {
      this.update();
    }
  }

  get aspectRatio() {
    const {options: {aspectRatio, maintainAspectRatio}, width, height, _aspectRatio} = this;
    if (!isNullOrUndef(aspectRatio)) {
      // If aspectRatio is defined in options, use that.
      return aspectRatio;
    }

    if (maintainAspectRatio && _aspectRatio) {
      // If maintainAspectRatio is truthly and we had previously determined _aspectRatio, use that
      return _aspectRatio;
    }

    // Calculate
    return height ? width / height : null;
  }

  get data() {
    return this.config.data;
  }

  set data(data) {
    this.config.data = data;
  }

  get options() {
    return this._options;
  }

  set options(options) {
    this.config.options = options;
  }

  /**
	 * @private
	 */
  _initialize() {
    // Before init plugin notification
    this.notifyPlugins('beforeInit');

    if (this.options.responsive) {
      this.resize();
    } else {
      retinaScale(this, this.options.devicePixelRatio);
    }

    this.bindEvents();

    // After init plugin notification
    this.notifyPlugins('afterInit');

    return this;
  }

  clear() {
    clearCanvas(this.canvas, this.ctx);
    return this;
  }

  stop() {
    animator.stop(this);
    return this;
  }

  /**
	 * Resize the chart to its container or to explicit dimensions.
	 * @param {number} [width]
	 * @param {number} [height]
	 */
  resize(width, height) {
    if (!animator.running(this)) {
      this._resize(width, height);
    } else {
      this._resizeBeforeDraw = {width, height};
    }
  }

  _resize(width, height) {
    const options = this.options;
    const canvas = this.canvas;
    const aspectRatio = options.maintainAspectRatio && this.aspectRatio;
    const newSize = this.platform.getMaximumSize(canvas, width, height, aspectRatio);
    const newRatio = options.devicePixelRatio || this.platform.getDevicePixelRatio();
    const mode = this.width ? 'resize' : 'attach';

    this.width = newSize.width;
    this.height = newSize.height;
    this._aspectRatio = this.aspectRatio;
    if (!retinaScale(this, newRatio, true)) {
      return;
    }

    this.notifyPlugins('resize', {size: newSize});

    callCallback(options.onResize, [this, newSize], this);

    if (this.attached) {
      if (this._doResize(mode)) {
        // The resize update is delayed, only draw without updating.
        this.render();
      }
    }
  }

  ensureScalesHaveIDs() {
    const options = this.options;
    const scalesOptions = options.scales || {};

    each(scalesOptions, (axisOptions, axisID) => {
      axisOptions.id = axisID;
    });
  }

  /**
	 * Builds a map of scale ID to scale object for future lookup.
	 */
  buildOrUpdateScales() {
    const options = this.options;
    const scaleOpts = options.scales;
    const scales = this.scales;
    const updated = Object.keys(scales).reduce((obj, id) => {
      obj[id] = false;
      return obj;
    }, {});
    let items = [];

    if (scaleOpts) {
      items = items.concat(
        Object.keys(scaleOpts).map((id) => {
          const scaleOptions = scaleOpts[id];
          const axis = determineAxis(id, scaleOptions);
          const isRadial = axis === 'r';
          const isHorizontal = axis === 'x';
          return {
            options: scaleOptions,
            dposition: isRadial ? 'chartArea' : isHorizontal ? 'bottom' : 'left',
            dtype: isRadial ? 'radialLinear' : isHorizontal ? 'category' : 'linear'
          };
        })
      );
    }

    each(items, (item) => {
      const scaleOptions = item.options;
      const id = scaleOptions.id;
      const axis = determineAxis(id, scaleOptions);
      const scaleType = valueOrDefault(scaleOptions.type, item.dtype);

      if (scaleOptions.position === undefined || positionIsHorizontal(scaleOptions.position, axis) !== positionIsHorizontal(item.dposition)) {
        scaleOptions.position = item.dposition;
      }

      updated[id] = true;
      let scale = null;
      if (id in scales && scales[id].type === scaleType) {
        scale = scales[id];
      } else {
        const scaleClass = registry.getScale(scaleType);
        scale = new scaleClass({
          id,
          type: scaleType,
          ctx: this.ctx,
          chart: this
        });
        scales[scale.id] = scale;
      }

      scale.init(scaleOptions, options);
    });
    // clear up discarded scales
    each(updated, (hasUpdated, id) => {
      if (!hasUpdated) {
        delete scales[id];
      }
    });

    each(scales, (scale) => {
      layouts.configure(this, scale, scale.options);
      layouts.addBox(this, scale);
    });
  }

  /**
	 * @private
	 */
  _updateMetasets() {
    const metasets = this._metasets;
    const numData = this.data.datasets.length;
    const numMeta = metasets.length;

    metasets.sort((a, b) => a.index - b.index);
    if (numMeta > numData) {
      for (let i = numData; i < numMeta; ++i) {
        this._destroyDatasetMeta(i);
      }
      metasets.splice(numData, numMeta - numData);
    }
    this._sortedMetasets = metasets.slice(0).sort(compare2Level('order', 'index'));
  }

  /**
	 * @private
	 */
  _removeUnreferencedMetasets() {
    const {_metasets: metasets, data: {datasets}} = this;
    if (metasets.length > datasets.length) {
      delete this._stacks;
    }
    metasets.forEach((meta, index) => {
      if (datasets.filter(x => x === meta._dataset).length === 0) {
        this._destroyDatasetMeta(index);
      }
    });
  }

  buildOrUpdateControllers() {
    const newControllers = [];
    const datasets = this.data.datasets;
    let i, ilen;

    this._removeUnreferencedMetasets();

    for (i = 0, ilen = datasets.length; i < ilen; i++) {
      const dataset = datasets[i];
      let meta = this.getDatasetMeta(i);
      const type = dataset.type || this.config.type;

      if (meta.type && meta.type !== type) {
        this._destroyDatasetMeta(i);
        meta = this.getDatasetMeta(i);
      }
      meta.type = type;
      meta.indexAxis = dataset.indexAxis || getIndexAxis(type, this.options);
      meta.order = dataset.order || 0;
      meta.index = i;
      meta.label = '' + dataset.label;
      meta.visible = this.isDatasetVisible(i);

      if (meta.controller) {
        meta.controller.updateIndex(i);
        meta.controller.linkScales();
      } else {
        const ControllerClass = registry.getController(type);
        const {datasetElementType, dataElementType} = defaults.datasets[type];
        Object.assign(ControllerClass.prototype, {
          dataElementType: registry.getElement(dataElementType),
          datasetElementType: datasetElementType && registry.getElement(datasetElementType)
        });
        meta.controller = new ControllerClass(this, i);
        newControllers.push(meta.controller);
      }
    }

    this._updateMetasets();
    return newControllers;
  }

  /**
	 * Reset the elements of all datasets
	 * @private
	 */
  _resetElements() {
    each(this.data.datasets, (dataset, datasetIndex) => {
      this.getDatasetMeta(datasetIndex).controller.reset();
    }, this);
  }

  /**
	* Resets the chart back to its state before the initial animation
	*/
  reset() {
    this._resetElements();
    this.notifyPlugins('reset');
  }

  update(mode) {
    const config = this.config;

    config.update();
    const options = this._options = config.createResolver(config.chartOptionScopes(), this.getContext());
    const animsDisabled = this._animationsDisabled = !options.animation;

    this._updateScales();
    this._checkEventBindings();
    this._updateHiddenIndices();

    // plugins options references might have change, let's invalidate the cache
    // https://github.com/chartjs/Chart.js/issues/5111#issuecomment-355934167
    this._plugins.invalidate();

    if (this.notifyPlugins('beforeUpdate', {mode, cancelable: true}) === false) {
      return;
    }

    // Make sure dataset controllers are updated and new controllers are reset
    const newControllers = this.buildOrUpdateControllers();

    this.notifyPlugins('beforeElementsUpdate');

    // Make sure all dataset controllers have correct meta data counts
    let minPadding = 0;
    for (let i = 0, ilen = this.data.datasets.length; i < ilen; i++) {
      const {controller} = this.getDatasetMeta(i);
      const reset = !animsDisabled && newControllers.indexOf(controller) === -1;
      // New controllers will be reset after the layout pass, so we only want to modify
      // elements added to new datasets
      controller.buildOrUpdateElements(reset);
      minPadding = Math.max(+controller.getMaxOverflow(), minPadding);
    }
    minPadding = this._minPadding = options.layout.autoPadding ? minPadding : 0;
    this._updateLayout(minPadding);

    // Only reset the controllers if we have animations
    if (!animsDisabled) {
      // Can only reset the new controllers after the scales have been updated
      // Reset is done to get the starting point for the initial animation
      each(newControllers, (controller) => {
        controller.reset();
      });
    }

    this._updateDatasets(mode);

    // Do this before render so that any plugins that need final scale updates can use it
    this.notifyPlugins('afterUpdate', {mode});

    this._layers.sort(compare2Level('z', '_idx'));

    // Replay last event from before update, or set hover styles on active elements
    const {_active, _lastEvent} = this;
    if (_lastEvent) {
      this._eventHandler(_lastEvent, true);
    } else if (_active.length) {
      this._updateHoverStyles(_active, _active, true);
    }

    this.render();
  }

  /**
   * @private
   */
  _updateScales() {
    each(this.scales, (scale) => {
      layouts.removeBox(this, scale);
    });

    this.ensureScalesHaveIDs();
    this.buildOrUpdateScales();
  }

  /**
   * @private
   */
  _checkEventBindings() {
    const options = this.options;
    const existingEvents = new Set(Object.keys(this._listeners));
    const newEvents = new Set(options.events);

    if (!setsEqual(existingEvents, newEvents) || !!this._responsiveListeners !== options.responsive) {
      // The configured events have changed. Rebind.
      this.unbindEvents();
      this.bindEvents();
    }
  }

  /**
   * @private
   */
  _updateHiddenIndices() {
    const {_hiddenIndices} = this;
    const changes = this._getUniformDataChanges() || [];
    for (const {method, start, count} of changes) {
      const move = method === '_removeElements' ? -count : count;
      moveNumericKeys(_hiddenIndices, start, move);
    }
  }

  /**
   * @private
   */
  _getUniformDataChanges() {
    const _dataChanges = this._dataChanges;
    if (!_dataChanges || !_dataChanges.length) {
      return;
    }

    this._dataChanges = [];
    const datasetCount = this.data.datasets.length;
    const makeSet = (idx) => new Set(
      _dataChanges
        .filter(c => c[0] === idx)
        .map((c, i) => i + ',' + c.splice(1).join(','))
    );

    const changeSet = makeSet(0);
    for (let i = 1; i < datasetCount; i++) {
      if (!setsEqual(changeSet, makeSet(i))) {
        return;
      }
    }
    return Array.from(changeSet)
      .map(c => c.split(','))
      .map(a => ({method: a[1], start: +a[2], count: +a[3]}));
  }

  /**
	 * Updates the chart layout unless a plugin returns `false` to the `beforeLayout`
	 * hook, in which case, plugins will not be called on `afterLayout`.
	 * @private
	 */
  _updateLayout(minPadding) {
    if (this.notifyPlugins('beforeLayout', {cancelable: true}) === false) {
      return;
    }

    layouts.update(this, this.width, this.height, minPadding);

    const area = this.chartArea;
    const noArea = area.width <= 0 || area.height <= 0;

    this._layers = [];
    each(this.boxes, (box) => {
      if (noArea && box.position === 'chartArea') {
        // Skip drawing and configuring chartArea boxes when chartArea is zero or negative
        return;
      }

      // configure is called twice, once in core.scale.update and once here.
      // Here the boxes are fully updated and at their final positions.
      if (box.configure) {
        box.configure();
      }
      this._layers.push(...box._layers());
    }, this);

    this._layers.forEach((item, index) => {
      item._idx = index;
    });

    this.notifyPlugins('afterLayout');
  }

  /**
	 * Updates all datasets unless a plugin returns `false` to the `beforeDatasetsUpdate`
	 * hook, in which case, plugins will not be called on `afterDatasetsUpdate`.
	 * @private
	 */
  _updateDatasets(mode) {
    if (this.notifyPlugins('beforeDatasetsUpdate', {mode, cancelable: true}) === false) {
      return;
    }

    for (let i = 0, ilen = this.data.datasets.length; i < ilen; ++i) {
      this.getDatasetMeta(i).controller.configure();
    }

    for (let i = 0, ilen = this.data.datasets.length; i < ilen; ++i) {
      this._updateDataset(i, isFunction(mode) ? mode({datasetIndex: i}) : mode);
    }

    this.notifyPlugins('afterDatasetsUpdate', {mode});
  }

  /**
	 * Updates dataset at index unless a plugin returns `false` to the `beforeDatasetUpdate`
	 * hook, in which case, plugins will not be called on `afterDatasetUpdate`.
	 * @private
	 */
  _updateDataset(index, mode) {
    const meta = this.getDatasetMeta(index);
    const args = {meta, index, mode, cancelable: true};

    if (this.notifyPlugins('beforeDatasetUpdate', args) === false) {
      return;
    }

    meta.controller._update(mode);

    args.cancelable = false;
    this.notifyPlugins('afterDatasetUpdate', args);
  }

  render() {
    if (this.notifyPlugins('beforeRender', {cancelable: true}) === false) {
      return;
    }

    if (animator.has(this)) {
      if (this.attached && !animator.running(this)) {
        animator.start(this);
      }
    } else {
      this.draw();
      onAnimationsComplete({chart: this});
    }
  }

  draw() {
    let i;
    if (this._resizeBeforeDraw) {
      const {width, height} = this._resizeBeforeDraw;
      this._resize(width, height);
      this._resizeBeforeDraw = null;
    }
    this.clear();

    if (this.width <= 0 || this.height <= 0) {
      return;
    }

    if (this.notifyPlugins('beforeDraw', {cancelable: true}) === false) {
      return;
    }

    // Because of plugin hooks (before/afterDatasetsDraw), datasets can't
    // currently be part of layers. Instead, we draw
    // layers <= 0 before(default, backward compat), and the rest after
    const layers = this._layers;
    for (i = 0; i < layers.length && layers[i].z <= 0; ++i) {
      layers[i].draw(this.chartArea);
    }

    this._drawDatasets();

    // Rest of layers
    for (; i < layers.length; ++i) {
      layers[i].draw(this.chartArea);
    }

    this.notifyPlugins('afterDraw');
  }

  /**
	 * @private
	 */
  _getSortedDatasetMetas(filterVisible) {
    const metasets = this._sortedMetasets;
    const result = [];
    let i, ilen;

    for (i = 0, ilen = metasets.length; i < ilen; ++i) {
      const meta = metasets[i];
      if (!filterVisible || meta.visible) {
        result.push(meta);
      }
    }

    return result;
  }

  /**
	 * Gets the visible dataset metas in drawing order
	 * @return {object[]}
	 */
  getSortedVisibleDatasetMetas() {
    return this._getSortedDatasetMetas(true);
  }

  /**
	 * Draws all datasets unless a plugin returns `false` to the `beforeDatasetsDraw`
	 * hook, in which case, plugins will not be called on `afterDatasetsDraw`.
	 * @private
	 */
  _drawDatasets() {
    if (this.notifyPlugins('beforeDatasetsDraw', {cancelable: true}) === false) {
      return;
    }

    const metasets = this.getSortedVisibleDatasetMetas();
    for (let i = metasets.length - 1; i >= 0; --i) {
      this._drawDataset(metasets[i]);
    }

    this.notifyPlugins('afterDatasetsDraw');
  }

  /**
	 * Draws dataset at index unless a plugin returns `false` to the `beforeDatasetDraw`
	 * hook, in which case, plugins will not be called on `afterDatasetDraw`.
	 * @private
	 */
  _drawDataset(meta) {
    const ctx = this.ctx;
    const clip = meta._clip;
    const useClip = !clip.disabled;
    const area = this.chartArea;
    const args = {
      meta,
      index: meta.index,
      cancelable: true
    };

    if (this.notifyPlugins('beforeDatasetDraw', args) === false) {
      return;
    }

    if (useClip) {
      clipArea(ctx, {
        left: clip.left === false ? 0 : area.left - clip.left,
        right: clip.right === false ? this.width : area.right + clip.right,
        top: clip.top === false ? 0 : area.top - clip.top,
        bottom: clip.bottom === false ? this.height : area.bottom + clip.bottom
      });
    }

    meta.controller.draw();

    if (useClip) {
      unclipArea(ctx);
    }

    args.cancelable = false;
    this.notifyPlugins('afterDatasetDraw', args);
  }

  getElementsAtEventForMode(e, mode, options, useFinalPosition) {
    const method = Interaction.modes[mode];
    if (typeof method === 'function') {
      return method(this, e, options, useFinalPosition);
    }

    return [];
  }

  getDatasetMeta(datasetIndex) {
    const dataset = this.data.datasets[datasetIndex];
    const metasets = this._metasets;
    let meta = metasets.filter(x => x && x._dataset === dataset).pop();

    if (!meta) {
      meta = {
        type: null,
        data: [],
        dataset: null,
        controller: null,
        hidden: null,			// See isDatasetVisible() comment
        xAxisID: null,
        yAxisID: null,
        order: dataset && dataset.order || 0,
        index: datasetIndex,
        _dataset: dataset,
        _parsed: [],
        _sorted: false
      };
      metasets.push(meta);
    }

    return meta;
  }

  getContext() {
    return this.$context || (this.$context = createContext(null, {chart: this, type: 'chart'}));
  }

  getVisibleDatasetCount() {
    return this.getSortedVisibleDatasetMetas().length;
  }

  isDatasetVisible(datasetIndex) {
    const dataset = this.data.datasets[datasetIndex];
    if (!dataset) {
      return false;
    }

    const meta = this.getDatasetMeta(datasetIndex);

    // meta.hidden is a per chart dataset hidden flag override with 3 states: if true or false,
    // the dataset.hidden value is ignored, else if null, the dataset hidden state is returned.
    return typeof meta.hidden === 'boolean' ? !meta.hidden : !dataset.hidden;
  }

  setDatasetVisibility(datasetIndex, visible) {
    const meta = this.getDatasetMeta(datasetIndex);
    meta.hidden = !visible;
  }

  toggleDataVisibility(index) {
    this._hiddenIndices[index] = !this._hiddenIndices[index];
  }

  getDataVisibility(index) {
    return !this._hiddenIndices[index];
  }

  /**
	 * @private
	 */
  _updateVisibility(datasetIndex, dataIndex, visible) {
    const mode = visible ? 'show' : 'hide';
    const meta = this.getDatasetMeta(datasetIndex);
    const anims = meta.controller._resolveAnimations(undefined, mode);

    if (defined(dataIndex)) {
      meta.data[dataIndex].hidden = !visible;
      this.update();
    } else {
      this.setDatasetVisibility(datasetIndex, visible);
      // Animate visible state, so hide animation can be seen. This could be handled better if update / updateDataset returned a Promise.
      anims.update(meta, {visible});
      this.update((ctx) => ctx.datasetIndex === datasetIndex ? mode : undefined);
    }
  }

  hide(datasetIndex, dataIndex) {
    this._updateVisibility(datasetIndex, dataIndex, false);
  }

  show(datasetIndex, dataIndex) {
    this._updateVisibility(datasetIndex, dataIndex, true);
  }

  /**
	 * @private
	 */
  _destroyDatasetMeta(datasetIndex) {
    const meta = this._metasets[datasetIndex];
    if (meta && meta.controller) {
      meta.controller._destroy();
    }
    delete this._metasets[datasetIndex];
  }

  _stop() {
    let i, ilen;
    this.stop();
    animator.remove(this);

    for (i = 0, ilen = this.data.datasets.length; i < ilen; ++i) {
      this._destroyDatasetMeta(i);
    }
  }

  destroy() {
    this.notifyPlugins('beforeDestroy');
    const {canvas, ctx} = this;

    this._stop();
    this.config.clearCache();

    if (canvas) {
      this.unbindEvents();
      clearCanvas(canvas, ctx);
      this.platform.releaseContext(ctx);
      this.canvas = null;
      this.ctx = null;
    }

    // TODO V4: delete destroy hook and reference to it in plugin flowchart
    this.notifyPlugins('destroy');

    delete instances[this.id];

    this.notifyPlugins('afterDestroy');
  }

  toBase64Image(...args) {
    return this.canvas.toDataURL(...args);
  }

  /**
	 * @private
	 */
  bindEvents() {
    this.bindUserEvents();
    if (this.options.responsive) {
      this.bindResponsiveEvents();
    } else {
      this.attached = true;
    }
  }

  /**
   * @private
   */
  bindUserEvents() {
    const listeners = this._listeners;
    const platform = this.platform;

    const _add = (type, listener) => {
      platform.addEventListener(this, type, listener);
      listeners[type] = listener;
    };

    const listener = (e, x, y) => {
      e.offsetX = x;
      e.offsetY = y;
      this._eventHandler(e);
    };

    each(this.options.events, (type) => _add(type, listener));
  }

  /**
   * @private
   */
  bindResponsiveEvents() {
    if (!this._responsiveListeners) {
      this._responsiveListeners = {};
    }
    const listeners = this._responsiveListeners;
    const platform = this.platform;

    const _add = (type, listener) => {
      platform.addEventListener(this, type, listener);
      listeners[type] = listener;
    };
    const _remove = (type, listener) => {
      if (listeners[type]) {
        platform.removeEventListener(this, type, listener);
        delete listeners[type];
      }
    };

    const listener = (width, height) => {
      if (this.canvas) {
        this.resize(width, height);
      }
    };

    let detached; // eslint-disable-line prefer-const
    const attached = () => {
      _remove('attach', attached);

      this.attached = true;
      this.resize();

      _add('resize', listener);
      _add('detach', detached);
    };

    detached = () => {
      this.attached = false;

      _remove('resize', listener);

      // Stop animating and remove metasets, so when re-attached, the animations start from beginning.
      this._stop();
      this._resize(0, 0);

      _add('attach', attached);
    };

    if (platform.isAttached(this.canvas)) {
      attached();
    } else {
      detached();
    }
  }

  /**
	 * @private
	 */
  unbindEvents() {
    each(this._listeners, (listener, type) => {
      this.platform.removeEventListener(this, type, listener);
    });
    this._listeners = {};

    each(this._responsiveListeners, (listener, type) => {
      this.platform.removeEventListener(this, type, listener);
    });
    this._responsiveListeners = undefined;
  }

  updateHoverStyle(items, mode, enabled) {
    const prefix = enabled ? 'set' : 'remove';
    let meta, item, i, ilen;

    if (mode === 'dataset') {
      meta = this.getDatasetMeta(items[0].datasetIndex);
      meta.controller['_' + prefix + 'DatasetHoverStyle']();
    }

    for (i = 0, ilen = items.length; i < ilen; ++i) {
      item = items[i];
      const controller = item && this.getDatasetMeta(item.datasetIndex).controller;
      if (controller) {
        controller[prefix + 'HoverStyle'](item.element, item.datasetIndex, item.index);
      }
    }
  }

  /**
	 * Get active (hovered) elements
	 * @returns array
	 */
  getActiveElements() {
    return this._active || [];
  }

  /**
	 * Set active (hovered) elements
	 * @param {array} activeElements New active data points
	 */
  setActiveElements(activeElements) {
    const lastActive = this._active || [];
    const active = activeElements.map(({datasetIndex, index}) => {
      const meta = this.getDatasetMeta(datasetIndex);
      if (!meta) {
        throw new Error('No dataset found at index ' + datasetIndex);
      }

      return {
        datasetIndex,
        element: meta.data[index],
        index,
      };
    });
    const changed = !_elementsEqual(active, lastActive);

    if (changed) {
      this._active = active;
      // Make sure we don't use the previous mouse event to override the active elements in update.
      this._lastEvent = null;
      this._updateHoverStyles(active, lastActive);
    }
  }

  /**
	 * Calls enabled plugins on the specified hook and with the given args.
	 * This method immediately returns as soon as a plugin explicitly returns false. The
	 * returned value can be used, for instance, to interrupt the current action.
	 * @param {string} hook - The name of the plugin method to call (e.g. 'beforeUpdate').
	 * @param {Object} [args] - Extra arguments to apply to the hook call.
   * @param {import('./core.plugins').filterCallback} [filter] - Filtering function for limiting which plugins are notified
	 * @returns {boolean} false if any of the plugins return false, else returns true.
	 */
  notifyPlugins(hook, args, filter) {
    return this._plugins.notify(this, hook, args, filter);
  }

  /**
	 * @private
	 */
  _updateHoverStyles(active, lastActive, replay) {
    const hoverOptions = this.options.hover;
    const diff = (a, b) => a.filter(x => !b.some(y => x.datasetIndex === y.datasetIndex && x.index === y.index));
    const deactivated = diff(lastActive, active);
    const activated = replay ? active : diff(active, lastActive);

    if (deactivated.length) {
      this.updateHoverStyle(deactivated, hoverOptions.mode, false);
    }

    if (activated.length && hoverOptions.mode) {
      this.updateHoverStyle(activated, hoverOptions.mode, true);
    }
  }

  /**
	 * @private
	 */
  _eventHandler(e, replay) {
    const args = {
      event: e,
      replay,
      cancelable: true,
      inChartArea: _isPointInArea(e, this.chartArea, this._minPadding)
    };
    const eventFilter = (plugin) => (plugin.options.events || this.options.events).includes(e.native.type);

    if (this.notifyPlugins('beforeEvent', args, eventFilter) === false) {
      return;
    }

    const changed = this._handleEvent(e, replay, args.inChartArea);

    args.cancelable = false;
    this.notifyPlugins('afterEvent', args, eventFilter);

    if (changed || args.changed) {
      this.render();
    }

    return this;
  }

  /**
	 * Handle an event
	 * @param {ChartEvent} e the event to handle
	 * @param {boolean} [replay] - true if the event was replayed by `update`
   * @param {boolean} [inChartArea] - true if the event is inside chartArea
	 * @return {boolean} true if the chart needs to re-render
	 * @private
	 */
  _handleEvent(e, replay, inChartArea) {
    const {_active: lastActive = [], options} = this;

    // If the event is replayed from `update`, we should evaluate with the final positions.
    //
    // The `replay`:
    // It's the last event (excluding click) that has occurred before `update`.
    // So mouse has not moved. It's also over the chart, because there is a `replay`.
    //
    // The why:
    // If animations are active, the elements haven't moved yet compared to state before update.
    // But if they will, we are activating the elements that would be active, if this check
    // was done after the animations have completed. => "final positions".
    // If there is no animations, the "final" and "current" positions are equal.
    // This is done so we do not have to evaluate the active elements each animation frame
    // - it would be expensive.
    const useFinalPosition = replay;
    const active = this._getActiveElements(e, lastActive, inChartArea, useFinalPosition);
    const isClick = _isClickEvent(e);
    const lastEvent = determineLastEvent(e, this._lastEvent, inChartArea, isClick);

    if (inChartArea) {
      // Set _lastEvent to null while we are processing the event handlers.
      // This prevents recursion if the handler calls chart.update()
      this._lastEvent = null;

      // Invoke onHover hook
      callCallback(options.onHover, [e, active, this], this);

      if (isClick) {
        callCallback(options.onClick, [e, active, this], this);
      }
    }

    const changed = !_elementsEqual(active, lastActive);
    if (changed || replay) {
      this._active = active;
      this._updateHoverStyles(active, lastActive, replay);
    }

    this._lastEvent = lastEvent;

    return changed;
  }

  /**
   * @param {ChartEvent} e - The event
   * @param {import('../../types/index.esm').ActiveElement[]} lastActive - Previously active elements
   * @param {boolean} inChartArea - Is the envent inside chartArea
   * @param {boolean} useFinalPosition - Should the evaluation be done with current or final (after animation) element positions
   * @returns {import('../../types/index.esm').ActiveElement[]} - The active elements
   * @pravate
   */
  _getActiveElements(e, lastActive, inChartArea, useFinalPosition) {
    if (e.type === 'mouseout') {
      return [];
    }

    if (!inChartArea) {
      // Let user control the active elements outside chartArea. Eg. using Legend.
      return lastActive;
    }

    const hoverOptions = this.options.hover;
    return this.getElementsAtEventForMode(e, hoverOptions.mode, hoverOptions, useFinalPosition);
  }
}

// @ts-ignore
const invalidatePlugins = () => each(Chart.instances, (chart) => chart._plugins.invalidate());

const enumerable = true;

// These are available to both, UMD and ESM packages. Read Only!
Object.defineProperties(Chart, {
  defaults: {
    enumerable,
    value: defaults
  },
  instances: {
    enumerable,
    value: instances
  },
  overrides: {
    enumerable,
    value: overrides
  },
  registry: {
    enumerable,
    value: registry
  },
  version: {
    enumerable,
    value: version
  },
  getChart: {
    enumerable,
    value: getChart
  },
  register: {
    enumerable,
    value: (...items) => {
      registry.add(...items);
      invalidatePlugins();
    }
  },
  unregister: {
    enumerable,
    value: (...items) => {
      registry.remove(...items);
      invalidatePlugins();
    }
  }
});

export default Chart;
