/**
 * 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 {
	assembleMethodSymbol,
	CustomElement,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import "../notify/notify.mjs";
import { HostStyleSheet } from "./stylesheet/host.mjs";
import { getLocaleOfDocument } from "../../dom/locale.mjs";
import { Embed } from "../../i18n/providers/embed.mjs";
import { getDocumentTranslations } from "../../i18n/translations.mjs";
import { windowReady } from "../../dom/ready.mjs";
import { FocusManager } from "../../dom/focusmanager.mjs";
import { ResourceManager } from "../../dom/resourcemanager.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { isIterable } from "../../types/is.mjs";
import "./config-manager.mjs";
import { instanceSymbol } from "../../constants.mjs";

export { Host };

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

/**
 * @private
 * @type {symbol}
 */
const notifyElementSymbol = Symbol("notifyElement");

/**
 * @private
 * @type {symbol}
 */
const overlayElementSymbol = Symbol("overlayElement");

/**
 * @private
 * @type {symbol}
 */
const configManagerElementSymbol = Symbol("configManagerElement");

/**
 * @private
 * @type {symbol}
 */
const focusManagerSymbol = Symbol("focusManager");

/**
 * @private
 * @type {symbol}
 */
const resourceManagerSymbol = Symbol("resourceManager");

/**
 * @private
 * @type {symbol}
 */
const dismissablesSymbol = Symbol("dismissables");

/**
 * @private
 * @type {symbol}
 */
const dismissQueueSymbol = Symbol("dismissQueue");

/**
 * @private
 * @type {symbol}
 */
const dismissVersionSymbol = Symbol("dismissVersion");

/**
 * @private
 * @type {symbol}
 */
const dismissSequenceSymbol = Symbol("dismissSequence");

/**
 * @private
 * @type {symbol}
 */
const dismissScheduledSymbol = Symbol("dismissScheduled");

/**
 * @private
 * @type {symbol}
 */
const dismissHandlerSymbol = Symbol("dismissHandler");

/**
 * @private
 * @type {symbol}
 */
const dismissListenersAttachedSymbol = Symbol("dismissListenersAttached");

/**
 * The Host component is used to encapsulate the content of a web app.
 *
 * @fragments /fragments/components/host/host/
 *
 * @example /examples/components/host/host-simple Host container
 *
 * @copyright Volker Schukai
 * @summary A simple host component
 * @fires monster-host-connected
 * @fires monster-host-disconnected
 */
class Host extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/component-host/Host@@instance");
	}

	/**
	 * The individual configuration values can be found in the table.
	 *
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 * @property {Object} features Feature definitions
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},
		});
	}

	/**
	 * @param key
	 * @return {Promise}
	 */
	getConfig(key) {
		if (this[configManagerElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no config manager element");
		}
		return this[configManagerElementSymbol].getConfig(key);
	}

	/**
	 * @param {string} key
	 * @returns {*}
	 */
	hasConfig(key) {
		if (this[configManagerElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no config manager element");
		}
		return this[configManagerElementSymbol].hasConfig(key);
	}

	/**
	 *
	 * @param {key} key
	 * @returns {*}
	 */
	deleteConfig(key) {
		if (this[configManagerElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no config manager element");
		}
		return this[configManagerElementSymbol].deleteConfig(key);
	}

	/**
	 *
	 * @param {string} key
	 * @param {*} value
	 * @return {Promise}
	 */
	setConfig(key, value) {
		if (this[configManagerElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no config manager element");
		}

		return this[configManagerElementSymbol].setConfig(key, value);
	}

	/**
	 * @private
	 * @fires Host#monster-host-connected
	 */
	connectedCallback() {
		super.connectedCallback();

		/**
		 * show the scroll bar always
		 * @type {string}
		 */
		document.documentElement.style.overflowY = "scroll";

		const classNames = this.getOption("classes.body");

		if (document.body.classList.contains(classNames)) {
			document.body.classList.remove(classNames);
		}

		attachDismissListeners.call(this);

		fireCustomEvent(this, "monster-host-connected");
	}

	/**
	 * @private
	 * @fires Host#monster-host-disconnected
	 */
	disconnectedCallback() {
		super.disconnectedCallback();
		document.documentElement.style.overflowY = "";

		const classNames = this.getOption("classes.body");
		if (!document.body.classList.contains(classNames)) {
			document.body.classList.add(classNames);
		}

		if (isIterable(this[promisesSymbol]) === false) {
			this[promisesSymbol] = [];
		}

		this[promisesSymbol].push(
			new Promise((resolve, reject) => {
				this.addEventListener(
					"monster-host-connected",
					() => {
						resolve();
					},
					{ once: true },
				);
			}),
		);

		fireCustomEvent(this, "monster-host-disconnected");

		detachDismissListeners.call(this);
	}

	/**
	 *
	 * @return {Host}
	 */
	[assembleMethodSymbol]() {
		this[promisesSymbol] = [];
		this[promisesSymbol].push(windowReady);

		super[assembleMethodSymbol]();

		initControlReferences.call(this);
		initEventHandler.call(this);
		initTranslations.call(this);

		this[focusManagerSymbol] = new FocusManager(this);
		this[resourceManagerSymbol] = new ResourceManager(this);

		try {
			this[promisesSymbol].push(this[resourceManagerSymbol].available());
		} catch (e) {
			return Promise.reject(e);
		}

		if (this.isConnected === false) {
			this[promisesSymbol].push(
				new Promise((resolve, reject) => {
					this.addEventListener(
						"monster-host-connected",
						() => {
							resolve();
						},
						{ once: true },
					);
				}),
			);
		}
	}

	/**
	 * The Promise is resolved when the element is connected to the DOM and all resources are available.
	 * If the element is not connected to the DOM, the Promise is rejected.
	 *
	 * @return {Promise}
	 */
	onReady() {
		if (isIterable(this[promisesSymbol]) === false) {
			this[promisesSymbol] = [];
		}

		return Promise.all(this[promisesSymbol]).then(() => {
			this[promisesSymbol] = [];
			return this;
		});
	}

	/**
	 * @see {@link https://monsterjs.org/en/doc/monster/Monster.DOM.FocusManager.html|Monster.DOM.FocusManager}
	 * @return {*}
	 */
	get focusManager() {
		return this[focusManagerSymbol];
	}

	/**
	 * @see {@link https://monsterjs.org/en/doc/monster/Monster.DOM.ResourceManager.html|Monster.DOM.ResourceManager}
	 * @return {*}
	 */
	get resourceManager() {
		return this[resourceManagerSymbol];
	}

	/**
	 *
	 * @return {Host}
	 * @throws {Error} There is no overlay element defined.
	 */
	toggleOverlay() {
		if (this[overlayElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no overlay element defined.");
		}

		this[overlayElementSymbol].toggle();
		return this;
	}

	/**
	 * @return {Host}
	 * @throws {Error} There is no overlay element defined.
	 */
	openOverlay() {
		if (this[overlayElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no overlay element defined.");
		}

		this[overlayElementSymbol].open();
		return this;
	}

	/**
	 * @return {Host}
	 * @throws {Error} There is no overlay element defined.
	 */
	closeOverlay() {
		if (this[overlayElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no overlay element defined.");
		}

		this[overlayElementSymbol].close();
		return this;
	}

	/**
	 * @return {string}
	 */
	static getTag() {
		return "monster-host";
	}

	/**
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [HostStyleSheet];
	}

	/**
	 * @return {Locale}
	 */
	get locale() {
		return getLocaleOfDocument();
	}

	/**
	 *
	 * @return {Translations}
	 */
	get translations() {
		return getDocumentTranslations();
	}

	/**
	 *
	 * @param {string|Message} message
	 */
	pushNotification(message) {
		if (this[notifyElementSymbol] instanceof HTMLElement === false) {
			throw new Error("There is no notify element defined.");
		}

		this[notifyElementSymbol].push(message);
		return this;
	}

	/**
	 * Register a dismissable overlay (popper/select/dialog) for outside click handling.
	 * @param {Object} entry
	 * @return {Object|null}
	 */
	registerDismissable(entry) {
		return registerDismissable.call(this, entry);
	}

	/**
	 * Unregister a dismissable overlay.
	 * @param {Object|HTMLElement} entry
	 * @return {boolean}
	 */
	unregisterDismissable(entry) {
		return unregisterDismissable.call(this, entry);
	}
}

/**
 * @private
 * @return {Select}
 * @throws {Error} no shadow-root is defined
 */
function initControlReferences() {
	if (!this.shadowRoot) {
		throw new Error("no shadow-root is defined");
	}

	this[overlayElementSymbol] = this.querySelector("monster-overlay");
	this[notifyElementSymbol] = this.querySelector("monster-notify");
	this[configManagerElementSymbol] = this.querySelector(
		"monster-config-manager",
	);
}

/**
 * @private
 */
function initTranslations() {
	if (isIterable(this[promisesSymbol]) === false) {
		this[promisesSymbol] = [];
	}

	this[promisesSymbol].push(Embed.assignTranslationsToElement());
}

/**
 * @private
 */
function initEventHandler() {
	initDismissManager.call(this);
	return this;
}

/**
 * @private
 */
function initDismissManager() {
	this[dismissablesSymbol] = new Map();
	this[dismissQueueSymbol] = [];
	this[dismissVersionSymbol] = 0;
	this[dismissSequenceSymbol] = 0;
	this[dismissScheduledSymbol] = false;
	this[dismissListenersAttachedSymbol] = false;

	this[dismissHandlerSymbol] = (event) => {
		if (!this.isConnected) {
			return;
		}
		queueDismissEvent.call(this, event);
	};
}

/**
 * @private
 */
function attachDismissListeners() {
	if (this[dismissListenersAttachedSymbol] === true) {
		return;
	}

	const supportsPointer = typeof globalThis.PointerEvent === "function";
	const eventTypes = supportsPointer
		? ["pointerdown"]
		: ["mousedown", "touchstart"];

	for (const type of eventTypes) {
		this.addEventListener(type, this[dismissHandlerSymbol], {
			capture: true,
		});
	}

	this[dismissListenersAttachedSymbol] = true;
}

/**
 * @private
 */
function detachDismissListeners() {
	if (this[dismissListenersAttachedSymbol] !== true) {
		return;
	}

	const supportsPointer = typeof globalThis.PointerEvent === "function";
	const eventTypes = supportsPointer
		? ["pointerdown"]
		: ["mousedown", "touchstart"];

	for (const type of eventTypes) {
		this.removeEventListener(type, this[dismissHandlerSymbol], {
			capture: true,
		});
	}

	this[dismissListenersAttachedSymbol] = false;
}

/**
 * @private
 * @param {Event} event
 */
function queueDismissEvent(event) {
	if (!event) {
		return;
	}

	const path =
		typeof event.composedPath === "function" ? event.composedPath() : [];
	this[dismissQueueSymbol].push({
		path,
		target: event.target || null,
		version: this[dismissVersionSymbol],
	});

	if (this[dismissScheduledSymbol] === true) {
		return;
	}

	this[dismissScheduledSymbol] = true;
	const schedule =
		typeof queueMicrotask === "function"
			? queueMicrotask
			: (cb) => Promise.resolve().then(cb);

	schedule(() => {
		this[dismissScheduledSymbol] = false;
		processDismissQueue.call(this);
	});
}

/**
 * @private
 */
function processDismissQueue() {
	const queue = this[dismissQueueSymbol];
	this[dismissQueueSymbol] = [];

	if (!queue.length) {
		return;
	}

	for (const entry of queue) {
		if (entry.version !== this[dismissVersionSymbol]) {
			continue;
		}

		const top = getTopDismissable.call(this);
		if (!top) {
			continue;
		}

		if (isEventInsideDismissable(entry, top)) {
			continue;
		}

		if (top.options?.dismissOnOutside === false) {
			continue;
		}

		if (typeof top.close === "function") {
			top.close();
		}

		break;
	}
}

/**
 * @private
 * @param {Object} entry
 * @return {Object|null}
 */
function registerDismissable(entry) {
	if (this[dismissablesSymbol] instanceof Map === false) {
		initDismissManager.call(this);
	}

	const normalized = normalizeDismissEntry(entry);
	if (!normalized) {
		return null;
	}

	const existing = this[dismissablesSymbol].get(normalized.element);
	const record = existing || { element: normalized.element };

	record.owner = normalized.owner || record.owner || null;
	record.close = normalized.close || record.close || null;
	record.priority = Number.isFinite(normalized.priority)
		? normalized.priority
		: Number.isFinite(record.priority)
			? record.priority
			: 0;
	record.options = Object.assign(
		{},
		record.options || {},
		normalized.options || {},
	);
	record.sequence = ++this[dismissSequenceSymbol];

	this[dismissablesSymbol].set(record.element, record);
	this[dismissVersionSymbol] += 1;

	return record;
}

/**
 * @private
 * @param {Object|HTMLElement} entry
 * @return {boolean}
 */
function unregisterDismissable(entry) {
	if (this[dismissablesSymbol] instanceof Map === false) {
		return false;
	}

	const record = resolveDismissRecord.call(this, entry);
	if (!record) {
		return false;
	}

	this[dismissablesSymbol].delete(record.element);
	this[dismissVersionSymbol] += 1;
	return true;
}

/**
 * @private
 * @param {Object} entry
 * @return {Object|null}
 */
function normalizeDismissEntry(entry) {
	if (!entry) {
		return null;
	}

	const element = entry.element || entry.owner || entry;
	if (!(element instanceof HTMLElement)) {
		return null;
	}

	return {
		element,
		owner: entry.owner instanceof HTMLElement ? entry.owner : null,
		close: typeof entry.close === "function" ? entry.close : null,
		priority: entry.priority,
		options: entry.options || {},
	};
}

/**
 * @private
 * @param {Object|HTMLElement} entry
 * @return {Object|null}
 */
function resolveDismissRecord(entry) {
	if (!entry) {
		return null;
	}

	if (entry.element && this[dismissablesSymbol].has(entry.element)) {
		return this[dismissablesSymbol].get(entry.element);
	}

	if (entry instanceof HTMLElement && this[dismissablesSymbol].has(entry)) {
		return this[dismissablesSymbol].get(entry);
	}

	for (const record of this[dismissablesSymbol].values()) {
		if (record === entry) {
			return record;
		}
		if (record.owner && record.owner === entry) {
			return record;
		}
	}

	return null;
}

/**
 * @private
 * @return {Object|null}
 */
function getTopDismissable() {
	if (this[dismissablesSymbol] instanceof Map === false) {
		return null;
	}

	let top = null;
	for (const record of this[dismissablesSymbol].values()) {
		if (!record) {
			continue;
		}
		if (!top) {
			top = record;
			continue;
		}
		if ((record.priority || 0) > (top.priority || 0)) {
			top = record;
			continue;
		}
		if (
			(record.priority || 0) === (top.priority || 0) &&
			(record.sequence || 0) > (top.sequence || 0)
		) {
			top = record;
		}
	}

	return top;
}

/**
 * @private
 * @param {Object} eventEntry
 * @param {Object} record
 * @return {boolean}
 */
function isEventInsideDismissable(eventEntry, record) {
	if (!record) {
		return false;
	}

	const path = eventEntry.path || [];
	const target = eventEntry.target || null;

	if (path.includes(record.element)) {
		return true;
	}

	if (record.owner && path.includes(record.owner)) {
		return true;
	}

	if (target && record.element?.contains?.(target)) {
		return true;
	}

	if (target && record.owner?.contains?.(target)) {
		return true;
	}

	return false;
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <template id="host-container">
            <div data-monster-replace="path:host-container.content"
                 data-monster-attributes="part path:host-container.name, data-monster-role path:host-container.name"></div>
        </template>

        <div data-monster-role="host-container">
            <slot></slot>
        </div>`;
}

registerCustomElement(Host);
