/**
 * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
 * Node module: @schukai/monster
 *
 * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
 * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
 *
 * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
 * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
 * For more information about purchasing a commercial license, please contact Volker Schukai.
 *
 * SPDX-License-Identifier: AGPL-3.0
 */

import {
	arrow,
	autoPlacement,
	autoUpdate,
	computePosition,
	flip,
	hide,
	offset,
	shift,
	size,
} from "@floating-ui/dom";
import { extend } from "../../../data/extend.mjs";
import {
	isArray,
	isElement,
	isFunction,
	isNumber,
	isObject,
	isString,
} from "../../../types/is.mjs";
import { validateBoolean } from "../../../types/validate.mjs";

export {
	createFloatingPopper,
	setEventListenersModifiers,
	popperInstanceSymbol,
};

/**
 * @private
 * @type {symbol}
 */
const popperInstanceSymbol = Symbol("popperInstance");

/**
 * @private
 * @type {symbol}
 */
const optionsSymbol = Symbol("options");

/**
 * @private
 * @type {symbol}
 */
const referenceElementSymbol = Symbol("referenceElement");

/**
 * @private
 * @type {symbol}
 */
const popperElementSymbol = Symbol("popperElement");

/**
 * @private
 * @type {symbol}
 */
const autoUpdateCleanupSymbol = Symbol("autoUpdateCleanup");

/**
 * @private
 * @param {HTMLElement} referenceElement
 * @param {HTMLElement} popperElement
 * @param {object} options
 * @return {{destroy: Function, setOptions: Function, update: Function}}
 */
function createFloatingPopper(referenceElement, popperElement, options = {}) {
	const instance = {
		[referenceElementSymbol]: referenceElement,
		[popperElementSymbol]: popperElement,
		[optionsSymbol]: normalizeOptions(options),
		[autoUpdateCleanupSymbol]: null,
		destroy() {
			stopAutoUpdate(instance);
		},
		setOptions(nextOptions) {
			const normalizedOptions = isFunction(nextOptions)
				? nextOptions(instance[optionsSymbol])
				: nextOptions;

			instance[optionsSymbol] = normalizeOptions(normalizedOptions);
			syncAutoUpdate(instance);
			return instance.update();
		},
		update() {
			return updatePosition(instance);
		},
	};

	syncAutoUpdate(instance);

	return instance;
}

/**
 * @private
 * @this {CustomElement}
 * @param {Boolean} mode
 */
function setEventListenersModifiers(mode) {
	const options = extend({}, this.getOption("popper"));
	const modifiers = options?.modifiers;

	if (!isArray(modifiers)) {
		options.modifiers = [];
	}

	const existingModifier = options.modifiers.find((entry) => {
		return entry?.name === "eventListeners";
	});

	if (existingModifier) {
		existingModifier.enabled = validateBoolean(mode);
	} else {
		options.modifiers.push({
			name: "eventListeners",
			enabled: validateBoolean(mode),
		});
	}

	return this[popperInstanceSymbol].setOptions(options);
}

/**
 * @private
 * @param {object} instance
 * @return {Promise<void>}
 */
function updatePosition(instance) {
	const referenceElement = instance[referenceElementSymbol];
	const popperElement = instance[popperElementSymbol];
	const options = instance[optionsSymbol];

	if (!isElement(referenceElement) || !isElement(popperElement)) {
		return Promise.resolve();
	}

	return computePosition(referenceElement, popperElement, {
		placement: options.placement,
		strategy: options.strategy,
		middleware: options.middleware,
	}).then(({ x, y, strategy }) => {
		Object.assign(popperElement.style, {
			position: strategy,
			left: `${roundByDPR(x)}px`,
			top: `${roundByDPR(y)}px`,
			transform: "",
		});
	});
}

/**
 * @private
 * @param {object} instance
 * @return {void}
 */
function syncAutoUpdate(instance) {
	stopAutoUpdate(instance);

	if (instance[optionsSymbol].eventListeners !== true) {
		return;
	}

	instance[autoUpdateCleanupSymbol] = autoUpdate(
		instance[referenceElementSymbol],
		instance[popperElementSymbol],
		() => {
			void instance.update();
		},
		{
			elementResize: typeof ResizeObserver === "function",
			layoutShift: false,
		},
	);
}

/**
 * @private
 * @param {object} instance
 * @return {void}
 */
function stopAutoUpdate(instance) {
	const cleanup = instance[autoUpdateCleanupSymbol];
	if (typeof cleanup === "function") {
		cleanup();
	}
	instance[autoUpdateCleanupSymbol] = null;
}

/**
 * @private
 * @param {object} options
 * @return {object}
 */
function normalizeOptions(options) {
	const config = extend(
		{
			placement: "bottom",
			strategy: "absolute",
			modifiers: [],
		},
		options || {},
	);

	config.eventListeners = normalizeEventListeners(config.modifiers);
	config.middleware = normalizeMiddleware(config);

	if (config.placement === "auto") {
		config.placement = "bottom";
		config.middleware.unshift(
			autoPlacement({
				crossAxis: true,
				autoAlignment: true,
			}),
		);
	}

	return config;
}

/**
 * @private
 * @param {object[]} modifiers
 * @return {boolean}
 */
function normalizeEventListeners(modifiers) {
	if (!isArray(modifiers)) {
		return true;
	}

	let result = true;

	for (const entry of modifiers) {
		if (entry?.name === "eventListeners") {
			result = validateBoolean(entry.enabled);
		}
	}

	return result;
}

/**
 * @private
 * @param {object} config
 * @return {Array}
 */
function normalizeMiddleware(config) {
	const result = [];
	const middleware = [];

	if (isArray(config?.middleware)) {
		middleware.push(...config.middleware);
	}

	if (isArray(config?.modifiers)) {
		middleware.push(...config.modifiers);
	}

	for (const entry of middleware) {
		if (isFunction(entry)) {
			result.push(entry);
			continue;
		}

		if (isObject(entry) && isFunction(entry?.fn)) {
			result.push(entry);
			continue;
		}

		if (isObject(entry) && !isString(entry?.name)) {
			result.push(entry);
			continue;
		}

		if (!isObject(entry) || !isString(entry?.name)) {
			continue;
		}

		const normalizedEntry = normalizeModifier(entry);
		if (normalizedEntry) {
			result.push(normalizedEntry);
		}
	}

	return result;
}

/**
 * @private
 * @param {{name: string, options?: object, enabled?: boolean}} modifier
 * @return {object|null}
 */
function normalizeModifier(modifier) {
	if (modifier.enabled === false) {
		return null;
	}

	switch (modifier.name) {
		case "arrow":
			if (!isElement(modifier?.options?.element)) {
				return null;
			}
			return arrow(modifier.options);
		case "autoPlacement":
			return autoPlacement(modifier.options);
		case "eventListeners":
			return null;
		case "flip":
			return flip(modifier.options);
		case "hide":
			return hide(modifier.options);
		case "offset":
			return offset(normalizeOffset(modifier.options?.offset));
		case "preventOverflow":
			return shift(modifier.options);
		case "shift":
			return shift(modifier.options);
		case "size":
			return size(modifier.options);
		default:
			return null;
	}
}

/**
 * @private
 * @param {number|Array<number>} rawOffset
 * @return {number|object}
 */
function normalizeOffset(rawOffset) {
	if (isArray(rawOffset)) {
		const [skidding = 0, distance = 0] = rawOffset;
		return {
			mainAxis: distance,
			crossAxis: skidding,
		};
	}

	if (isNumber(rawOffset)) {
		return rawOffset;
	}

	return 0;
}

/**
 * @private
 * @param {number} value
 * @return {number}
 */
function roundByDPR(value) {
	const dpr = window.devicePixelRatio || 1;
	return Math.round(value * dpr) / dpr;
}
