/**
 * 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 { getGlobal } from "../types/global.mjs";
import { validateString } from "../types/validate.mjs";

export {
	getDocument,
	getWindow,
	getDocumentFragmentFromString,
	findElementWithIdUpwards,
	getContainingDocument,
	getRegisteredCustomElements,
	findElementWithSelectorUpwards,
	waitForCustomElement,
};

/**
 * This method fetches the document object
 *
 * In nodejs this functionality can be performed with [jsdom](https://www.npmjs.com/package/jsdom).
 *
 * ```
 * import {JSDOM} from "jsdom"
 * if (typeof window !== "object") {
 *    const {window} = new JSDOM('', {
 *        url: 'http://example.com/',
 *        pretendToBeVisual: true
 *    });
 *
 *    [
 *        'self',
 *        'document',
 *        'Document',
 *        'Node',
 *        'Element',
 *        'HTMLElement',
 *        'DocumentFragment',
 *        'DOMParser',
 *        'XMLSerializer',
 *        'NodeFilter',
 *        'InputEvent',
 *        'CustomEvent'
 *    ].forEach(key => (getGlobal()[key] = window[key]));
 * }
 * ```
 *
 * @return {object}
 * @license AGPLv3
 * @since 1.6.0
 * @copyright Volker Schukai
 * @throws {Error} not supported environment
 */
function getDocument() {
	const document = getGlobal()?.["document"];
	if (typeof document !== "object") {
		throw new Error("not supported environment");
	}

	return document;
}

/**
 * This method fetches the window object
 *
 * In nodejs this functionality can be performed with [jsdom](https://www.npmjs.com/package/jsdom).
 *
 * ```
 * import {JSDOM} from "jsdom"
 * if (typeof window !== "object") {
 *    const {window} = new JSDOM('', {
 *        url: 'http://example.com/',
 *        pretendToBeVisual: true
 *    });
 *
 *    getGlobal()['window']=window;
 *
 *    [
 *        'self',
 *        'document',
 *        'Document',
 *        'Node',
 *        'Element',
 *        'HTMLElement',
 *        'DocumentFragment',
 *        'DOMParser',
 *        'XMLSerializer',
 *        'NodeFilter',
 *        'InputEvent',
 *        'CustomEvent'
 *    ].forEach(key => (getGlobal()[key] = window[key]));
 * }
 * ```
 *
 * @return {object}
 * @license AGPLv3
 * @since 1.6.0
 * @copyright Volker Schukai
 * @throws {Error} not supported environment
 */
function getWindow() {
	const window = getGlobal()?.["window"];
	if (typeof window !== "object") {
		throw new Error("not supported environment");
	}

	return window;
}

/**
 * This method fetches the document object
 *
 * In nodejs this functionality can be performed with [jsdom](https://www.npmjs.com/package/jsdom).
 *
 * ```
 * import {JSDOM} from "jsdom"
 * if (typeof window !== "object") {
 *    const {window} = new JSDOM('', {
 *        url: 'http://example.com/',
 *        pretendToBeVisual: true
 *    });
 *
 *    [
 *        'self',
 *        'document',
 *        'Document',
 *        'Node',
 *        'Element',
 *        'HTMLElement',
 *        'DocumentFragment',
 *        'DOMParser',
 *        'XMLSerializer',
 *        'NodeFilter',
 *        'InputEvent',
 *        'CustomEvent'
 *    ].forEach(key => (getGlobal()[key] = window[key]));
 * }
 * ```
 *
 * @return {DocumentFragment}
 * @license AGPLv3
 * @since 1.6.0
 * @copyright Volker Schukai
 * @throws {Error} not supported environment
 * @throws {TypeError} value is not a string
 */
function getDocumentFragmentFromString(html) {
	validateString(html);

	const document = getDocument();
	const template = document.createElement("template");
	template.innerHTML = html;

	return template.content;
}

/**
 * Recursively searches upwards from a given element to find an ancestor element
 * with a specified ID, considering both normal DOM and shadow DOM.
 *
 * @param {HTMLElement|ShadowRoot} element - The starting element or shadow root to search from.
 * @param {string} targetId - The ID of the target element to find.
 * @return {HTMLElement|null} - The ancestor element with the specified ID, or null if not found.
 * @since 3.29.0
 * @license AGPLv3
 * @copyright Volker Schukai
 */
function findElementWithIdUpwards(element, targetId) {
	if (!element) {
		return null;
	}

	// Check if the current element has the target ID
	if (element.id === targetId) {
		return element;
	}

	// Search within the current element's shadow root, if it exists
	if (element.shadowRoot) {
		const target = element.shadowRoot.getElementById(targetId);
		if (target) {
			return target;
		}
	}

	// If the current element is the document.documentElement, search within the main document
	if (element === document.documentElement || element === document) {
		const target = document.getElementById(targetId);
		if (target) {
			return target;
		}
	}

	// If the current element is inside a shadow root, search its host's ancestors
	const rootNode = element.getRootNode();
	if (rootNode && rootNode instanceof ShadowRoot) {
		return findElementWithIdUpwards(rootNode.host, targetId);
	}

	// Otherwise, search the current element's parent
	return findElementWithIdUpwards(element.parentElement, targetId);
}

/**
 * Recursively searches upwards from a given element to find an ancestor element
 * with a specified selector, considering both normal DOM and shadow DOM.
 * This method is useful for finding a parent element with a specific class.
 *
 * @param {HTMLElement|ShadowRoot} element - The starting element or shadow root to search from.
 * @param {string} selector - The selector of the target element to find.
 * @return {HTMLElement|null} - The ancestor element with the specified selector, or null if not found.
 * @since 3.55.0
 */
function findElementWithSelectorUpwards(element, selector) {
	if (!element || !selector) {
		return null;
	}

	// Search within the current element's shadow root, if it exists
	if (element.shadowRoot) {
		const target = element.shadowRoot.querySelector(selector);
		if (target) {
			return target;
		}
	}

	if (element === document.documentElement) {
		const target = document.querySelector(selector);
		if (target) {
			return target;
		}
	}

	// If the current element is inside a shadow root, search its host's ancestors
	const rootNode = element.getRootNode();
	if (rootNode && rootNode instanceof ShadowRoot) {
		return findElementWithSelectorUpwards(rootNode.host, selector);
	}

	// Otherwise, search the current element's parent
	return findElementWithSelectorUpwards(element.parentElement, selector);
}

/**
 * @private
 * @param {HTMLElement} element
 * @return {HTMLElement|null}
 */
function traverseShadowRoots(element) {
	let currentRoot = element.shadowRoot;
	let currentParent = element.parentNode;

	while (
		currentParent &&
		currentParent.nodeType !== Node.DOCUMENT_NODE &&
		currentParent.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
	) {
		if (currentRoot && currentRoot.parentNode) {
			currentParent = currentRoot.parentNode;
			currentRoot = currentParent.shadowRoot;
		} else if (currentParent.parentNode) {
			currentParent = currentParent.parentNode;
			currentRoot = null;
		} else if (
			currentRoot &&
			currentRoot.host &&
			currentRoot.host.nodeType === Node.DOCUMENT_NODE
		) {
			currentParent = currentRoot.host;
			currentRoot = null;
		} else {
			currentParent = null;
			currentRoot = null;
		}
	}

	return currentParent;
}

/**
 * Recursively searches upwards from a given element to find an ancestor element
 *
 * @param {HTMLElement} element
 * @return {*}
 * @throws {Error} Invalid argument. Expected an HTMLElement.
 * @since 3.36.0
 */
function getContainingDocument(element) {
	if (
		!element ||
		!(
			element instanceof HTMLElement ||
			element instanceof element.ownerDocument.defaultView.HTMLElement
		)
	) {
		throw new Error("Invalid argument. Expected an HTMLElement.");
	}

	return traverseShadowRoots(element) || null;
}

/**
 * Returns a list of all registered custom elements in the current document.
 *
 * @return {string[]}
 * @since 4.0.0
 * @return {string[]}
 */
function getRegisteredCustomElements() {
	const customElementTags = Array.from(document.querySelectorAll("*"))
		.map((tag) => tag.tagName.toLowerCase())
		.filter((tagName) => tagName.includes("-") && customElements.get(tagName));

	return Array.from(new Set(customElementTags));
}

/**
 * Waits until a specific custom element instance is defined and upgraded.
 *
 * @param {HTMLElement} element
 * @param {Object} [options]
 * @param {string} [options.method] - Optional method to wait for on the instance.
 * @param {string} [options.tagName] - Optional tag name override.
 * @param {number|null} [options.timeout=2000] - Timeout in ms; set null to disable.
 * @return {Promise<HTMLElement>}
 * @since 4.1.0
 */
function waitForCustomElement(
	element,
	{ method = null, tagName = null, timeout = 2000, readyCheck = null } = {},
) {
	if (!(element instanceof HTMLElement)) {
		return Promise.reject(
			new Error("Invalid argument. Expected an HTMLElement."),
		);
	}

	const name = (tagName || element.tagName || "").toLowerCase();
	if (!name.includes("-")) {
		return Promise.reject(
			new Error("Invalid argument. Expected a custom element tag name."),
		);
	}

	const window = getWindow();
	const registry = window.customElements;
	if (!registry) {
		return Promise.reject(new Error("customElements is not supported."));
	}

	return registry.whenDefined(name).then(
		() =>
			new Promise((resolve, reject) => {
				if (typeof registry.upgrade === "function") {
					registry.upgrade(element);
				}

				const start = Date.now();
				const isReady = () => {
					if (method && typeof element[method] !== "function") {
						return false;
					}
					if (typeof readyCheck === "function") {
						return readyCheck(element) === true;
					}
					return true;
				};

				const check = () => {
					if (isReady()) {
						resolve(element);
						return;
					}

					if (
						typeof timeout === "number" &&
						timeout >= 0 &&
						Date.now() - start > timeout
					) {
						reject(
							new Error(`Timed out waiting for "${method}" on <${name}>.`),
						);
						return;
					}

					window.setTimeout(check, 10);
				};

				window.setTimeout(check, 10);
			}),
	);
}
