/**
 * 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 { getGlobalFunction } from "../types/global.mjs";
import { TokenList } from "../types/tokenlist.mjs";
import {
	validateInstance,
	validateString,
	validateSymbol,
} from "../types/validate.mjs";
import { ATTRIBUTE_OBJECTLINK } from "./constants.mjs";

export {
	findClosestObjectLink,
	addToObjectLink,
	removeObjectLink,
	hasObjectLink,
	getLinkedObjects,
	toggleAttributeToken,
	addAttributeToken,
	removeAttributeToken,
	containsAttributeToken,
	replaceAttributeToken,
	clearAttributeTokens,
	findClosestByAttribute,
	findClosestByClass,
};

/**
 * Get the closest object link of a node
 *
 * if a node is specified without a object link, a recursive search upwards is performed until the corresponding
 * object link is found, or undefined is returned.
 *
 * @param {HTMLElement} element
 * @return {HTMLElement|undefined}
 * @license AGPLv3
 * @since 1.10.0
 * @copyright Volker Schukai
 * @throws {TypeError} value is not an instance of HTMLElement
 */
function findClosestObjectLink(element) {
	return findClosestByAttribute(element, ATTRIBUTE_OBJECTLINK);
}

/**
 * Adds a class attribute to an element.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param element
 * @param symbol
 * @param object
 * @return {*}
 */
function addToObjectLink(element, symbol, object) {
	validateInstance(element, HTMLElement);
	validateSymbol(symbol);

	if (element?.[symbol] === undefined) {
		element[symbol] = new Set();
	}

	addAttributeToken(element, ATTRIBUTE_OBJECTLINK, symbol.toString());
	element[symbol].add(object);
	return element;
}

/**
 * Removes an object from an element
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {Symbol} symbol
 * @return {*}
 */
function removeObjectLink(element, symbol) {
	validateInstance(element, HTMLElement);
	validateSymbol(symbol);

	if (element?.[symbol] === undefined) {
		return element;
	}

	removeAttributeToken(element, ATTRIBUTE_OBJECTLINK, symbol.toString());
	delete element[symbol];
	return element;
}

/**
 * Checks if an element has an object link
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {Symbol} symbol
 * @return {boolean}
 */
function hasObjectLink(element, symbol) {
	validateInstance(element, HTMLElement);
	validateSymbol(symbol);

	if (element?.[symbol] === undefined) {
		return false;
	}

	return containsAttributeToken(
		element,
		ATTRIBUTE_OBJECTLINK,
		symbol.toString(),
	);
}

/**
 * The ObjectLink can be used to attach objects to HTMLElements. The elements are kept in a set under a unique
 * symbol and can be read via an iterator {@see {@link getLinkedObjects}}.
 *
 * In addition, elements with an objectLink receive the attribute `data-monster-objectlink`.
 *
 * With the method  {@see {@link addToObjectLink}} the objects can be added.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {Symbol} symbol
 * @return {Iterator}
 * @throws {Error} there is no object link for symbol
 */
function getLinkedObjects(element, symbol) {
	validateInstance(element, HTMLElement);
	validateSymbol(symbol);

	if (element?.[symbol] === undefined) {
		throw new Error(`there is no object link for ${symbol.toString()}`);
	}

	return element?.[symbol][Symbol.iterator]();
}

/**
 * With this method tokens in an attribute can be switched on or off. For example, classes can be switched on and off in the elements class attribute.
 *
 * Tokens are always separated by a space.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} key
 * @param {string} token
 * @return {HTMLElement}
 */
function toggleAttributeToken(element, key, token) {
	validateInstance(element, HTMLElement);
	validateString(token);
	validateString(key);

	if (!element.hasAttribute(key)) {
		element.setAttribute(key, token);
		return element;
	}

	element.setAttribute(
		key,
		new TokenList(element.getAttribute(key)).toggle(token).toString(),
	);

	return element;
}

/**
 * This method can be used to add a token to an attribute. Tokens are always separated by a space.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} key
 * @param {string} token
 * @return {HTMLElement}
 */
function addAttributeToken(element, key, token) {
	validateInstance(element, HTMLElement);
	validateString(token);
	validateString(key);

	if (!element.hasAttribute(key)) {
		element.setAttribute(key, token);
		return element;
	}

	element.setAttribute(
		key,
		new TokenList(element.getAttribute(key)).add(token).toString(),
	);

	return element;
}

/**
 * This function can be used to remove tokens from an attribute.
 *
 * Tokens are always separated by a space.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} key
 * @param {string} token
 * @return {HTMLElement}
 */
function removeAttributeToken(element, key, token) {
	validateInstance(element, HTMLElement);
	validateString(token);
	validateString(key);

	if (!element.hasAttribute(key)) {
		return element;
	}

	element.setAttribute(
		key,
		new TokenList(element.getAttribute(key)).remove(token).toString(),
	);

	return element;
}

/**
 * This method can be used to determine whether an attribute has a token.
 *
 * Tokens are always separated by a space.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} key
 * @param {string} token
 * @return {boolean}
 */
function containsAttributeToken(element, key, token) {
	validateInstance(element, HTMLElement);
	validateString(token);
	validateString(key);

	if (!element.hasAttribute(key)) {
		return false;
	}

	return new TokenList(element.getAttribute(key)).contains(token);
}

/**
 * Tokens are always separated by a space.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} key
 * @param {string} from
 * @param {string} to
 * @return {HTMLElement}
 */
function replaceAttributeToken(element, key, from, to) {
	validateInstance(element, HTMLElement);
	validateString(from);
	validateString(to);
	validateString(key);

	if (!element.hasAttribute(key)) {
		return element;
	}

	element.setAttribute(
		key,
		new TokenList(element.getAttribute(key)).replace(from, to).toString(),
	);

	return element;
}

/**
 * Tokens are always separated by a space.
 *
 * @license AGPLv3
 * @since 1.9.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} key
 * @return {HTMLElement}
 */
function clearAttributeTokens(element, key) {
	validateInstance(element, HTMLElement);
	validateString(key);

	if (!element.hasAttribute(key)) {
		return element;
	}

	element.setAttribute(key, "");

	return element;
}

/**
 * This function searches, starting from an `HTMLElemement`, for the next element that has a certain attribute.
 *
 * ```html
 * <div data-my-attribute="2" id="2">
 *     <div id="1"></div>
 * </div>
 * ```
 *
 * ```javascript
 * // if no value is specified (undefined), then only the attribute is checked.
 * findClosestByAttribute(document.getElementById('1'),'data-my-attribute'); // ↦ node with id 2
 * findClosestByAttribute(document.getElementById('2'),'data-my-attribute'); // ↦ node with id 2
 *
 * // if a value is specified, for example an empty string, then the name and the value are checked.
 * findClosestByAttribute(document.getElementById('1'),'data-my-attribute', '');  // ↦ undefined
 * findClosestByAttribute(document.getElementById('1'),'data-my-attribute', '2'); // ↦ node with id 2
 * ```
 *
 * @license AGPLv3
 * @since 1.14.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} key
 * @param {string|undefined} value
 * @return {HTMLElement|undefined}
 * @summary find closest node
 */
function findClosestByAttribute(element, key, value) {
	validateInstance(element, getGlobalFunction("HTMLElement"));

	if (element.hasAttribute(key)) {
		if (value === undefined) {
			return element;
		}

		if (element.getAttribute(key) === value) {
			return element;
		}
	}

	let selector = validateString(key);
	if (value !== undefined) selector += `=${validateString(value)}`;
	const result = element.closest(`[${selector}]`);
	if (result instanceof HTMLElement) {
		return result;
	}
	return undefined;
}

/**
 * This function searches, starting from an `HTMLElement`, for the next element that has a certain attribute.
 *
 * ```html
 * <div class="myclass" id="2">
 *     <div id="1"></div>
 * </div>
 * ```
 *
 * ```javascript
 * // if no value is specified (undefined), then only the attribute is checked.
 * findClosestByClass(document.getElementById('1'),'myclass'); // ↦ node with id 2
 * findClosestByClass(document.getElementById('2'),'myclass'); // ↦ node with id 2
 * ```
 *
 * ```
 * <script type="module">
 * import {findClosestByClass} from '@schukai/monster/source/dom/attributes.mjs';
 * findClosestByClass();
 * </script>
 * ```
 *
 * @license AGPLv3
 * @since 1.27.0
 * @copyright Volker Schukai
 * @param {HTMLElement} element
 * @param {string} className
 * @return {HTMLElement|undefined}
 * @summary find closest node
 */
function findClosestByClass(element, className) {
	validateInstance(element, getGlobalFunction("HTMLElement"));

	if (element?.classList?.contains(validateString(className))) {
		return element;
	}

	const result = element.closest(`.${className}`);
	if (result instanceof HTMLElement) {
		return result;
	}

	return undefined;
}
