import Animations from '../core/core.animations';
import defaults from '../core/core.defaults';
import Element from '../core/core.element';
import {valueOrDefault, each, noop, isNullOrUndef, isArray, _elementsEqual, merge} from '../helpers/helpers.core';
import {getRtlAdapter, overrideTextDirection, restoreTextDirection} from '../helpers/helpers.rtl';
import {distanceBetweenPoints} from '../helpers/helpers.math';
import {toFont} from '../helpers/helpers.options';

/**
 * @typedef { import("../platform/platform.base").IEvent } IEvent
 */

const positioners = {
	/**
	 * Average mode places the tooltip at the average position of the elements shown
	 * @function Chart.Tooltip.positioners.average
	 * @param items {object[]} the items being displayed in the tooltip
	 * @returns {object} tooltip position
	 */
	average(items) {
		if (!items.length) {
			return false;
		}

		let i, len;
		let x = 0;
		let y = 0;
		let count = 0;

		for (i = 0, len = items.length; i < len; ++i) {
			const el = items[i].element;
			if (el && el.hasValue()) {
				const pos = el.tooltipPosition();
				x += pos.x;
				y += pos.y;
				++count;
			}
		}

		return {
			x: x / count,
			y: y / count
		};
	},

	/**
	 * Gets the tooltip position nearest of the item nearest to the event position
	 * @function Chart.Tooltip.positioners.nearest
	 * @param items {object[]} the tooltip items
	 * @param eventPosition {object} the position of the event in canvas coordinates
	 * @returns {object} the tooltip position
	 */
	nearest(items, eventPosition) {
		let x = eventPosition.x;
		let y = eventPosition.y;
		let minDistance = Number.POSITIVE_INFINITY;
		let i, len, nearestElement;

		for (i = 0, len = items.length; i < len; ++i) {
			const el = items[i].element;
			if (el && el.hasValue()) {
				const center = el.getCenterPoint();
				const d = distanceBetweenPoints(eventPosition, center);

				if (d < minDistance) {
					minDistance = d;
					nearestElement = el;
				}
			}
		}

		if (nearestElement) {
			const tp = nearestElement.tooltipPosition();
			x = tp.x;
			y = tp.y;
		}

		return {
			x,
			y
		};
	}
};

// Helper to push or concat based on if the 2nd parameter is an array or not
function pushOrConcat(base, toPush) {
	if (toPush) {
		if (isArray(toPush)) {
			// base = base.concat(toPush);
			Array.prototype.push.apply(base, toPush);
		} else {
			base.push(toPush);
		}
	}

	return base;
}

/**
 * Returns array of strings split by newline
 * @param {*} str - The value to split by newline.
 * @returns {string|string[]} value if newline present - Returned from String split() method
 * @function
 */
function splitNewlines(str) {
	if ((typeof str === 'string' || str instanceof String) && str.indexOf('\n') > -1) {
		return str.split('\n');
	}
	return str;
}


/**
 * Private helper to create a tooltip item model
 * @param item - {element, index, datasetIndex} to create the tooltip item for
 * @return new tooltip item
 */
function createTooltipItem(chart, item) {
	const {element, datasetIndex, index} = item;
	const controller = chart.getDatasetMeta(datasetIndex).controller;
	const {label, value} = controller.getLabelAndValue(index);

	return {
		chart,
		label,
		dataPoint: controller.getParsed(index),
		formattedValue: value,
		dataset: controller.getDataset(),
		dataIndex: index,
		datasetIndex,
		element
	};
}

/**
 * Helper to get the reset model for the tooltip
 * @param options {object} the tooltip options
 */
function resolveOptions(options) {

	options = merge({}, [defaults.plugins.tooltip, options]);

	options.bodyFont = toFont(options.bodyFont);
	options.titleFont = toFont(options.titleFont);
	options.footerFont = toFont(options.footerFont);

	options.boxHeight = valueOrDefault(options.boxHeight, options.bodyFont.size);
	options.boxWidth = valueOrDefault(options.boxWidth, options.bodyFont.size);

	return options;
}

/**
 * Get the size of the tooltip
 */
function getTooltipSize(tooltip) {
	const ctx = tooltip._chart.ctx;
	const {body, footer, options, title} = tooltip;
	const {bodyFont, footerFont, titleFont, boxWidth, boxHeight} = options;
	const titleLineCount = title.length;
	const footerLineCount = footer.length;
	const bodyLineItemCount = body.length;

	let height = options.yPadding * 2; // Tooltip Padding
	let width = 0;

	// Count of all lines in the body
	let combinedBodyLength = body.reduce((count, bodyItem) => count + bodyItem.before.length + bodyItem.lines.length + bodyItem.after.length, 0);
	combinedBodyLength += tooltip.beforeBody.length + tooltip.afterBody.length;

	if (titleLineCount) {
		height += titleLineCount * titleFont.size
			+ (titleLineCount - 1) * options.titleSpacing
			+ options.titleMarginBottom;
	}
	if (combinedBodyLength) {
		// Body lines may include some extra height depending on boxHeight
		const bodyLineHeight = options.displayColors ? Math.max(boxHeight, bodyFont.size) : bodyFont.size;
		height += bodyLineItemCount * bodyLineHeight
			+ (combinedBodyLength - bodyLineItemCount) * bodyFont.size
			+ (combinedBodyLength - 1) * options.bodySpacing;
	}
	if (footerLineCount) {
		height += options.footerMarginTop
			+ footerLineCount * footerFont.size
			+ (footerLineCount - 1) * options.footerSpacing;
	}

	// Title width
	let widthPadding = 0;
	const maxLineWidth = function(line) {
		width = Math.max(width, ctx.measureText(line).width + widthPadding);
	};

	ctx.save();

	ctx.font = titleFont.string;
	each(tooltip.title, maxLineWidth);

	// Body width
	ctx.font = bodyFont.string;
	each(tooltip.beforeBody.concat(tooltip.afterBody), maxLineWidth);

	// Body lines may include some extra width due to the color box
	widthPadding = options.displayColors ? (boxWidth + 2) : 0;
	each(body, (bodyItem) => {
		each(bodyItem.before, maxLineWidth);
		each(bodyItem.lines, maxLineWidth);
		each(bodyItem.after, maxLineWidth);
	});

	// Reset back to 0
	widthPadding = 0;

	// Footer width
	ctx.font = footerFont.string;
	each(tooltip.footer, maxLineWidth);

	ctx.restore();

	// Add padding
	width += 2 * options.xPadding;

	return {width, height};
}

/**
 * Helper to get the alignment of a tooltip given the size
 */
function determineAlignment(chart, options, size) {
	const {x, y, width, height} = size;
	const chartArea = chart.chartArea;
	let xAlign = 'center';
	let yAlign = 'center';

	if (y < height) {
		yAlign = 'top';
	} else if (y > (chart.height - height)) {
		yAlign = 'bottom';
	}

	let lf, rf; // functions to determine left, right alignment
	const midX = (chartArea.left + chartArea.right) / 2;
	const midY = (chartArea.top + chartArea.bottom) / 2;

	if (yAlign === 'center') {
		lf = (value) => value <= midX;
		rf = (value) => value > midX;
	} else {
		lf = (value) => value <= (width / 2);
		rf = (value) => value >= (chart.width - (width / 2));
	}

	// functions to determine if left/right alignment causes tooltip to go outside chart
	const olf = (value) => value + width + options.caretSize + options.caretPadding > chart.width;
	const orf = (value) => value - width - options.caretSize - options.caretPadding < 0;
	// function to get the y alignment if the tooltip goes outside of the left or right edges
	const yf = (value) => value <= midY ? 'top' : 'bottom';

	if (lf(x)) {
		xAlign = 'left';

		// Is tooltip too wide and goes over the right side of the chart.?
		if (olf(x)) {
			xAlign = 'center';
			yAlign = yf(y);
		}
	} else if (rf(x)) {
		xAlign = 'right';

		// Is tooltip too wide and goes outside left edge of canvas?
		if (orf(x)) {
			xAlign = 'center';
			yAlign = yf(y);
		}
	}

	return {
		xAlign: options.xAlign ? options.xAlign : xAlign,
		yAlign: options.yAlign ? options.yAlign : yAlign
	};
}

function alignX(size, xAlign, chartWidth) {
	// eslint-disable-next-line prefer-const
	let {x, width} = size;
	if (xAlign === 'right') {
		x -= width;
	} else if (xAlign === 'center') {
		x -= (width / 2);
		if (x + width > chartWidth) {
			x = chartWidth - width;
		}
		if (x < 0) {
			x = 0;
		}
	}
	return x;
}

function alignY(size, yAlign, paddingAndSize) {
	// eslint-disable-next-line prefer-const
	let {y, height} = size;
	if (yAlign === 'top') {
		y += paddingAndSize;
	} else if (yAlign === 'bottom') {
		y -= height + paddingAndSize;
	} else {
		y -= (height / 2);
	}
	return y;
}

/**
 * Helper to get the location a tooltip needs to be placed at given the initial position (via the vm) and the size and alignment
 */
function getBackgroundPoint(options, size, alignment, chart) {
	const {caretSize, caretPadding, cornerRadius} = options;
	const {xAlign, yAlign} = alignment;
	const paddingAndSize = caretSize + caretPadding;
	const radiusAndPadding = cornerRadius + caretPadding;

	let x = alignX(size, xAlign, chart.width);
	const y = alignY(size, yAlign, paddingAndSize);

	if (yAlign === 'center') {
		if (xAlign === 'left') {
			x += paddingAndSize;
		} else if (xAlign === 'right') {
			x -= paddingAndSize;
		}
	} else if (xAlign === 'left') {
		x -= radiusAndPadding;
	} else if (xAlign === 'right') {
		x += radiusAndPadding;
	}

	return {x, y};
}

function getAlignedX(tooltip, align) {
	const options = tooltip.options;
	return align === 'center'
		? tooltip.x + tooltip.width / 2
		: align === 'right'
			? tooltip.x + tooltip.width - options.xPadding
			: tooltip.x + options.xPadding;
}

/**
 * Helper to build before and after body lines
 */
function getBeforeAfterBodyLines(callback) {
	return pushOrConcat([], splitNewlines(callback));
}

export class Tooltip extends Element {
	constructor(config) {
		super();

		this.opacity = 0;
		this._active = [];
		this._chart = config._chart;
		this._eventPosition = undefined;
		this._size = undefined;
		this._cachedAnimations = undefined;
		this.$animations = undefined;
		this.options = undefined;
		this.dataPoints = undefined;
		this.title = undefined;
		this.beforeBody = undefined;
		this.body = undefined;
		this.afterBody = undefined;
		this.footer = undefined;
		this.xAlign = undefined;
		this.yAlign = undefined;
		this.x = undefined;
		this.y = undefined;
		this.height = undefined;
		this.width = undefined;
		this.caretX = undefined;
		this.caretY = undefined;
		this.labelColors = undefined;
		this.labelTextColors = undefined;

		this.initialize();
	}

	initialize() {
		const me = this;
		me.options = resolveOptions(me._chart.options.tooltips);
	}

	/**
	 * @private
	 */
	_resolveAnimations() {
		const me = this;
		const cached = me._cachedAnimations;

		if (cached) {
			return cached;
		}

		const chart = me._chart;
		const options = me.options;
		const opts = options.enabled && chart.options.animation && options.animation;
		const animations = new Animations(me._chart, opts);
		me._cachedAnimations = Object.freeze(animations);

		return animations;
	}

	getTitle(context) {
		const me = this;
		const opts = me.options;
		const callbacks = opts.callbacks;

		const beforeTitle = callbacks.beforeTitle.apply(me, [context]);
		const title = callbacks.title.apply(me, [context]);
		const afterTitle = callbacks.afterTitle.apply(me, [context]);

		let lines = [];
		lines = pushOrConcat(lines, splitNewlines(beforeTitle));
		lines = pushOrConcat(lines, splitNewlines(title));
		lines = pushOrConcat(lines, splitNewlines(afterTitle));

		return lines;
	}

	getBeforeBody(tooltipItems) {
		return getBeforeAfterBodyLines(this.options.callbacks.beforeBody.apply(this, [tooltipItems]));
	}

	getBody(tooltipItems) {
		const me = this;
		const callbacks = me.options.callbacks;
		const bodyItems = [];

		each(tooltipItems, (context) => {
			const bodyItem = {
				before: [],
				lines: [],
				after: []
			};
			pushOrConcat(bodyItem.before, splitNewlines(callbacks.beforeLabel.call(me, context)));
			pushOrConcat(bodyItem.lines, callbacks.label.call(me, context));
			pushOrConcat(bodyItem.after, splitNewlines(callbacks.afterLabel.call(me, context)));

			bodyItems.push(bodyItem);
		});

		return bodyItems;
	}

	getAfterBody(tooltipItems) {
		return getBeforeAfterBodyLines(this.options.callbacks.afterBody.apply(this, [tooltipItems]));
	}

	// Get the footer and beforeFooter and afterFooter lines
	getFooter(tooltipItems) {
		const me = this;
		const callbacks = me.options.callbacks;

		const beforeFooter = callbacks.beforeFooter.apply(me, [tooltipItems]);
		const footer = callbacks.footer.apply(me, [tooltipItems]);
		const afterFooter = callbacks.afterFooter.apply(me, [tooltipItems]);

		let lines = [];
		lines = pushOrConcat(lines, splitNewlines(beforeFooter));
		lines = pushOrConcat(lines, splitNewlines(footer));
		lines = pushOrConcat(lines, splitNewlines(afterFooter));

		return lines;
	}

	/**
	 * @private
	 */
	_createItems() {
		const me = this;
		const active = me._active;
		const options = me.options;
		const data = me._chart.data;
		const labelColors = [];
		const labelTextColors = [];
		let tooltipItems = [];
		let i, len;

		for (i = 0, len = active.length; i < len; ++i) {
			tooltipItems.push(createTooltipItem(me._chart, active[i]));
		}

		// If the user provided a filter function, use it to modify the tooltip items
		if (options.filter) {
			tooltipItems = tooltipItems.filter((element, index, array) => options.filter(element, index, array, data));
		}

		// If the user provided a sorting function, use it to modify the tooltip items
		if (options.itemSort) {
			tooltipItems = tooltipItems.sort((a, b) => options.itemSort(a, b, data));
		}

		// Determine colors for boxes
		each(tooltipItems, (context) => {
			labelColors.push(options.callbacks.labelColor.call(me, context));
			labelTextColors.push(options.callbacks.labelTextColor.call(me, context));
		});

		me.labelColors = labelColors;
		me.labelTextColors = labelTextColors;
		me.dataPoints = tooltipItems;
		return tooltipItems;
	}

	update(changed) {
		const me = this;
		const options = me.options;
		const active = me._active;
		let properties;

		if (!active.length) {
			if (me.opacity !== 0) {
				properties = {
					opacity: 0
				};
			}
		} else {
			const position = positioners[options.position].call(me, active, me._eventPosition);
			const tooltipItems = me._createItems();

			me.title = me.getTitle(tooltipItems);
			me.beforeBody = me.getBeforeBody(tooltipItems);
			me.body = me.getBody(tooltipItems);
			me.afterBody = me.getAfterBody(tooltipItems);
			me.footer = me.getFooter(tooltipItems);

			const size = me._size = getTooltipSize(me);
			const positionAndSize = Object.assign({}, position, size);
			const alignment = determineAlignment(me._chart, options, positionAndSize);
			const backgroundPoint = getBackgroundPoint(options, positionAndSize, alignment, me._chart);

			me.xAlign = alignment.xAlign;
			me.yAlign = alignment.yAlign;

			properties = {
				opacity: 1,
				x: backgroundPoint.x,
				y: backgroundPoint.y,
				width: size.width,
				height: size.height,
				caretX: position.x,
				caretY: position.y
			};
		}

		if (properties) {
			me._resolveAnimations().update(me, properties);
		}

		if (changed && options.custom) {
			options.custom.call(me, {chart: me._chart, tooltip: me});
		}
	}

	drawCaret(tooltipPoint, ctx, size) {
		const caretPosition = this.getCaretPosition(tooltipPoint, size);

		ctx.lineTo(caretPosition.x1, caretPosition.y1);
		ctx.lineTo(caretPosition.x2, caretPosition.y2);
		ctx.lineTo(caretPosition.x3, caretPosition.y3);
	}

	getCaretPosition(tooltipPoint, size) {
		const {xAlign, yAlign, options} = this;
		const {cornerRadius, caretSize} = options;
		const {x: ptX, y: ptY} = tooltipPoint;
		const {width, height} = size;
		let x1, x2, x3, y1, y2, y3;

		if (yAlign === 'center') {
			y2 = ptY + (height / 2);

			if (xAlign === 'left') {
				x1 = ptX;
				x2 = x1 - caretSize;

				// Left draws bottom -> top, this y1 is on the bottom
				y1 = y2 + caretSize;
				y3 = y2 - caretSize;
			} else {
				x1 = ptX + width;
				x2 = x1 + caretSize;

				// Right draws top -> bottom, thus y1 is on the top
				y1 = y2 - caretSize;
				y3 = y2 + caretSize;
			}

			x3 = x1;
		} else {
			if (xAlign === 'left') {
				x2 = ptX + cornerRadius + (caretSize);
			} else if (xAlign === 'right') {
				x2 = ptX + width - cornerRadius - caretSize;
			} else {
				x2 = this.caretX;
			}

			if (yAlign === 'top') {
				y1 = ptY;
				y2 = y1 - caretSize;

				// Top draws left -> right, thus x1 is on the left
				x1 = x2 - caretSize;
				x3 = x2 + caretSize;
			} else {
				y1 = ptY + height;
				y2 = y1 + caretSize;

				// Bottom draws right -> left, thus x1 is on the right
				x1 = x2 + caretSize;
				x3 = x2 - caretSize;
			}
			y3 = y1;
		}
		return {x1, x2, x3, y1, y2, y3};
	}

	drawTitle(pt, ctx) {
		const me = this;
		const options = me.options;
		const title = me.title;
		const length = title.length;
		let titleFont, titleSpacing, i;

		if (length) {
			const rtlHelper = getRtlAdapter(options.rtl, me.x, me.width);

			pt.x = getAlignedX(me, options.titleAlign);

			ctx.textAlign = rtlHelper.textAlign(options.titleAlign);
			ctx.textBaseline = 'middle';

			titleFont = options.titleFont;
			titleSpacing = options.titleSpacing;

			ctx.fillStyle = options.titleFont.color;
			ctx.font = titleFont.string;

			for (i = 0; i < length; ++i) {
				ctx.fillText(title[i], rtlHelper.x(pt.x), pt.y + titleFont.size / 2);
				pt.y += titleFont.size + titleSpacing; // Line Height and spacing

				if (i + 1 === length) {
					pt.y += options.titleMarginBottom - titleSpacing; // If Last, add margin, remove spacing
				}
			}
		}
	}

	/**
	 * @private
	 */
	_drawColorBox(ctx, pt, i, rtlHelper) {
		const me = this;
		const options = me.options;
		const labelColors = me.labelColors[i];
		const {boxHeight, boxWidth, bodyFont} = options;
		const colorX = getAlignedX(me, 'left');
		const rtlColorX = rtlHelper.x(colorX);
		const yOffSet = boxHeight < bodyFont.size ? (bodyFont.size - boxHeight) / 2 : 0;
		const colorY = pt.y + yOffSet;

		// Fill a white rect so that colours merge nicely if the opacity is < 1
		ctx.fillStyle = options.multiKeyBackground;
		ctx.fillRect(rtlHelper.leftForLtr(rtlColorX, boxWidth), colorY, boxWidth, boxHeight);

		// Border
		ctx.lineWidth = 1;
		ctx.strokeStyle = labelColors.borderColor;
		ctx.strokeRect(rtlHelper.leftForLtr(rtlColorX, boxWidth), colorY, boxWidth, boxHeight);

		// Inner square
		ctx.fillStyle = labelColors.backgroundColor;
		ctx.fillRect(rtlHelper.leftForLtr(rtlHelper.xPlus(rtlColorX, 1), boxWidth - 2), colorY + 1, boxWidth - 2, boxHeight - 2);

		// restore fillStyle
		ctx.fillStyle = me.labelTextColors[i];
	}

	drawBody(pt, ctx) {
		const me = this;
		const {body, options} = me;
		const {bodyFont, bodySpacing, bodyAlign, displayColors, boxHeight, boxWidth} = options;
		let bodyLineHeight = bodyFont.size;
		let xLinePadding = 0;

		const rtlHelper = getRtlAdapter(options.rtl, me.x, me.width);

		const fillLineOfText = function(line) {
			ctx.fillText(line, rtlHelper.x(pt.x + xLinePadding), pt.y + bodyLineHeight / 2);
			pt.y += bodyLineHeight + bodySpacing;
		};

		const bodyAlignForCalculation = rtlHelper.textAlign(bodyAlign);
		let bodyItem, textColor, lines, i, j, ilen, jlen;

		ctx.textAlign = bodyAlign;
		ctx.textBaseline = 'middle';
		ctx.font = bodyFont.string;

		pt.x = getAlignedX(me, bodyAlignForCalculation);

		// Before body lines
		ctx.fillStyle = bodyFont.color;
		each(me.beforeBody, fillLineOfText);

		xLinePadding = displayColors && bodyAlignForCalculation !== 'right'
			? bodyAlign === 'center' ? (boxWidth / 2 + 1) : (boxWidth + 2)
			: 0;

		// Draw body lines now
		for (i = 0, ilen = body.length; i < ilen; ++i) {
			bodyItem = body[i];
			textColor = me.labelTextColors[i];

			ctx.fillStyle = textColor;
			each(bodyItem.before, fillLineOfText);

			lines = bodyItem.lines;
			// Draw Legend-like boxes if needed
			if (displayColors && lines.length) {
				me._drawColorBox(ctx, pt, i, rtlHelper);
				bodyLineHeight = Math.max(bodyFont.size, boxHeight);
			}

			for (j = 0, jlen = lines.length; j < jlen; ++j) {
				fillLineOfText(lines[j]);
				// Reset for any lines that don't include colorbox
				bodyLineHeight = bodyFont.size;
			}

			each(bodyItem.after, fillLineOfText);
		}

		// Reset back to 0 for after body
		xLinePadding = 0;
		bodyLineHeight = bodyFont.size;

		// After body lines
		each(me.afterBody, fillLineOfText);
		pt.y -= bodySpacing; // Remove last body spacing
	}

	drawFooter(pt, ctx) {
		const me = this;
		const options = me.options;
		const footer = me.footer;
		const length = footer.length;
		let footerFont, i;

		if (length) {
			const rtlHelper = getRtlAdapter(options.rtl, me.x, me.width);

			pt.x = getAlignedX(me, options.footerAlign);
			pt.y += options.footerMarginTop;

			ctx.textAlign = rtlHelper.textAlign(options.footerAlign);
			ctx.textBaseline = 'middle';

			footerFont = options.footerFont;

			ctx.fillStyle = options.footerFont.color;
			ctx.font = footerFont.string;

			for (i = 0; i < length; ++i) {
				ctx.fillText(footer[i], rtlHelper.x(pt.x), pt.y + footerFont.size / 2);
				pt.y += footerFont.size + options.footerSpacing;
			}
		}
	}

	drawBackground(pt, ctx, tooltipSize) {
		const {xAlign, yAlign, options} = this;
		const {x, y} = pt;
		const {width, height} = tooltipSize;
		const radius = options.cornerRadius;

		ctx.fillStyle = options.backgroundColor;
		ctx.strokeStyle = options.borderColor;
		ctx.lineWidth = options.borderWidth;

		ctx.beginPath();
		ctx.moveTo(x + radius, y);
		if (yAlign === 'top') {
			this.drawCaret(pt, ctx, tooltipSize);
		}
		ctx.lineTo(x + width - radius, y);
		ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
		if (yAlign === 'center' && xAlign === 'right') {
			this.drawCaret(pt, ctx, tooltipSize);
		}
		ctx.lineTo(x + width, y + height - radius);
		ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
		if (yAlign === 'bottom') {
			this.drawCaret(pt, ctx, tooltipSize);
		}
		ctx.lineTo(x + radius, y + height);
		ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
		if (yAlign === 'center' && xAlign === 'left') {
			this.drawCaret(pt, ctx, tooltipSize);
		}
		ctx.lineTo(x, y + radius);
		ctx.quadraticCurveTo(x, y, x + radius, y);
		ctx.closePath();

		ctx.fill();

		if (options.borderWidth > 0) {
			ctx.stroke();
		}
	}

	/**
	 * Update x/y animation targets when _active elements are animating too
	 * @private
	 */
	_updateAnimationTarget() {
		const me = this;
		const chart = me._chart;
		const options = me.options;
		const anims = me.$animations;
		const animX = anims && anims.x;
		const animY = anims && anims.y;
		if (animX || animY) {
			const position = positioners[options.position].call(me, me._active, me._eventPosition);
			if (!position) {
				return;
			}
			const size = me._size = getTooltipSize(me);
			const positionAndSize = Object.assign({}, position, me._size);
			const alignment = determineAlignment(chart, options, positionAndSize);
			const point = getBackgroundPoint(options, positionAndSize, alignment, chart);
			if (animX._to !== point.x || animY._to !== point.y) {
				me.xAlign = alignment.xAlign;
				me.yAlign = alignment.yAlign;
				me.width = size.width;
				me.height = size.height;
				me.caretX = position.x;
				me.caretY = position.y;
				me._resolveAnimations().update(me, point);
			}
		}
	}

	draw(ctx) {
		const me = this;
		const options = me.options;
		let opacity = me.opacity;

		if (!opacity) {
			return;
		}

		me._updateAnimationTarget();

		const tooltipSize = {
			width: me.width,
			height: me.height
		};
		const pt = {
			x: me.x,
			y: me.y
		};

		// IE11/Edge does not like very small opacities, so snap to 0
		opacity = Math.abs(opacity) < 1e-3 ? 0 : opacity;

		// Truthy/falsey value for empty tooltip
		const hasTooltipContent = me.title.length || me.beforeBody.length || me.body.length || me.afterBody.length || me.footer.length;

		if (options.enabled && hasTooltipContent) {
			ctx.save();
			ctx.globalAlpha = opacity;

			// Draw Background
			me.drawBackground(pt, ctx, tooltipSize);

			overrideTextDirection(ctx, options.textDirection);

			pt.y += options.yPadding;

			// Titles
			me.drawTitle(pt, ctx);

			// Body
			me.drawBody(pt, ctx);

			// Footer
			me.drawFooter(pt, ctx);

			restoreTextDirection(ctx, options.textDirection);

			ctx.restore();
		}
	}

	/**
	 * Handle an event
	 * @param {IEvent} e - The event to handle
	 * @param {boolean} [replay] - This is a replayed event (from update)
	 * @returns {boolean} true if the tooltip changed
	 */
	handleEvent(e, replay) {
		const me = this;
		const options = me.options;
		const lastActive = me._active || [];
		let changed = false;
		let active = [];

		// Find Active Elements for tooltips
		if (e.type !== 'mouseout') {
			active = me._chart.getElementsAtEventForMode(e, options.mode, options, replay);
			if (options.reverse) {
				active.reverse();
			}
		}

		// When there are multiple items shown, but the tooltip position is nearest mode
		// an update may need to be made because our position may have changed even though
		// the items are the same as before.
		const position = positioners[options.position].call(me, active, e);
		const positionChanged = this.caretX !== position.x || this.caretY !== position.y;

		// Remember Last Actives
		changed = replay || !_elementsEqual(active, lastActive) || positionChanged;

		// Only handle target event on tooltip change
		if (changed) {
			me._active = active;

			if (options.enabled || options.custom) {
				me._eventPosition = {
					x: e.x,
					y: e.y
				};

				me.update(true);
			}
		}

		return changed;
	}
}

/**
 * @namespace Chart.Tooltip.positioners
 */
Tooltip.positioners = positioners;

export default {
	id: 'tooltip',
	_element: Tooltip,
	positioners,

	afterInit(chart) {
		const tooltipOpts = chart.options.tooltips;

		if (tooltipOpts) {
			chart.tooltip = new Tooltip({_chart: chart});
		}
	},

	beforeUpdate(chart) {
		if (chart.tooltip) {
			chart.tooltip.initialize();
		}
	},

	reset(chart) {
		if (chart.tooltip) {
			chart.tooltip.initialize();
		}
	},

	afterDraw(chart) {
		const tooltip = chart.tooltip;

		const args = {
			tooltip
		};

		if (chart._plugins.notify(chart, 'beforeTooltipDraw', [args]) === false) {
			return;
		}

		if (tooltip) {
			tooltip.draw(chart.ctx);
		}

		chart._plugins.notify(chart, 'afterTooltipDraw', [args]);
	},

	afterEvent(chart, e, replay) {
		if (chart.tooltip) {
			// If the event is replayed from `update`, we should evaluate with the final positions.
			const useFinalPosition = replay;
			chart.tooltip.handleEvent(e, useFinalPosition);
		}
	},

	defaults: {
		enabled: true,
		custom: null,
		mode: 'nearest',
		position: 'average',
		intersect: true,
		backgroundColor: 'rgba(0,0,0,0.8)',
		titleFont: {
			style: 'bold',
			color: '#fff',
		},
		titleSpacing: 2,
		titleMarginBottom: 6,
		titleAlign: 'left',
		bodySpacing: 2,
		bodyFont: {
			color: '#fff',
		},
		bodyAlign: 'left',
		footerSpacing: 2,
		footerMarginTop: 6,
		footerFont: {
			color: '#fff',
			style: 'bold',
		},
		footerAlign: 'left',
		yPadding: 6,
		xPadding: 6,
		caretPadding: 2,
		caretSize: 5,
		cornerRadius: 6,
		multiKeyBackground: '#fff',
		displayColors: true,
		borderColor: 'rgba(0,0,0,0)',
		borderWidth: 0,
		animation: {
			duration: 400,
			easing: 'easeOutQuart',
			numbers: {
				type: 'number',
				properties: ['x', 'y', 'width', 'height', 'caretX', 'caretY'],
			},
			opacity: {
				easing: 'linear',
				duration: 200
			}
		},
		callbacks: {
			// Args are: (tooltipItems, data)
			beforeTitle: noop,
			title(tooltipItems) {
				if (tooltipItems.length > 0) {
					const item = tooltipItems[0];
					const labels = item.chart.data.labels;
					const labelCount = labels ? labels.length : 0;
					if (item.label) {
						return item.label;
					} else if (labelCount > 0 && item.dataIndex < labelCount) {
						return labels[item.dataIndex];
					}
				}

				return '';
			},
			afterTitle: noop,

			// Args are: (tooltipItems, data)
			beforeBody: noop,

			// Args are: (tooltipItem, data)
			beforeLabel: noop,
			label(tooltipItem) {
				let label = tooltipItem.dataset.label || '';

				if (label) {
					label += ': ';
				}
				const value = tooltipItem.formattedValue;
				if (!isNullOrUndef(value)) {
					label += value;
				}
				return label;
			},
			labelColor(tooltipItem) {
				const meta = tooltipItem.chart.getDatasetMeta(tooltipItem.datasetIndex);
				const options = meta.controller.getStyle(tooltipItem.dataIndex);
				return {
					borderColor: options.borderColor,
					backgroundColor: options.backgroundColor
				};
			},
			labelTextColor() {
				return this.options.bodyFont.color;
			},
			afterLabel: noop,

			// Args are: (tooltipItems, data)
			afterBody: noop,

			// Args are: (tooltipItems, data)
			beforeFooter: noop,
			footer: noop,
			afterFooter: noop
		}
	}
};
