import type Element from '../nodes/element/Element.js';
import * as PropertySymbol from '../PropertySymbol.js';
import type SelectorItem from './SelectorItem.js';
import NodeList from '../nodes/node/NodeList.js';
import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js';
import SelectorCombinatorEnum from './SelectorCombinatorEnum.js';
import type Document from '../nodes/document/Document.js';
import type DocumentFragment from '../nodes/document-fragment/DocumentFragment.js';
import SelectorParser from './SelectorParser.js';
import type ISelectorMatch from './ISelectorMatch.js';
import type IHTMLElementTagNameMap from '../config/IHTMLElementTagNameMap.js';
import type ISVGElementTagNameMap from '../config/ISVGElementTagNameMap.js';
import type ICachedQuerySelectorAllItem from '../nodes/node/ICachedQuerySelectorAllResult.js';
import type ICachedQuerySelectorItem from '../nodes/node/ICachedQuerySelectorResult.js';
import type ICachedMatchesItem from '../nodes/node/ICachedMatchesResult.js';

type DocumentPositionAndElement = {
	documentPosition: string;
	element: Element;
};

/**
 * Invalid Selector RegExp.
 */
const INVALID_SELECTOR_REGEXP = /^[.#\[]?\d|[.#]$/;

/**
 * Utility for query selection in an HTML element.
 *
 * @class QuerySelector
 */
export default class QuerySelector {
	/**
	 * Finds elements based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML elements.
	 */
	public static querySelectorAll<K extends keyof IHTMLElementTagNameMap>(
		node: Element | Document | DocumentFragment,
		selector: K
	): NodeList<IHTMLElementTagNameMap[K]>;

	/**
	 * Finds elements based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML elements.
	 */
	public static querySelectorAll<K extends keyof ISVGElementTagNameMap>(
		node: Element | Document | DocumentFragment,
		selector: K
	): NodeList<ISVGElementTagNameMap[K]>;

	/**
	 * Finds elements based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML elements.
	 */
	public static querySelectorAll(
		node: Element | Document | DocumentFragment,
		selector: string
	): NodeList<Element>;

	/**
	 * Finds elements based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML elements.
	 */
	public static querySelectorAll(
		node: Element | Document | DocumentFragment,
		selector: string
	): NodeList<Element> {
		const window = node[PropertySymbol.window];

		if (<string>selector === '') {
			throw new window.DOMException(
				`Failed to execute 'querySelectorAll' on '${node.constructor.name}': The provided selector is empty.`
			);
		}

		if (typeof selector === 'function') {
			throw new window.DOMException(
				`Failed to execute 'querySelectorAll' on '${node.constructor.name}': '${selector}' is not a valid selector.`
			);
		}

		if (typeof selector === 'symbol') {
			throw new window.TypeError(
				`Failed to execute 'querySelectorAll' on '${node.constructor.name}': Cannot convert a Symbol value to a string`
			);
		}

		selector = String(selector);

		if (INVALID_SELECTOR_REGEXP.test(selector)) {
			throw new window.DOMException(
				`Failed to execute 'querySelectorAll' on '${node.constructor.name}': '${selector}' is not a valid selector.`
			);
		}

		const cache = node[PropertySymbol.cache].querySelectorAll;
		const cachedResult = cache.get(selector);

		if (cachedResult) {
			if (cachedResult.result !== null) {
				const result = cachedResult.result.deref();
				if (result) {
					return result;
				}
			}
			cache.delete(selector);
		}

		const scope =
			node[PropertySymbol.nodeType] === NodeTypeEnum.documentNode
				? (<Document>node).documentElement
				: node;
		const groups = new SelectorParser({ window, scope }).getSelectorGroups(selector);
		const items: Element[] = [];
		const nodeList = new NodeList<Element>(PropertySymbol.illegalConstructor, items);
		const matchesMap: Map<string, Element> = new Map();
		const matchedPositions: string[] = [];
		const cachedItem = {
			result: new WeakRef(nodeList)
		};
		node[PropertySymbol.cache].querySelectorAll.set(selector, cachedItem);

		if (node[PropertySymbol.isConnected]) {
			// Document is affected for the ":target" selector
			(node[PropertySymbol.ownerDocument] || node)[PropertySymbol.affectsCache].push(cachedItem);
		}

		for (const selectorItems of groups) {
			const rootElement =
				node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode ? <Element>node : null;
			const children =
				node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode
					? [<Element>node]
					: (<Element>node)[PropertySymbol.elementArray];
			const matches = this.findAll({ scope, rootElement, children, selectorItems, cachedItem });

			for (const match of matches) {
				if (!matchesMap.has(match.documentPosition)) {
					matchesMap.set(match.documentPosition, match.element);
					matchedPositions.push(match.documentPosition);
				}
			}
		}

		const keys = matchedPositions.sort();

		for (let i = 0, max = keys.length; i < max; i++) {
			items.push(matchesMap.get(keys[i])!);
		}

		return nodeList;
	}

	/**
	 * Finds an element based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML element.
	 */
	public static querySelector<K extends keyof IHTMLElementTagNameMap>(
		node: Element | Document | DocumentFragment,
		selector: K
	): IHTMLElementTagNameMap[K] | null;

	/**
	 * Finds an element based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML element.
	 */
	public static querySelector<K extends keyof ISVGElementTagNameMap>(
		node: Element | Document | DocumentFragment,
		selector: K
	): ISVGElementTagNameMap[K] | null;

	/**
	 * Finds an element based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML element.
	 */
	public static querySelector(
		node: Element | Document | DocumentFragment,
		selector: string
	): Element | null;

	/**
	 * Finds an element based on a query selector.
	 *
	 * @param node Node to search in.
	 * @param selector Selector.
	 * @returns HTML element.
	 */
	public static querySelector(
		node: Element | Document | DocumentFragment,
		selector: string
	): Element | null {
		const window = node[PropertySymbol.window];

		if (selector === '') {
			throw new window.DOMException(
				`Failed to execute 'querySelector' on '${node.constructor.name}': The provided selector is empty.`
			);
		}

		if (typeof selector === 'function') {
			throw new window.DOMException(
				`Failed to execute 'querySelector' on '${node.constructor.name}': '${selector}' is not a valid selector.`
			);
		}

		if (typeof selector === 'symbol') {
			throw new window.TypeError(
				`Failed to execute 'querySelector' on '${node.constructor.name}': Cannot convert a Symbol value to a string`
			);
		}

		selector = String(selector);

		if (INVALID_SELECTOR_REGEXP.test(selector)) {
			throw new window.DOMException(
				`Failed to execute 'querySelector' on '${node.constructor.name}': '${selector}' is not a valid selector.`
			);
		}

		const cachedResult = node[PropertySymbol.cache].querySelector.get(selector);

		if (cachedResult) {
			if (cachedResult.result !== null) {
				if (!cachedResult.result.element) {
					return null;
				}
				const result = cachedResult.result.element.deref();
				if (result) {
					return result;
				}
			}
			node[PropertySymbol.cache].querySelector.delete(selector);
		}

		const cachedItem: ICachedQuerySelectorItem = {
			result: { element: null }
		};

		node[PropertySymbol.cache].querySelector.set(selector, cachedItem);

		if (node[PropertySymbol.isConnected]) {
			// Document is affected for the ":target" selector
			(node[PropertySymbol.ownerDocument] || node)[PropertySymbol.affectsCache].push(cachedItem);
		}

		let bestMatch: DocumentPositionAndElement | null = null;
		const matchesMap: Map<string, boolean> = new Map();
		const scope =
			node[PropertySymbol.nodeType] === NodeTypeEnum.documentNode
				? (<Document>node).documentElement
				: node;

		for (const selectorItems of new SelectorParser({ window, scope }).getSelectorGroups(selector)) {
			const rootElement =
				node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode ? <Element>node : null;
			const children =
				node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode
					? [<Element>node]
					: (<Element>node)[PropertySymbol.elementArray];
			const match = this.findFirst({ scope, rootElement, children, selectorItems, cachedItem });

			if (match && !matchesMap.has(match.documentPosition)) {
				matchesMap.set(match.documentPosition, true);
				if (!bestMatch || match.documentPosition < bestMatch.documentPosition) {
					bestMatch = match;
				}
			}
		}

		const element = bestMatch?.element || null;

		cachedItem.result = { element: element ? new WeakRef(element) : null };

		return element;
	}

	/**
	 * Checks if an element matches a selector and returns priority weight.
	 *
	 * @param element Element to match.
	 * @param selector Selector to match with.
	 * @param [options] Options.
	 * @param [options.scope] Scope.
	 * @param [options.ignoreErrors] Ignores errors.
	 * @returns Result.
	 */
	public static matches(
		element: Element,
		selector: string,
		options?: { scope?: Element | Document | DocumentFragment | null; ignoreErrors?: boolean }
	): ISelectorMatch | null {
		const ignoreErrors = options?.ignoreErrors;
		const window = element[PropertySymbol.window];

		if (selector === '*') {
			return {
				priorityWeight: 1
			};
		}

		if (<string>selector === '') {
			if (ignoreErrors) {
				return null;
			}
			throw new window.DOMException(
				`Failed to execute 'matches' on '${element.constructor.name}': The provided selector is empty.`
			);
		}

		if (typeof selector === 'function') {
			if (ignoreErrors) {
				return null;
			}
			throw new window.DOMException(
				`Failed to execute 'matches' on '${element.constructor.name}': '${selector}' is not a valid selector.`
			);
		}

		if (typeof selector === 'symbol') {
			if (ignoreErrors) {
				return null;
			}
			throw new window.TypeError(`Cannot convert a Symbol value to a string`);
		}

		selector = String(selector);

		if (INVALID_SELECTOR_REGEXP.test(selector)) {
			if (ignoreErrors) {
				return null;
			}
			throw new window.DOMException(
				`Failed to execute 'matches' on '${element.constructor.name}': '${selector}' is not a valid selector.`
			);
		}

		const cachedResult = element[PropertySymbol.cache].matches.get(selector);

		if (cachedResult) {
			if (cachedResult.result !== null) {
				return cachedResult.result.match;
			}
			element[PropertySymbol.cache].matches.delete(selector);
		}

		const cachedItem: ICachedMatchesItem = {
			result: { match: null }
		};

		element[PropertySymbol.cache].matches.set(selector, cachedItem);

		if (element[PropertySymbol.isConnected]) {
			// Document is affected for the ":target" selector
			(element[PropertySymbol.ownerDocument] || element)[PropertySymbol.affectsCache].push(
				cachedItem
			);
		}

		const scopeOrElement = options?.scope || element;
		const scope =
			scopeOrElement[PropertySymbol.nodeType] === NodeTypeEnum.documentNode
				? (<Document>scopeOrElement).documentElement
				: scopeOrElement;
		for (const items of new SelectorParser({
			ignoreErrors: options?.ignoreErrors,
			window,
			scope
		}).getSelectorGroups(selector)) {
			const result = this.matchSelector({
				scope,
				element,
				selectorItems: items.slice().reverse(),
				cachedItem,
				ignoreErrors
			});

			if (result) {
				cachedItem.result!.match = result;
				return result;
			}
		}

		return null;
	}

	/**
	 * Checks if a node matches a selector.
	 *
	 * @param options Options.
	 * @param options.scope Scope.
	 * @param options.element Target element.
	 * @param options.currentElement
	 * @param options.selectorItems Selector items.
	 * @param options.cachedItem Cached item.
	 * @param [options.ignoreErrors] Ignores errors.
	 * @param [previousSelectorItem] Previous selector item.
	 * @param [priorityWeight] Priority weight.
	 * @returns Result.
	 */
	private static matchSelector(
		options: {
			scope: Element | Document | DocumentFragment | null;
			element: Element;
			selectorItems: SelectorItem[];
			cachedItem: ICachedMatchesItem;
			ignoreErrors?: boolean;
		},
		previousSelectorItem: SelectorItem | null = null,
		priorityWeight = 0
	): ISelectorMatch | null {
		const { scope, element, selectorItems, cachedItem, ignoreErrors } = options;
		const selectorItem = selectorItems[0];

		if (!selectorItem) {
			return null;
		}

		const result = selectorItem.match(scope, element, ignoreErrors);

		if (result) {
			if (selectorItems.length === 1) {
				return {
					priorityWeight: priorityWeight + result.priorityWeight
				};
			}

			switch (selectorItem.combinator) {
				case SelectorCombinatorEnum.adjacentSibling:
					const previousElementSibling = element.previousElementSibling;
					if (previousElementSibling) {
						previousElementSibling[PropertySymbol.affectsCache].push(cachedItem);

						const match = this.matchSelector(
							{
								scope,
								element: previousElementSibling,
								selectorItems: selectorItems.slice(1),
								cachedItem: cachedItem,
								ignoreErrors
							},
							selectorItem,
							priorityWeight + result.priorityWeight
						);

						if (match) {
							return match;
						}
					}
					break;
				case SelectorCombinatorEnum.none:
				case SelectorCombinatorEnum.child:
				case SelectorCombinatorEnum.descendant:
					const parentElement = element.parentElement;
					if (parentElement) {
						parentElement[PropertySymbol.affectsCache].push(cachedItem);

						const match = this.matchSelector(
							{
								scope,
								element: parentElement,
								selectorItems: selectorItems.slice(1),
								cachedItem: cachedItem,
								ignoreErrors
							},
							selectorItem,
							priorityWeight + result.priorityWeight
						);

						if (match) {
							return match;
						}
					}
					break;
				case SelectorCombinatorEnum.subsequentSibling:
					const siblingParentElement = element.parentElement;
					if (siblingParentElement) {
						const siblings = siblingParentElement[PropertySymbol.elementArray];
						const index = siblings.indexOf(element);

						siblingParentElement[PropertySymbol.affectsCache].push(cachedItem);

						for (let i = index - 1; i >= 0; i--) {
							const sibling = siblings[i];

							sibling[PropertySymbol.affectsCache].push(cachedItem);

							const match = this.matchSelector(
								{
									scope,
									element: sibling,
									selectorItems: selectorItems.slice(1),
									cachedItem: cachedItem,
									ignoreErrors
								},
								selectorItem,
								priorityWeight + result.priorityWeight
							);

							if (match) {
								return match;
							}
						}
					}
					break;
			}
		}

		if (
			previousSelectorItem?.combinator === SelectorCombinatorEnum.none ||
			previousSelectorItem?.combinator === SelectorCombinatorEnum.descendant
		) {
			const parentElement = element.parentElement;
			if (parentElement) {
				return this.matchSelector(
					{
						scope,
						element: parentElement,
						selectorItems: selectorItems,
						cachedItem: cachedItem,
						ignoreErrors
					},
					previousSelectorItem,
					priorityWeight
				);
			}
		}

		return null;
	}

	/**
	 * Finds elements based on a query selector for a part of a list of selectors separated with comma.
	 *
	 * @param options Options.
	 * @param options.scope Scope.
	 * @param options.rootElement Root element.
	 * @param options.children Child elements.
	 * @param options.selectorItems Selector items.
	 * @param options.cachedItem Cached item.
	 * @param [documentPosition] Document position of the element.
	 * @returns Document position and element map.
	 */
	private static findAll(
		options: {
			scope: Element | Document | DocumentFragment | null;
			rootElement: Element | null;
			children: Element[];
			selectorItems: SelectorItem[];
			cachedItem: ICachedQuerySelectorAllItem;
		},
		documentPosition?: string
	): DocumentPositionAndElement[] {
		const { scope, rootElement, children, selectorItems, cachedItem } = options;
		const selectorItem = selectorItems[0];
		const nextSelectorItem = selectorItems[1];
		let matched: DocumentPositionAndElement[] = [];

		if (!selectorItem) {
			return [];
		}

		for (let i = 0, max = children.length; i < max; i++) {
			const child = children[i];
			const childrenOfChild = (<Element>child)[PropertySymbol.elementArray];
			const position = (documentPosition ? documentPosition + '>' : '') + String.fromCharCode(i);

			child[PropertySymbol.affectsCache].push(cachedItem);

			if (selectorItem.match(scope, child)) {
				if (!nextSelectorItem) {
					if (rootElement !== child) {
						matched.push({
							documentPosition: position,
							element: child
						});
					}
				} else {
					switch (nextSelectorItem.combinator) {
						case SelectorCombinatorEnum.adjacentSibling:
							const nextElementSibling = child.nextElementSibling;
							if (nextElementSibling) {
								matched = matched.concat(
									this.findAll(
										{
											scope,
											rootElement,
											children: [nextElementSibling],
											selectorItems: selectorItems.slice(1),
											cachedItem: cachedItem
										},
										position
									)
								);
							}
							break;
						case SelectorCombinatorEnum.none:
						case SelectorCombinatorEnum.descendant:
						case SelectorCombinatorEnum.child:
							matched = matched.concat(
								this.findAll(
									{
										scope,
										rootElement,
										children: childrenOfChild,
										selectorItems: selectorItems.slice(1),
										cachedItem
									},
									position
								)
							);
							break;
						case SelectorCombinatorEnum.subsequentSibling:
							const index = children.indexOf(child);
							for (let j = index + 1; j < children.length; j++) {
								const sibling = children[j];
								matched = matched.concat(
									this.findAll(
										{
											scope,
											rootElement,
											children: [sibling],
											selectorItems: selectorItems.slice(1),
											cachedItem
										},
										position
									)
								);
							}
							break;
					}
				}
			}

			if (
				(selectorItem.combinator === SelectorCombinatorEnum.none ||
					selectorItem.combinator === SelectorCombinatorEnum.descendant) &&
				childrenOfChild.length
			) {
				matched = matched.concat(
					this.findAll(
						{
							scope,
							rootElement,
							children: childrenOfChild,
							selectorItems: selectorItems,
							cachedItem
						},
						position
					)
				);
			}
		}

		return matched;
	}

	/**
	 * Finds an element based on a query selector for a part of a list of selectors separated with comma.
	 *
	 * @param options Options.
	 * @param options.scope Scope.
	 * @param options.rootElement Root element.
	 * @param options.children Child elements.
	 * @param options.selectorItems Selector items.
	 * @param options.cachedItem Cached item.
	 * @param [documentPosition] Document position of the element.
	 * @returns Document position and element map.
	 */
	private static findFirst(
		options: {
			scope: Element | Document | DocumentFragment | null;
			rootElement: Element | null;
			children: Element[];
			selectorItems: SelectorItem[];
			cachedItem: ICachedQuerySelectorItem;
		},
		documentPosition?: string
	): DocumentPositionAndElement | null {
		const { scope, rootElement, children, selectorItems, cachedItem } = options;
		const selectorItem = selectorItems[0];
		const nextSelectorItem = selectorItems[1];

		if (!selectorItem) {
			return null;
		}

		for (let i = 0, max = children.length; i < max; i++) {
			const child = children[i];
			const childrenOfChild = (<Element>child)[PropertySymbol.elementArray];
			const position = (documentPosition ? documentPosition + '>' : '') + String.fromCharCode(i);

			child[PropertySymbol.affectsCache].push(cachedItem);

			if (selectorItem.match(scope, child)) {
				if (!nextSelectorItem) {
					if (rootElement !== child) {
						return { documentPosition: position, element: child };
					}
				} else {
					switch (nextSelectorItem.combinator) {
						case SelectorCombinatorEnum.adjacentSibling:
							const nextElementSibling = child.nextElementSibling;
							if (nextElementSibling) {
								const match = this.findFirst(
									{
										scope,
										rootElement,
										children: [nextElementSibling],
										selectorItems: selectorItems.slice(1),
										cachedItem
									},
									position
								);
								if (match) {
									return match;
								}
							}
							break;
						case SelectorCombinatorEnum.none:
						case SelectorCombinatorEnum.descendant:
						case SelectorCombinatorEnum.child:
							const match = this.findFirst(
								{
									scope,
									rootElement,
									children: childrenOfChild,
									selectorItems: selectorItems.slice(1),
									cachedItem
								},
								position
							);
							if (match) {
								return match;
							}
							break;
						case SelectorCombinatorEnum.subsequentSibling:
							const index = children.indexOf(child);
							for (let i = index + 1; i < children.length; i++) {
								const sibling = children[i];
								const match = this.findFirst(
									{
										scope,
										rootElement,
										children: [sibling],
										selectorItems: selectorItems.slice(1),
										cachedItem
									},
									position
								);
								if (match) {
									return match;
								}
							}
							break;
					}
				}
			}

			if (
				(selectorItem.combinator === SelectorCombinatorEnum.none ||
					selectorItem.combinator === SelectorCombinatorEnum.descendant) &&
				childrenOfChild.length
			) {
				const match = this.findFirst(
					{
						scope,
						rootElement,
						children: childrenOfChild,
						selectorItems,
						cachedItem
					},
					position
				);

				if (match) {
					return match;
				}
			}
		}

		return null;
	}
}
