/**
 * 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 { instanceSymbol } from "../../constants.mjs";
import {
	addAttributeToken,
	containsAttributeToken,
	removeAttributeToken,
} from "../../dom/attributes.mjs";
import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import {
	assembleMethodSymbol,
	attributeObserverSymbol,
	CustomElement,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { addErrorAttribute } from "../../dom/error.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import {
	findElementWithSelectorUpwards,
	getDocument,
} from "../../dom/util.mjs";
import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
import {
	closePositionedPopper,
	isPositionedPopperOpen,
	openPositionedPopper,
	positionPopper,
} from "../form/util/floating-ui.mjs";
import { PopperStyleSheet } from "./stylesheet/popper.mjs";
import { isArray } from "../../types/is.mjs";

export { Popper };

/**
 * Symbol for timer callback reference
 * @private
 * @type {symbol}
 */
const timerCallbackSymbol = Symbol("timerCallback");

/**
 * Symbol for resize observer reference
 * @private
 * @type {symbol}
 */
const resizeObserverSymbol = Symbol("resizeObserver");

/**
 * Symbol for close event handler reference
 * @private
 * @type {symbol}
 */
const closeEventHandler = Symbol("closeEventHandler");

/**
 * Symbol for control element reference
 * @private
 * @type {symbol}
 */
const controlElementSymbol = Symbol("controlElement");

/**
 * Symbol for button element reference
 * @private
 * @type {symbol}
 */
const buttonElementSymbol = Symbol("buttonElement");

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

/**
 * Symbol for content element reference
 * @private
 * @type {symbol}
 */
const contentElementSymbol = Symbol("contentElement");

/**
 * Symbol for arrow element reference
 * @private
 * @type {symbol}
 */
const arrowElementSymbol = Symbol("arrowElement");

/**
 * @private
 * @type {symbol}
 */
const hostElementSymbol = Symbol("hostElement");

/**
 * @private
 * @type {symbol}
 */
const dismissRecordSymbol = Symbol("dismissRecord");

/**
 * @private
 * @type {symbol}
 */
const usesHostDismissSymbol = Symbol("usesHostDismiss");
const actionQueueSymbol = Symbol("actionQueue");
const pendingActionSymbol = Symbol("pendingAction");

/**
 * Popper component for displaying floating UI elements
 *
 * The Popper class creates a floating overlay element that can be shown/hidden
 * and positioned relative to a trigger element. It supports different interaction
 * modes like click, hover, focus etc.
 *
 * @fragments /fragments/components/layout/popper/
 *
 * @example /examples/components/layout/popper-simple
 * @example /examples/components/layout/popper-click
 *
 * @since 1.65.0
 * @copyright Volker Schukai
 * @summary Floating overlay component with flexible positioning and interaction modes
 * @fires monster-popper-hide - Fired when popper starts hiding
 * @fires monster-popper-hidden - Fired when popper is fully hidden
 * @fires monster-popper-open - Fired when popper starts opening
 * @fires monster-popper-opened - Fired when popper is fully opened
 */
class Popper extends CustomElement {
	/**
	 * Gets the instance symbol for type checking
	 * @return {symbol} The instance type symbol
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/components/layout/popper@@instance");
	}

	/**
	 * Default configuration options for the popper
	 *
	 * @property {Object} templates - Template configuration
	 * @property {string} templates.main - Main template HTML
	 * @property {string} mode - Interaction mode(s): click|enter|manual|focus|auto
	 * @property {string} content - Content template
	 * @property {Object} popper - Positioning options
	 * @property {string} popper.placement - Placement: top|bottom|left|right
	 * @property {Array} popper.middleware - Positioning middleware functions
	 * @property {string} popper.contentOverflow - Content clipping mode: both|horizontal|visible|smart
	 * @property {Object} features - Feature flags
	 * @property {boolean} features.preventOpenEventSent - Prevent open event
	 * @returns {Object} Default options merged with parent defaults
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},
			mode: "auto focus",
			content: "<slot></slot>",
			popper: {
				placement: "top",
				engine: "floating",
				middleware: ["flip", "shift", "offset:15", "arrow"],
				contentOverflow: "both",
			},
			features: {
				preventOpenEventSent: false,
			},
		});
	}

	/**
	 * Initialize the component
	 * Called on first connection to DOM
	 * @private
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		initControlReferences.call(this);
		initOverflowObserver.call(this);
		applyContentOverflowMode.call(this);
		initEventHandler.call(this);
	}

	/**
	 * @inheritdoc
	 */
	setOption(path, value) {
		super.setOption(path, value);

		if (path === "popper.contentOverflow") {
			applyContentOverflowMode.call(this);
		} else if (path === "content") {
			queueMicrotask(() => {
				applyContentOverflowMode.call(this);
			});
		}

		return this;
	}

	/**
	 * Gets the custom element tag name
	 * @return {string} The tag name
	 */
	static getTag() {
		return "monster-popper";
	}

	/**
	 * Gets component stylesheets
	 * @return {CSSStyleSheet[]} Array of stylesheets
	 */
	static getCSSStyleSheet() {
		return [PopperStyleSheet];
	}

	/**
	 * Lifecycle callback when element connects to DOM
	 * Sets up event listeners and initializes popper
	 */
	connectedCallback() {
		super.connectedCallback();

		this[hostElementSymbol] = findElementWithSelectorUpwards(
			this,
			"monster-host",
		);
		this[usesHostDismissSymbol] =
			this[hostElementSymbol] &&
			typeof this[hostElementSymbol].registerDismissable === "function";

		if (!this[usesHostDismissSymbol]) {
			const document = getDocument();
			for (const [, type] of Object.entries(["click", "touch"])) {
				document.addEventListener(type, this[closeEventHandler]);
			}
		}

		updatePopper.call(this);
		attachResizeObserver.call(this);
	}

	/**
	 * Lifecycle callback when element disconnects from DOM
	 * Cleans up event listeners and observers
	 */
	disconnectedCallback() {
		super.disconnectedCallback();

		if (!this[usesHostDismissSymbol]) {
			const document = getDocument();
			for (const [, type] of Object.entries(["click", "touch"])) {
				document.removeEventListener(type, this[closeEventHandler]);
			}
		}

		unregisterFromHost.call(this);
		disconnectResizeObserver.call(this);
	}

	/**
	 * Shows the popper element
	 * @return {Popper} The popper instance
	 */
	showDialog() {
		queuePopperAction.call(this, "show");
		return this;
	}

	/**
	 * Recalculates the size and position of an already open popper.
	 *
	 * @return {Popper}
	 */
	recalcPopper() {
		queuePopperAction.call(this, "update");
		return this;
	}

	/**
	 * Resolves the effective popper options for the current render pass.
	 * Subclasses can override this to adapt positioning without mutating
	 * the persisted component options.
	 *
	 * @return {object}
	 */
	resolvePopperOptions() {
		return Object.assign({}, this.getOption("popper", {}));
	}

	/**
	 * Resolves the effective content overflow mode for the rendered wrapper.
	 * Subclasses can override this when the configured option is only an intermediate mode.
	 * `smart` keeps regular content measurable inside the popper while nested overlays
	 * are allowed to escape horizontally without forcing the parent wrapper to size to them.
	 *
	 * @return {string}
	 */
	resolveContentOverflowMode() {
		const configuredMode = this.getOption("popper.contentOverflow", "both");
		if (configuredMode !== "smart") {
			return configuredMode;
		}

		if (containsNestedOverlayContent(this.getOption("content"))) {
			return "horizontal";
		}

		return "both";
	}

	/**
	 * Hides the popper element
	 * @return {Popper} The popper instance
	 */
	hideDialog() {
		queuePopperAction.call(this, "hide");
		return this;
	}

	/**
	 * Toggles popper visibility
	 * @return {Popper} The popper instance
	 */
	toggleDialog() {
		queuePopperAction.call(this, "toggle");
		return this;
	}
}

/**
 * Initializes event handlers for popper interactivity
 * @private
 * @return {Popper} The popper instance
 */
function initEventHandler() {
	this[closeEventHandler] = (event) => {
		if (
			isEventInsidePopperOwner(
				this,
				event,
				this[controlElementSymbol],
				this[buttonElementSymbol],
				this[popperElementSymbol],
			)
		) {
			return;
		}

		if (
			!isPositionedPopperOpen(this[popperElementSymbol]) &&
			!containsAttributeToken(this[controlElementSymbol], "class", "open")
		) {
			return;
		}

		hide.call(this);
	};

	let modes = null;
	const modeOption = this.getOption("mode");

	if (typeof modeOption === "string") {
		modes = modeOption.split(" ");
	}

	if (
		modes === null ||
		modes === undefined ||
		isArray(modes) === false ||
		modes.length === 0
	) {
		modes = ["manual"];
	}

	for (const [, mode] of Object.entries(modes)) {
		initEventHandlerByMode.call(this, mode);
	}

	return this;
}

/**
 * Serializes popper state changes so open/close/update events do not interleave.
 * The latest requested action wins while a queue is already running.
 *
 * @private
 * @param {"show"|"hide"|"toggle"|"update"} action
 * @return {Promise<void>}
 */
function queuePopperAction(action) {
	this[pendingActionSymbol] = action;

	if (this[actionQueueSymbol] instanceof Promise) {
		return this[actionQueueSymbol];
	}

	this[actionQueueSymbol] = (async () => {
		while (this[pendingActionSymbol]) {
			const nextAction = this[pendingActionSymbol];
			delete this[pendingActionSymbol];
			await Promise.resolve(runPopperAction.call(this, nextAction));
		}
	})()
		.catch((e) => {
			addErrorAttribute(this, e);
		})
		.finally(() => {
			delete this[actionQueueSymbol];
			if (this[pendingActionSymbol]) {
				void queuePopperAction.call(this, this[pendingActionSymbol]);
			}
		});

	return this[actionQueueSymbol];
}

function runPopperAction(action) {
	switch (action) {
		case "toggle":
			if (isPositionedPopperOpen(this[popperElementSymbol])) {
				return performHide.call(this);
			}
			return performShow.call(this);
		case "hide":
			return performHide.call(this);
		case "show":
			return performShow.call(this);
		case "update":
			return performUpdate.call(this);
		default:
			return undefined;
	}
}

function isEventInsidePopperOwner(
	owner,
	event,
	controlElement,
	buttonElement,
	popperElement,
) {
	const path = event.composedPath?.() || [];

	for (const element of path) {
		if (
			element === owner ||
			element === controlElement ||
			element === buttonElement ||
			element === popperElement
		) {
			return true;
		}
	}

	const target = path[0] || event.target;
	if (!(target instanceof Node)) {
		return false;
	}

	if (owner instanceof HTMLElement && owner.contains(target)) {
		return true;
	}

	if (
		owner?.shadowRoot instanceof ShadowRoot &&
		owner.shadowRoot.contains(target)
	) {
		return true;
	}

	return false;
}

/**
 * Sets up event handlers for specific interaction mode
 * @private
 * @param {string} mode - Interaction mode to initialize
 * @return {Popper} The popper instance
 * @throws {Error} For unknown modes
 */
function initEventHandlerByMode(mode) {
	switch (mode) {
		case "manual":
			break;

		case "focus":
			this[buttonElementSymbol].addEventListener("focus", (event) => {
				if (this.getOption("features.preventOpenEventSent") === true) {
					event.preventDefault();
				}
				this.showDialog();
			});
			this[buttonElementSymbol].addEventListener("blur", (event) => {
				if (this.getOption("features.preventOpenEventSent") === true) {
					event.preventDefault();
				}
				this.hideDialog();
			});
			break;

		case "click":
			this[buttonElementSymbol].addEventListener("click", (event) => {
				if (this.getOption("features.preventOpenEventSent") === true) {
					event.preventDefault();
				}
				this.toggleDialog();
			});
			break;
		case "enter":
			this[buttonElementSymbol].addEventListener("mouseenter", (event) => {
				if (this.getOption("features.preventOpenEventSent") === true) {
					event.preventDefault();
				}
				this.showDialog();
			});
			break;

		case "auto": // is hover
			this[buttonElementSymbol].addEventListener("mouseenter", (event) => {
				if (this.getOption("features.preventOpenEventSent") === true) {
					event.preventDefault();
				}
				this.showDialog();
			});
			this[buttonElementSymbol].addEventListener("mouseleave", (event) => {
				if (this.getOption("features.preventOpenEventSent") === true) {
					event.preventDefault();
				}
				this.hideDialog();
			});
			break;
		default:
			throw new Error(`Unknown mode ${mode}`);
	}
}

/**
 * Sets up resize observer for popper repositioning
 * @private
 */
function attachResizeObserver() {
	this[resizeObserverSymbol] = new ResizeObserver((entries) => {
		if (this[timerCallbackSymbol] instanceof DeadMansSwitch) {
			try {
				this[timerCallbackSymbol].touch();
				return;
			} catch (e) {
				delete this[timerCallbackSymbol];
			}
		}

		this[timerCallbackSymbol] = new DeadMansSwitch(200, () => {
			updatePopper.call(this);
		});
	});

	requestAnimationFrame(() => {
		let parent = this.parentNode;
		while (!(parent instanceof HTMLElement) && parent !== null) {
			parent = parent.parentNode;
		}

		if (parent instanceof HTMLElement) {
			this[resizeObserverSymbol].observe(parent);
		}
	});
}

/**
 * Disconnects resize observer
 * @private
 */
function disconnectResizeObserver() {
	if (this[resizeObserverSymbol] instanceof ResizeObserver) {
		this[resizeObserverSymbol].disconnect();
	}
}

/**
 * Hides the popper element
 * @private
 */
function hide() {
	void queuePopperAction.call(this, "hide");
}

/**
 * Hides the popper element
 * @private
 */
function performHide() {
	const self = this;
	const popperElement = self[popperElementSymbol];
	const controlElement = self[controlElementSymbol];

	if (!self.isConnected) {
		unregisterFromHost.call(self);
		return;
	}

	fireCustomEvent(self, "monster-popper-hide", {
		self,
	});

	if (popperElement instanceof HTMLElement) {
		closePositionedPopper(popperElement);
	}

	if (controlElement instanceof HTMLElement) {
		removeAttributeToken(controlElement, "class", "open");
	}

	unregisterFromHost.call(self);

	setTimeout(() => {
		fireCustomEvent(self, "monster-popper-hidden", {
			self,
		});
	}, 0);
}

/**
 * Shows the popper element
 * @private
 */
function show() {
	void queuePopperAction.call(this, "show");
}

/**
 * Shows the popper element
 * @private
 */
function performShow() {
	const self = this;
	const popperElement = self[popperElementSymbol];
	const controlElement = self[controlElementSymbol];

	if (self.getOption("disabled", false) === true) {
		return;
	}

	if (
		!self.isConnected ||
		!(popperElement instanceof HTMLElement) ||
		!(controlElement instanceof HTMLElement)
	) {
		return;
	}

	if (isPositionedPopperOpen(popperElement)) {
		return;
	}

	fireCustomEvent(self, "monster-popper-open", {
		self,
	});

	applyContentOverflowMode.call(self);

	popperElement.style.visibility = "hidden";
	openPositionedPopper(
		self[controlElementSymbol],
		popperElement,
		self.resolvePopperOptions(),
	);

	addAttributeToken(controlElement, "class", "open");
	registerWithHost.call(self);
	return performUpdate.call(self).then(() => {
		setTimeout(() => {
			fireCustomEvent(self, "monster-popper-opened", {
				self,
			});
		}, 0);
	});
}

/**
 * Updates popper positioning
 * @private
 */
function updatePopper() {
	void queuePopperAction.call(this, "update");
}

/**
 * Updates popper positioning
 * @private
 */
function performUpdate() {
	if (
		!this.isConnected ||
		!(this[controlElementSymbol] instanceof HTMLElement) ||
		!(this[popperElementSymbol] instanceof HTMLElement)
	) {
		return;
	}

	if (!isPositionedPopperOpen(this[popperElementSymbol])) {
		return;
	}

	if (this.getOption("disabled", false) === true) {
		return;
	}

	applyContentOverflowMode.call(this);

	return positionPopper.call(
		this,
		this[controlElementSymbol],
		this[popperElementSymbol],
		this.resolvePopperOptions(),
	);
}

/**
 * @private
 */
function registerWithHost() {
	if (!this[usesHostDismissSymbol]) {
		return;
	}

	if (!(this[hostElementSymbol] instanceof HTMLElement)) {
		return;
	}

	const record = this[hostElementSymbol].registerDismissable?.({
		element: this,
		owner: this,
		close: () => {
			this.hideDialog();
		},
		priority: 10,
		options: {
			dismissOnOutside: true,
		},
	});

	if (record) {
		this[dismissRecordSymbol] = record;
	}
}

/**
 * @private
 */
function unregisterFromHost() {
	if (!this[usesHostDismissSymbol]) {
		return;
	}

	if (!(this[hostElementSymbol] instanceof HTMLElement)) {
		return;
	}

	if (this[dismissRecordSymbol]) {
		this[hostElementSymbol].unregisterDismissable?.(this[dismissRecordSymbol]);
		this[dismissRecordSymbol] = null;
		return;
	}

	this[hostElementSymbol].unregisterDismissable?.(this);
}

/**
 * Initializes references to DOM elements
 * @private
 * @return {Popper} The popper instance
 */
function initControlReferences() {
	this[controlElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}=control]`,
	);
	this[buttonElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}=button]`,
	);
	this[popperElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}=popper]`,
	);
	this[arrowElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}=arrow]`,
	);
	this[contentElementSymbol] =
		this.shadowRoot.querySelector(`[part="content"]`);
	return this;
}

/**
 * Keeps the rendered content wrapper in sync with the configured overflow mode
 * @private
 * @return {void}
 */
function initOverflowObserver() {
	this[attributeObserverSymbol]["data-monster-option-popper-content-overflow"] =
		() => {
			applyContentOverflowMode.call(this);
		};
	this[attributeObserverSymbol]["data-monster-option-content"] = () => {
		applyContentOverflowMode.call(this);
	};
}

/**
 * Applies the current content overflow mode to the rendered wrapper element
 * @private
 * @return {void}
 */
function applyContentOverflowMode() {
	const contentElement = this[contentElementSymbol];
	if (!(contentElement instanceof HTMLElement)) {
		return;
	}

	const overflowMode = this.resolveContentOverflowMode();
	contentElement.setAttribute("data-monster-overflow-mode", overflowMode);

	switch (overflowMode) {
		case "horizontal":
			contentElement.style.overflow = "visible";
			contentElement.style.removeProperty("max-height");
			contentElement.style.removeProperty("max-width");
			break;
		case "both":
		case "visible":
			contentElement.style.overflow = "visible";
			contentElement.style.maxHeight = "none";
			contentElement.style.maxWidth = "none";
			break;
		default:
			contentElement.style.removeProperty("overflow");
			contentElement.style.removeProperty("max-height");
			contentElement.style.removeProperty("max-width");
			break;
	}
}

/**
 * Gets the main template HTML
 * @private
 * @return {string} Template HTML
 */
function getTemplate() {
	// language=HTML
	return `
        <div data-monster-role="control" part="control">
            <slot name="button" data-monster-role="button"></slot>

            <div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1">
                <div data-monster-role="arrow"></div>
                <div part="content"
                     class="flex"
                     data-monster-replace="path:content">
                </div>
            </div>
        </div>
    `;
}

function containsNestedOverlayContent(content) {
	const selector = [
		"monster-details",
		"monster-message-state-button",
		"monster-popper",
		"monster-popper-button",
		"monster-select",
		"details",
	].join(",");

	if (typeof content === "string") {
		const container = document.createElement("div");
		container.innerHTML = content;
		return container.querySelector(selector) instanceof HTMLElement;
	}

	if (!(content instanceof HTMLElement)) {
		return false;
	}

	if (content.matches(selector)) {
		return true;
	}

	return content.querySelector(selector) instanceof HTMLElement;
}

registerCustomElement(Popper);
