/**
 * Copyright (c) 2017 ~ present NAVER Corp.
 * billboard.js project is licensed under the MIT license
 */
import {TYPE, TYPE_BY_CATEGORY} from "../../config/const";
import {KEY} from "../../module/Cache";
import {
	brushEmpty,
	diffDomain,
	getBrushSelection,
	getMinMax,
	isDefined,
	isNumber,
	isObject,
	isValue,
	notEmpty,
	parseDate,
	sortValue,
	toSet
} from "../../module/util";
import type {IData, TDomainRange} from "../data/IData";

type DomainMinMax = [number | Date | undefined, number | Date | undefined];
type MinMaxAccumulator = {min: any, max: any};

/**
 * Build a compact cache key for target-domain scans.
 * @param {object} $$ ChartInternal instance
 * @param {Array} targets Data targets
 * @returns {string} Cache key
 * @private
 */
function getTargetDomainCacheKey($$, targets: IData[]): string {
	return targets.map(target => {
		const {values} = target;
		const first = values[0];
		const last = values[values.length - 1];
		const firstX = first ? $$.getXCacheKey?.(first.x) ?? first.x : "";
		const lastX = last ? $$.getXCacheKey?.(last.x) ?? last.x : "";

		return `${target.id}:${values.length}:${firstX}:${lastX}`;
	}).join("|");
}

/**
 * Check whether domain result can be cached without accumulating zoom-window entries.
 * @param {object} $$ ChartInternal instance
 * @param {Array} targets Data targets
 * @returns {boolean} Whether the targets use original value arrays
 * @private
 */
function canCacheTargetDomain($$, targets: IData[]): boolean {
	const sourceTargets = $$.data?.targets;

	if (!sourceTargets) {
		return false;
	}

	for (let i = 0; i < targets.length; i++) {
		const target = targets[i];
		const source = sourceTargets.find(v => v.id === target.id);

		if (!source || source.values !== target.values) {
			return false;
		}
	}

	return true;
}

/**
 * Update a min/max accumulator with a scalar domain value.
 * @param {object} minMax Min/max accumulator
 * @param {number|Date|null|undefined} value Domain value
 * @private
 */
function updateMinMax(minMax: MinMaxAccumulator, value): void {
	if (!notEmpty(value)) {
		return;
	}

	if (minMax.min === undefined || value < minMax.min) {
		minMax.min = value;
	}

	if (minMax.max === undefined || value > minMax.max) {
		minMax.max = value;
	}
}

/**
 * Update a min/max accumulator from an array-like value.
 * @param {object} minMax Min/max accumulator
 * @param {Array} values Domain values
 * @private
 */
function updateMinMaxFromValues(minMax: MinMaxAccumulator, values): void {
	for (let i = 0; i < values.length; i++) {
		updateMinMax(minMax, values[i]);
	}
}

/**
 * Compute target value min/max without allocating intermediate value arrays.
 * @param {object} $$ ChartInternal instance
 * @param {Array} targets Data targets
 * @returns {Array} Min/max tuple
 * @private
 */
function getTargetValueMinMax($$, targets: IData[]): DomainMinMax {
	const minMax = {min: undefined, max: undefined};
	const hasAxis = $$.state.hasAxis;

	for (let i = 0; i < targets.length; i++) {
		const target = targets[i];
		const isCandlestick = $$.isCandlestickType?.(target);
		const {values} = target;

		for (let j = 0; j < values.length; j++) {
			const row = values[j];
			let value: any = row.value;

			if (!(isValue(value) || value === null)) {
				continue;
			}

			if (value !== null && isCandlestick) {
				value = Array.isArray(value) ?
					value.slice(0, 4) :
					[value.open, value.high, value.low, value.close];
			}

			if (Array.isArray(value)) {
				updateMinMaxFromValues(minMax, value);
			} else if (isObject(value) && "high" in value) {
				updateMinMaxFromValues(minMax, Object.values(value));
			} else if ($$.isBubbleZType?.(row)) {
				updateMinMax(minMax, hasAxis && $$.getBubbleZData(value, "y"));
			} else {
				updateMinMax(minMax, value);
			}
		}
	}

	return [minMax.min, minMax.max];
}

/**
 * Compute x-domain min or max without allocating intermediate x arrays.
 * @param {Array} targets Data targets
 * @param {string} type Min/max type
 * @returns {number|Date|undefined} Domain value
 * @private
 */
function getTargetXMinMax(targets: IData[], type: "min" | "max") {
	let result;

	for (let i = 0; i < targets.length; i++) {
		const {values} = targets[i];

		for (let j = 0; j < values.length; j++) {
			const {x} = values[j];

			if (
				notEmpty(x) &&
				(result === undefined || (type === "min" ? x < result : x > result))
			) {
				result = x;
			}
		}
	}

	return result;
}

export default {
	/**
	 * Get both min and max Y domain values in a single pass.
	 * Avoids calling getValuesAsIdKeyed twice.
	 * @param {Array} targets Target data
	 * @returns {[number|Date|undefined, number|Date|undefined]} [min, max]
	 * @private
	 */
	getYDomainMinMaxBoth(targets): [number | Date | undefined, number | Date | undefined] {
		const $$ = this;
		const {axis, cache, config, state} = $$;
		const canCache = canCacheTargetDomain($$, targets);
		const cacheKey = canCache ?
			`${KEY.domainMinMax}_y_${getTargetDomainCacheKey($$, targets)}` :
			null;
		const cached = cacheKey && cache.get(cacheKey);

		if (cached && cached.generation === state.dataGeneration) {
			return cached.value;
		}

		const dataGroups = config.data_groups;
		const ids = $$.mapToIds(targets);
		const idsSet = toSet(ids);
		let result: DomainMinMax;

		if (dataGroups.length > 0) {
			const rawYs = $$.getValuesAsIdKeyed(targets);
			const hasNegative = targets.some(t => t.values.some(v => v.value < 0));
			const hasPositive = targets.some(t => t.values.some(v => v.value > 0));
			const axisIdMap = new Map(ids.map(id => [id, axis.getId(id)]));

			// Clone ys into separate min/max copies since grouped calculation mutates values
			const ysMin = {};
			const ysMax = {};

			for (const key in rawYs) {
				ysMin[key] = rawYs[key].slice();
				ysMax[key] = rawYs[key].slice();
			}

			dataGroups.forEach(groupIds => {
				const idsInGroup = groupIds.filter(v => idsSet.has(v));

				if (idsInGroup.length) {
					const baseId = idsInGroup[0];
					const baseAxisId = axisIdMap.get(baseId);

					// Initialize base values for min (negative) and max (positive)
					if (ysMin[baseId] && hasNegative) {
						ysMin[baseId] = ysMin[baseId].map(v => (v < 0 ? v : 0));
					}

					if (ysMax[baseId] && hasPositive) {
						ysMax[baseId] = ysMax[baseId].map(v => (v > 0 ? v : 0));
					}

					idsInGroup
						.filter((v, i) => i > 0)
						.forEach(id => {
							if (ysMin[id]) {
								const axisId = axisIdMap.get(id);

								ysMin[id].forEach((v, i) => {
									const val = +v;

									// min pass: skip positive values when hasNegative
									if (axisId === baseAxisId && !(hasNegative && val > 0)) {
										ysMin[baseId][i] += val;
									}
								});
							}

							if (ysMax[id]) {
								const axisId = axisIdMap.get(id);

								ysMax[id].forEach((v, i) => {
									const val = +v;

									// max pass: skip negative values when hasPositive
									if (axisId === baseAxisId && !(hasPositive && val < 0)) {
										ysMax[baseId][i] += val;
									}
								});
							}
						});
				}
			});

			const minVals: number[] = [];
			const maxVals: number[] = [];

			for (const key in ysMin) {
				minVals.push(getMinMax("min", ysMin[key]));
				maxVals.push(getMinMax("max", ysMax[key]));
			}

			result = [
				getMinMax("min", minVals),
				getMinMax("max", maxVals)
			];
		} else {
			result = getTargetValueMinMax($$, targets);
		}

		if (cacheKey) {
			cache.add(cacheKey, {
				generation: state.dataGeneration,
				value: result
			});
		}

		return result;
	},

	/**
	 * Check if hidden targets bound to the given axis id
	 * @param {string} id ID to be checked
	 * @returns {boolean}
	 * @private
	 */
	isHiddenTargetWithYDomain(id): boolean {
		const $$ = this;

		for (const v of $$.state.hiddenTargetIds) {
			if ($$.axis.getId(v) === id) return true;
		}

		return false;
	},

	getYDomain(targets: IData[], axisId: "y" | "y2", xDomain: TDomainRange) {
		const $$ = this;
		const {axis, config, scale} = $$;
		const pfx = `axis_${axisId}`;

		// Check if stack normalization should be applied for this axis
		if ($$.isStackNormalized()) {
			// Get all data IDs that belong to this axis
			const axisDataIds = targets
				.filter(t => axis.getId(t.id) === axisId)
				.map(t => t.id);

			// Check if any of the axis data IDs are in groups
			const hasGroupedData = axisDataIds.some(id => $$.isGrouped(id));

			// Apply normalization only if this axis has grouped data
			if (hasGroupedData) {
				return [0, 100];
			}
		}

		const isLog = scale?.[axisId] && scale[axisId].type === "log";
		const targetsByAxisId = targets.filter(t => axis.getId(t.id) === axisId);
		const yTargets = xDomain ? $$.filterByXDomain(targetsByAxisId, xDomain) : targetsByAxisId;

		if (yTargets.length === 0) { // use domain of the other axis if target of axisId is none
			if ($$.isHiddenTargetWithYDomain(axisId)) {
				return scale[axisId].domain();
			} else {
				return axisId === "y2" ?
					scale.y.domain() : // When all data bounds to y2, y Axis domain is called prior y2.
					// So, it needs to call to get y2 domain here
					$$.getYDomain(targets, "y2", xDomain);
			}
		}

		const yMin = config[`${pfx}_min`];
		const yMax = config[`${pfx}_max`];
		const center = config[`${pfx}_center`];
		const isInverted = config[`${pfx}_inverted`];
		const showHorizontalDataLabel = $$.hasDataLabel() && config.axis_rotated;
		const showVerticalDataLabel = $$.hasDataLabel() && !config.axis_rotated;

		const [yDomainMinVal, yDomainMaxVal] = $$.getYDomainMinMaxBoth(yTargets);
		let yDomainMin = yDomainMinVal;
		let yDomainMax = yDomainMaxVal;

		let isZeroBased = [TYPE.BAR, TYPE.BUBBLE, TYPE.SCATTER, ...TYPE_BY_CATEGORY.Line]
			.some(v => {
				const type = v.indexOf("area") > -1 ? "area" : v;

				return $$.hasType(v, yTargets, true) && config[`${type}_zerobased`];
			});

		// MEMO: avoid inverting domain unexpectedly
		yDomainMin = isValue(yMin) ? yMin : (
			isValue(yMax) ?
				(
					yDomainMin <= yMax ? yDomainMin : yMax - 10
				) :
				yDomainMin
		);

		yDomainMax = isValue(yMax) ? yMax : (
			isValue(yMin) ?
				(
					yMin <= yDomainMax ? yDomainMax : yMin + 10
				) :
				yDomainMax
		);

		if (isNaN(yDomainMin)) { // set minimum to zero when not number
			yDomainMin = 0;
		}

		if (isNaN(yDomainMax)) { // set maximum to have same value as yDomainMin
			yDomainMax = yDomainMin;
		}

		if (yDomainMin === yDomainMax) {
			yDomainMin < 0 ? yDomainMax = 0 : yDomainMin = 0;
		}

		const isAllPositive = yDomainMin >= 0 && yDomainMax >= 0;
		const isAllNegative = yDomainMin <= 0 && yDomainMax <= 0;

		// Cancel zerobased if axis_*_min / axis_*_max specified
		if ((isValue(yMin) && isAllPositive) || (isValue(yMax) && isAllNegative)) {
			isZeroBased = false;
		}

		// Bar/Area chart should be 0-based if all positive|negative
		if (isZeroBased) {
			isAllPositive && (yDomainMin = 0);
			isAllNegative && (yDomainMax = 0);
		}

		const domainLength = Math.abs(yDomainMax - yDomainMin);
		let padding = {top: domainLength * 0.1, bottom: domainLength * 0.1};

		if (isDefined(center)) {
			const yDomainAbs = Math.max(Math.abs(yDomainMin), Math.abs(yDomainMax));

			yDomainMax = center + yDomainAbs;
			yDomainMin = center - yDomainAbs;
		}

		// add padding for data label
		if (showHorizontalDataLabel) {
			const diff = diffDomain(scale.y.range());
			const ratio = $$.getDataLabelLength(yDomainMin, yDomainMax, "width")
				.map(v => {
					const result = v / diff;

					return isFinite(result) ? result : 0;
				});

			["bottom", "top"].forEach((v, i) => {
				padding[v] += domainLength * (ratio[i] / (1 - ratio[0] - ratio[1]));
			});
		} else if (showVerticalDataLabel) {
			const lengths = $$.getDataLabelLength(yDomainMin, yDomainMax, "height");

			["bottom", "top"].forEach((v, i) => {
				padding[v] += $$.convertPixelToScale("y", lengths[i], domainLength);
			});
		}

		padding = $$.getResettedPadding(padding);

		// if padding is set, the domain will be updated relative the current domain value
		// ex) $$.height=300, padding.top=150, domainLength=4  --> domain=6
		const p = config[`${pfx}_padding`];

		if (notEmpty(p)) {
			["bottom", "top"].forEach(v => {
				padding[v] = axis.getPadding(p, v, padding[v], domainLength);
			});
		}

		// Bar/Area chart should be 0-based if all positive|negative
		if (isZeroBased) {
			isAllPositive && (padding.bottom = yDomainMin);
			isAllNegative && (padding.top = -yDomainMax);
		}

		const domain = isLog ?
			[yDomainMin, yDomainMax].map(v => (v < 0 ? 0 : v)) :
			[yDomainMin - padding.bottom, yDomainMax + padding.top];

		return isInverted ? domain.reverse() : domain;
	},

	getXDomainMinMax(targets, type) {
		const $$ = this;
		const {cache, state} = $$;
		const configValue = $$.config[`axis_x_${type}`];
		const canCache = canCacheTargetDomain($$, targets);
		const cacheKey = canCache ?
			`${KEY.domainMinMax}_x_${type}_${getTargetDomainCacheKey($$, targets)}` :
			null;
		const cached = cacheKey && cache.get(cacheKey);
		let dataValue = cached?.generation === state.dataGeneration ? cached.value : undefined;

		if (dataValue === undefined) {
			dataValue = getTargetXMinMax(targets, type);
			cacheKey && cache.add(cacheKey, {
				generation: state.dataGeneration,
				value: dataValue
			});
		}

		let value = isObject(configValue) ? configValue.value : configValue;

		value = isDefined(value) && $$.axis?.isTimeSeries() ? parseDate.bind(this)(value) : value;

		if (
			isObject(configValue) && configValue.fit && (
				(type === "min" && value < dataValue) || (type === "max" && value > dataValue)
			)
		) {
			value = undefined;
		}

		return isDefined(value) ? value : dataValue;
	},

	/**
	 * Get x Axis padding
	 * @param {Array} domain x Axis domain
	 * @param {number} tickCount Tick count
	 * @returns {object} Padding object values with 'left' & 'right' key
	 * @private
	 */
	getXDomainPadding(domain, tickCount: number): {left: number, right: number} {
		const $$ = this;
		const {axis, config} = $$;
		const padding = config.axis_x_padding;
		const isTimeSeriesTickCount = axis.isTimeSeries() && tickCount;
		const diff = diffDomain(domain);
		let defaultValue;

		// determine default padding value
		if (axis.isCategorized() || isTimeSeriesTickCount) {
			defaultValue = 0;
		} else if ($$.hasType("bar")) {
			const maxDataCount = $$.getMaxDataCount();

			defaultValue = maxDataCount > 1 ? (diff / (maxDataCount - 1)) / 2 : 0.5;
		} else {
			defaultValue = $$.getResettedPadding(diff * 0.01);
		}

		let {left = defaultValue, right = defaultValue} = isNumber(padding) ?
			{left: padding, right: padding} :
			padding;

		// when the unit is pixel, convert pixels to axis scale value
		if (padding.unit === "px") {
			const domainLength = Math.abs(diff + (diff * 0.2));

			left = axis.getPadding(padding, "left", defaultValue, domainLength);
			right = axis.getPadding(padding, "right", defaultValue, domainLength);
		} else {
			const range = diff + left + right;

			if (isTimeSeriesTickCount && range) {
				const relativeTickWidth = (diff / tickCount) / range;

				left = left / range / relativeTickWidth;
				right = right / range / relativeTickWidth;
			}
		}

		return {left, right};
	},

	/**
	 * Get x Axis domain
	 * @param {Array} targets targets
	 * @returns {Array} x Axis domain
	 * @private
	 */
	getXDomain(targets: IData[]): (Date | number)[] {
		const $$ = this;
		const {axis, config, scale: {x}} = $$;
		const isInverted = config.axis_x_inverted;
		const domain = [
			$$.getXDomainMinMax(targets, "min"),
			$$.getXDomainMinMax(targets, "max")
		];
		let [min = 0, max = 0] = domain;

		if (x.type !== "log") {
			const isCategorized = axis.isCategorized();
			const isTimeSeries = axis.isTimeSeries();
			const padding = $$.getXDomainPadding(domain);
			let [firstX, lastX] = domain;

			// show center of x domain if min and max are the same
			if ((firstX - lastX) === 0 && !isCategorized) {
				if (isTimeSeries) {
					firstX = new Date(firstX.getTime() * 0.5);
					lastX = new Date(lastX.getTime() * 1.5);
				} else {
					firstX = firstX === 0 ? 1 : (firstX * 0.5);
					lastX = lastX === 0 ? -1 : (lastX * 1.5);
				}
			}

			if (firstX || firstX === 0) {
				min = isTimeSeries ?
					new Date(firstX.getTime() - padding.left) :
					firstX - padding.left;
			}

			if (lastX || lastX === 0) {
				max = isTimeSeries ?
					new Date(lastX.getTime() + padding.right) :
					lastX + padding.right;
			}
		}

		return isInverted ? [max, min] : [min, max];
	},

	updateXDomain(targets, withUpdateXDomain, withUpdateOrgXDomain, withTrim, domain) {
		const $$ = this;
		const {config, org, scale: {x, subX}} = $$;
		const zoomEnabled = config.zoom_enabled;

		if (withUpdateOrgXDomain) {
			x.domain(domain || sortValue($$.getXDomain(targets), !config.axis_x_inverted));
			org.xDomain = x.domain();

			// zoomEnabled && $$.zoom.updateScaleExtent();

			subX.domain(x.domain());
			$$.brush?.scale(subX);
		}

		if (withUpdateXDomain) {
			const domainValue = domain || (!$$.brush || brushEmpty($$)) ?
				org.xDomain :
				getBrushSelection($$).map(subX.invert);

			x.domain(domainValue);
			// zoomEnabled && $$.zoom.updateScaleExtent();
		}

		if (withUpdateOrgXDomain || withUpdateXDomain) {
			zoomEnabled && $$.zoom.updateScaleExtent();
		}

		// Trim domain when too big by zoom mousemove event
		withTrim && x.domain($$.trimXDomain(x.orgDomain()));

		return x.domain();
	},

	/**
	 * Trim x domain when given domain surpasses the range
	 * @param {Array} domain Domain value
	 * @returns {Array} Trimed domain if given domain is out of range
	 * @private
	 */
	trimXDomain(domain) {
		const $$ = this;
		const isInverted = $$.config.axis_x_inverted;
		const zoomDomain = $$.getZoomDomain();
		const [min, max] = zoomDomain;

		if (isInverted ? domain[0] >= min : domain[0] <= min) {
			domain[1] = +domain[1] + (min - domain[0]);
			domain[0] = min;
		}

		if (isInverted ? domain[1] <= max : domain[1] >= max) {
			domain[0] = +domain[0] - (domain[1] - max);
			domain[1] = max;
		}

		return domain;
	},

	/**
	 * Get subchart/zoom domain
	 * @param {string} type "subX" or "zoom"
	 * @param {boolean} getCurrent Get current domain if true
	 * @returns {Array} zoom domain
	 * @private
	 */
	getZoomDomain(type: "subX" | "zoom" = "zoom", getCurrent = false): TDomainRange {
		const $$ = this;
		const {config, scale, org} = $$;
		let [min, max] = getCurrent && scale[type] ? scale[type].domain() : org.xDomain;

		if (type === "zoom") {
			if (isDefined(config.zoom_x_min)) {
				min = getMinMax("min", [min, config.zoom_x_min]);
			}

			if (isDefined(config.zoom_x_max)) {
				max = getMinMax("max", [max, config.zoom_x_max]);
			}
		}

		return [min, max];
	},

	/**
	 * Return zoom domain from given domain
	 * - 'category' type need to add offset to original value
	 * @param {Array} domainValue domain value
	 * @returns {Array} Zoom domain
	 * @private
	 */
	getZoomDomainValue<T = TDomainRange>(domainValue: T): T | undefined {
		const $$ = this;
		const {config, axis} = $$;

		if (axis.isCategorized() && Array.isArray(domainValue)) {
			const isInverted = config.axis_x_inverted;

			// need to add offset to original value for 'category' type
			const domain = domainValue.map((v, i) =>
				Number(v) + (i === 0 ? +isInverted : +!isInverted)
			);

			return domain as T;
		}

		return domainValue;
	},

	/**
	 * Converts pixels to axis' scale values
	 * @param {string} type Axis type
	 * @param {number} pixels Pixels
	 * @param {number} domainLength Domain length
	 * @returns {number}
	 * @private
	 */
	convertPixelToScale(type: "x" | "y", pixels: number, domainLength: number): number {
		const $$ = this;
		const {config, state} = $$;
		const isRotated = config.axis_rotated;
		let length;

		if (type === "x") {
			length = isRotated ? "height" : "width";
		} else {
			length = isRotated ? "width" : "height";
		}

		return domainLength * (pixels / state[length]);
	},

	/**
	 * Check if the given domain is within subchart/zoom range
	 * @param {Array} domain Target domain value
	 * @param {Array} current Current subchart/zoom domain value
	 * @param {Array} range subchart/zoom range value
	 * @returns {boolean}
	 * @private
	 */
	withinRange<T = TDomainRange>(domain: T, current = [0, 0], range: T): boolean {
		const $$ = this;
		const isInverted = $$.config.axis_x_inverted;
		const [min, max] = range as number[];

		if (Array.isArray(domain)) {
			const lo = isInverted ? domain[1] : domain[0];
			const hi = isInverted ? domain[0] : domain[1];

			if (lo < hi) {
				return domain.every((v, i) =>
					(
						i === 0 ?
							(
								isInverted ? +v <= min : +v >= min
							) :
							(
								isInverted ? +v >= max : +v <= max
							)
					) && !(domain.every((v, i) => v === current[i]))
				);
			}
		}

		return false;
	}
};
