/**
 * 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 { internalSymbol } from "../constants.mjs";
import { diff } from "../data/diff.mjs";
import { Pathfinder } from "../data/pathfinder.mjs";
import { Pipe } from "../data/pipe.mjs";
import {
	ATTRIBUTE_ERRORMESSAGE,
	ATTRIBUTE_UPDATER_ATTRIBUTES,
	ATTRIBUTE_UPDATER_BIND,
	ATTRIBUTE_UPDATER_BIND_TYPE,
	ATTRIBUTE_UPDATER_INSERT,
	ATTRIBUTE_UPDATER_INSERT_REFERENCE,
	ATTRIBUTE_UPDATER_PROPERTIES,
	ATTRIBUTE_UPDATER_PATCH,
	ATTRIBUTE_UPDATER_PATCH_KEY,
	ATTRIBUTE_UPDATER_PATCH_RENDER,
	ATTRIBUTE_UPDATER_REMOVE,
	ATTRIBUTE_UPDATER_REPLACE,
	ATTRIBUTE_UPDATER_SELECT_THIS,
	customElementUpdaterLinkSymbol,
} from "./constants.mjs";

import { Base } from "../types/base.mjs";
import {
	isArray,
	isInteger,
	isString,
	isInstance,
	isIterable,
} from "../types/is.mjs";
import { Observer } from "../types/observer.mjs";
import { ProxyObserver } from "../types/proxyobserver.mjs";
import { validateArray, validateInstance } from "../types/validate.mjs";
import { clone } from "../util/clone.mjs";
import { trimSpaces } from "../util/trimspaces.mjs";
import {
	addAttributeToken,
	addToObjectLink,
	getLinkedObjects,
	hasObjectLink,
	removeObjectLink,
} from "./attributes.mjs";
import {
	CustomElement,
	updaterTransformerMethodsSymbol,
} from "./customelement.mjs";
import { findTargetElementFromEvent } from "./events.mjs";
import { findDocumentTemplate } from "./template.mjs";
import { getWindow } from "./util.mjs";
import { DeadMansSwitch } from "../util/deadmansswitch.mjs";
import { addErrorAttribute, removeErrorAttribute } from "./error.mjs";

export { Updater, addObjectWithUpdaterToElement };

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

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

/**
 * @private
 * @type {symbol}
 */
const processingSymbol = Symbol("processing");
const processQueueSymbol = Symbol("processQueue");
const applyChangeSymbol = Symbol("applyChange");
const updaterRootSymbol = Symbol.for("@schukai/monster/dom/@@updater-root");
const disposedSymbol = Symbol("disposed");
const subjectObserverSymbol = Symbol("subjectObserver");
const patchNodeKeySymbol = Symbol("patchNodeKey");
const queuedSnapshotSymbol = Symbol("queuedSnapshot");

/**
 * The updater class connects an object with the DOM. In this way, structures and contents in the DOM can be
 * programmatically adapted via attributes.
 *
 * For example, to include a string from an object, the attribute `data-monster-replace`
 * or the lifecycle-safe `data-monster-patch` can be used.
 * a further explanation can be found under [monsterjs.org](https://monsterjs.org/)
 *
 * Changes to attributes are made only when the direct values are changed. If you want to assign changes
 * to other values as well, you have to insert the attribute `data-monster-select-this`. This should be
 * done with care, as it can reduce performance.
 *
 * @fragments /fragments/libraries/dom/updater/
 *
 * @example /examples/libraries/dom/updater/simple/ Simple example
 * @example /examples/libraries/dom/updater/properties-and-patch/ Properties, patching and keyed list rendering
 * @example /examples/libraries/dom/updater/attributes-and-aria/ Attribute and ARIA mapping
 * @example /examples/libraries/dom/updater/bind-types/ Bound value casting
 * @example /examples/libraries/dom/updater/retrieve-once/ One-time retrieval without live events
 * @example /examples/libraries/dom/updater/insert-template-list/ Template insertion for iterable data
 * @example /examples/libraries/dom/updater/custom-callbacks/ Custom pipe callbacks
 * @example /examples/libraries/dom/updater/batch-updates/ Batched update processing
 * @example /examples/libraries/dom/updater/control-properties/ Property updates on native controls
 * @example /examples/libraries/dom/updater/select-multiple-array/ Multi-select array binding
 *
 * @license AGPLv3
 * @since 1.8.0
 * @copyright Volker Schukai
 * @throws {Error} the value is not iterable
 * @throws {Error} pipes are not allowed when cloning a node.
 * @throws {Error} no template was found with the specified key.
 * @throws {Error} the maximum depth for the recursion is reached.
 * @throws {TypeError} value is not a object
 * @throws {TypeError} value is not an instance of HTMLElement
 * @summary A DOM updater that binds structured data objects to templates and rendered document fragments.
 */
class Updater extends Base {
	/**
	 * @since 1.8.0
	 * @param {HTMLElement} element
	 * @param {object|ProxyObserver|undefined} subject
	 * @throws {TypeError} value is not a object
	 * @throws {TypeError} value is not an instance of HTMLElement
	 * @see {@link findDocumentTemplate}
	 */
	constructor(element, subject) {
		super();

		/**
		 * @type {HTMLElement}
		 */
		if (subject === undefined) subject = {};
		if (!isInstance(subject, ProxyObserver)) {
			subject = new ProxyObserver(subject);
		}

		this[internalSymbol] = {
			element: validateInstance(element, HTMLElement),
			last: {},
			callbacks: new Map(),
			eventTypes: ["keyup", "click", "change", "drop", "touchend", "input"],
			subject: subject,
			features: {
				batchUpdates: false,
			},
		};

		this[internalSymbol].callbacks.set(
			"checkstate",
			getCheckStateCallback.call(this),
		);

		this[pendingDiffsSymbol] = [];
		this[processingSymbol] = false;
		this[disposedSymbol] = false;
		this[queuedSnapshotSymbol] = clone(this[internalSymbol].last);

		this[subjectObserverSymbol] = new Observer(() => {
			if (this[disposedSymbol] === true) {
				return Promise.resolve();
			}

			const real = this[internalSymbol].subject.getRealSubject();
			const diffResult = diff(this[queuedSnapshotSymbol], real);
			if (diffResult.length === 0) {
				return Promise.resolve();
			}
			const snapshot = clone(real);
			this[queuedSnapshotSymbol] = snapshot;
			this[pendingDiffsSymbol].push({ diffResult, snapshot });
			return this[processQueueSymbol]();
		});
		this[internalSymbol].subject.attachObserver(this[subjectObserverSymbol]);
	}

	/**
	 * @private
	 * @return {Promise}
	 */
	async [processQueueSymbol]() {
		if (this[disposedSymbol] === true) {
			return Promise.resolve();
		}

		if (this[processingSymbol]) {
			return Promise.resolve();
		}
		this[processingSymbol] = true;

		try {
			while (this[pendingDiffsSymbol].length) {
				if (this[disposedSymbol] === true) {
					this[pendingDiffsSymbol].length = 0;
					return Promise.resolve();
				}

				const { diffResult, snapshot } = this[pendingDiffsSymbol].shift();
				if (this[internalSymbol].features.batchUpdates === true) {
					const updatePaths = new Map();
					for (const change of Object.values(diffResult)) {
						removeElement.call(this, change);
						insertElement.call(this, change);

						const path = isArray(change?.["path"]) ? change["path"] : null;
						if (!path) {
							continue;
						}
						if (path.length === 0) {
							updatePaths.set("", path);
							continue;
						}
						const root = path[0];
						if (!updatePaths.has(root)) {
							updatePaths.set(root, [root]);
						}
					}

					for (const path of updatePaths.values()) {
						updateContent.call(this, { path });
						updateAttributes.call(this, { path });
						updateProperties.call(this, { path });
					}
				} else {
					for (const change of Object.values(diffResult)) {
						await this[applyChangeSymbol](change);
					}
				}
				this[internalSymbol].last = clone(snapshot);
			}
		} catch (err) {
			addErrorAttribute(this[internalSymbol]?.element, err);
		} finally {
			this[processingSymbol] = false;
		}
	}

	/** @private **/
	async [applyChangeSymbol](change) {
		if (this[disposedSymbol] === true) {
			return Promise.resolve();
		}

		removeElement.call(this, change);
		insertElement.call(this, change);
		updateContent.call(this, change);
		await Promise.resolve();
		updateAttributes.call(this, change);
		updateProperties.call(this, change);
	}

	/**
	 * Defaults: 'keyup', 'click', 'change', 'drop', 'touchend'
	 *
	 * @see {@link https://developer.mozilla.org/de/docs/Web/Events}
	 * @since 1.9.0
	 * @param {Array} types
	 * @return {Updater}
	 */
	setEventTypes(types) {
		this[internalSymbol].eventTypes = validateArray(types);
		return this;
	}

	/**
	 * Enable or disable batched update processing.
	 *
	 * @since 4.104.0
	 * @param {boolean} enabled
	 * @return {Updater}
	 */
	setBatchUpdates(enabled) {
		this[internalSymbol].features.batchUpdates = enabled === true;
		return this;
	}

	/**
	 * With this method, the eventlisteners are hooked in and the magic begins.
	 *
	 * ```js
	 * updater.run().then(() => {
	 *   updater.enableEventProcessing();
	 * });
	 * ```
	 *
	 * @since 1.9.0
	 * @return {Updater}
	 * @throws {Error} the bind argument must start as a value with a path
	 */
	enableEventProcessing() {
		if (this[disposedSymbol] === true) {
			return this;
		}

		this.disableEventProcessing();

		this[internalSymbol].element[updaterRootSymbol] = true;

		for (const type of this[internalSymbol].eventTypes) {
			// @see https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
			this[internalSymbol].element.addEventListener(
				type,
				getControlEventHandler.call(this),
				{
					capture: true,
					passive: true,
				},
			);
		}

		return this;
	}

	/**
	 * This method turns off the magic or who loves it more profane it removes the eventListener.
	 *
	 * @since 1.9.0
	 * @return {Updater}
	 */
	disableEventProcessing() {
		for (const type of this[internalSymbol].eventTypes) {
			this[internalSymbol].element.removeEventListener(
				type,
				getControlEventHandler.call(this),
			);
		}

		delete this[internalSymbol].element[updaterRootSymbol];

		return this;
	}

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

		this[disposedSymbol] = true;
		this.disableEventProcessing();
		this[pendingDiffsSymbol].length = 0;

		if (this[subjectObserverSymbol] instanceof Observer) {
			this[internalSymbol].subject.detachObserver(this[subjectObserverSymbol]);
		}

		delete this[timerElementEventHandlerSymbol];

		return this;
	}

	isDisposed() {
		return this[disposedSymbol] === true;
	}

	/**
	 * The run method must be called for the update to start working.
	 * The method ensures that changes are detected.
	 *
	 * ```js
	 * updater.run().then(() => {
	 *   updater.enableEventProcessing();
	 * });
	 * ```
	 *
	 * @summary Let the magic begin
	 * @return {Promise}
	 */
	run() {
		if (this[disposedSymbol] === true) {
			return Promise.resolve();
		}

		// the key __init__has no further meaning and is only
		// used to create the diff for empty objects.
		this[internalSymbol].last = { __init__: true };
		this[queuedSnapshotSymbol] = clone(this[internalSymbol].last);
		return this[internalSymbol].subject.notifyObservers();
	}

	/**
	 * Gets the values of bound elements and changes them in subject
	 *
	 * @since 1.27.0
	 * @return {Monster.DOM.Updater}
	 */
	retrieve() {
		if (this[disposedSymbol] === true) {
			return this;
		}

		retrieveFromBindings.call(this);
		return this;
	}

	/**
	 * If you have passed a ProxyObserver in the constructor, you will get the object that the ProxyObserver manages here.
	 * However, if you passed a simple object, here you will get a proxy for that object.
	 *
	 * For changes, the ProxyObserver must be used.
	 *
	 * @since 1.8.0
	 * @return {Proxy}
	 */
	getSubject() {
		return this[internalSymbol].subject.getSubject();
	}

	/**
	 * This method can be used to register commands that can be called via call: instruction.
	 * This can be used to provide a pipe with its own functionality.
	 *
	 * @param {string} name
	 * @param {function} callback
	 * @return {Transformer}
	 * @throws {TypeError} value is not a string
	 * @throws {TypeError} value is not a function
	 */
	setCallback(name, callback) {
		this[internalSymbol].callbacks.set(name, callback);
		return this;
	}
}

/**
 * @private
 * @license AGPLv3
 * @since 1.9.0
 * @return {function
 * @this Updater
 */
function getCheckStateCallback() {
	return function (current) {
		// this is a reference to the current object (therefore no array function here)
		if (this instanceof HTMLInputElement) {
			if (["radio", "checkbox"].indexOf(this.type) !== -1) {
				return `${this.value}` === `${current}` ? "true" : undefined;
			}
		} else if (this instanceof HTMLOptionElement) {
			if (isArray(current) && current.indexOf(this.value) !== -1) {
				return "true";
			}

			return undefined;
		}
	};
}

/**
 * @private
 */
const symbol = Symbol("@schukai/monster/updater@@EventHandler");

/**
 * @private
 * @return {function}
 * @this Updater
 * @throws {Error} the bind argument must start as a value with a path
 */
function getControlEventHandler() {
	if (this[symbol]) {
		return this[symbol];
	}

	/**
	 * @throws {Error} the bind argument must start as a value with a path.
	 * @throws {Error} unsupported object
	 * @param {Event} event
	 */
	this[symbol] = (event) => {
		if (
			this[disposedSymbol] === true ||
			!this[internalSymbol].element?.isConnected
		) {
			return;
		}

		const root = findClosestUpdaterRootFromEvent(event);
		if (root !== this[internalSymbol].element) {
			return;
		}
		const element = findTargetElementFromEvent(event, ATTRIBUTE_UPDATER_BIND);

		if (element === undefined) {
			return;
		}

		if (this[timerElementEventHandlerSymbol] instanceof DeadMansSwitch) {
			try {
				this[timerElementEventHandlerSymbol].touch();
				return;
			} catch (e) {
				delete this[timerElementEventHandlerSymbol];
			}
		}

		this[timerElementEventHandlerSymbol] = new DeadMansSwitch(50, () => {
			if (
				this[disposedSymbol] === true ||
				!this[internalSymbol].element?.isConnected ||
				!element?.isConnected
			) {
				return;
			}

			try {
				retrieveAndSetValue.call(this, element);
			} catch (e) {
				addErrorAttribute(element, e);
			}
		});
	};

	return this[symbol];
}

/**
 * @private
 * @param {Event} event
 * @return {HTMLElement|undefined}
 */
function findClosestUpdaterRootFromEvent(event) {
	if (typeof event?.composedPath !== "function") {
		return undefined;
	}

	const path = event.composedPath();
	for (let i = 0; i < path.length; i++) {
		const node = path[i];
		if (node instanceof HTMLElement && node[updaterRootSymbol] === true) {
			return node;
		}
	}

	return undefined;
}

/**
 * @throws {Error} the bind argument must start as a value with a path
 * @param {HTMLElement} element
 * @return void
 * @private
 */
function retrieveAndSetValue(element) {
	const pathfinder = new Pathfinder(this[internalSymbol].subject.getSubject());

	let path = element.getAttribute(ATTRIBUTE_UPDATER_BIND);
	if (path === null)
		throw new Error("the bind argument must start as a value with a path");

	if (path.indexOf("path:") !== 0) {
		throw new Error("the bind argument must start as a value with a path");
	}

	path = path.substring(5); // remove path: from the string

	let value;

	if (element instanceof HTMLInputElement) {
		switch (element.type) {
			case "checkbox":
				value = element.checked ? element.value : undefined;
				break;
			default:
				value = element.value;
				break;
		}
	} else if (element instanceof HTMLTextAreaElement) {
		value = element.value;
	} else if (element instanceof HTMLSelectElement) {
		switch (element.type) {
			case "select-one":
				value = element.value;
				break;
			case "select-multiple":
				value = element.value;

				let options = element?.selectedOptions;
				if (options === undefined)
					options = element.querySelectorAll(":scope option:checked");
				value = Array.from(options).map(({ value }) => value);

				break;
		}

		// values from custom elements
	} else if (
		(element?.constructor?.prototype &&
			!!Object.getOwnPropertyDescriptor(
				element.constructor.prototype,
				"value",
			)?.["get"]) ||
		"value" in element
	) {
		value = element?.["value"];
	} else {
		throw new Error("unsupported object");
	}

	const type = element.getAttribute(ATTRIBUTE_UPDATER_BIND_TYPE);
	switch (type) {
		case "integer?":
		case "int?":
		case "number?": {
			const empty =
				value === undefined ||
				value === null ||
				(isString(value) && value.trim() === "");
			value = Number(value);
			if (empty || isNaN(value)) {
				value = undefined;
			}
			break;
		}

		case "number":
		case "int":
		case "float":
		case "integer":
			value = Number(value);
			if (isNaN(value)) {
				value = 0;
			}
			break;
		case "boolean":
		case "bool":
		case "checkbox":
			value =
				value === "true" || value === "1" || value === "on" || value === true;
			break;

		case "string[]":
		case "[]string":
			if (isString(value)) {
				if (value.trim() === "") {
					value = [];
				} else {
					value = value.split(",").map((v) => `${v}`);
				}
			} else if (isInteger(value)) {
				value = [`${value}`];
			} else if (value === undefined || value === null) {
				value = [];
			} else if (isArray(value)) {
				value = value.map((v) => `${v}`);
			} else {
				throw new Error("unsupported value");
			}

			break;

		case "int[]":
		case "integer[]":
		case "number[]":
		case "[]int":
		case "[]integer":
		case "[]number":
			value = parseIntArray(value);
			break;
		case "[]":
		case "array":
		case "list":
			if (isString(value)) {
				if (value.trim() === "") {
					value = [];
				} else {
					value = value.split(",");
				}
			} else if (isInteger(value)) {
				value = [value];
			} else if (value === undefined || value === null) {
				value = [];
			} else if (isArray(value)) {
				// nothing to do
			} else {
				throw new Error("unsupported value for array");
			}
			break;
		case "object":
		case "json":
			if (isString(value)) {
				value = JSON.parse(value);
			} else {
				throw new Error("unsupported value for object");
			}

			break;
		default:
			break;
	}

	const copy = clone(this[internalSymbol].subject.getRealSubject());

	const pf = new Pathfinder(copy);
	pf.setVia(path, value);

	const diffResult = diff(copy, this[internalSymbol].subject.getRealSubject());

	if (diffResult.length > 0) {
		pathfinder.setVia(path, value);
	}
}

/**
 * @private
 */
function parseIntArray(val) {
	if (isString(val)) {
		return val.trim() === ""
			? []
			: val
					.split(",")
					.map((v) => parseInt(v, 10))
					.filter((v) => !isNaN(v));
	} else if (isInteger(val)) {
		return [val];
	} else if (val === undefined || val === null) {
		return [];
	} else if (isArray(val)) {
		return val.map((v) => parseInt(v, 10)).filter((v) => !isNaN(v));
	}
	throw new Error("unsupported value for int array");
}

/**
 * @license AGPLv3
 * @since 1.27.0
 * @return void
 * @private
 */
function retrieveFromBindings() {
	if (this[internalSymbol].element.matches(`[${ATTRIBUTE_UPDATER_BIND}]`)) {
		retrieveAndSetValue.call(this, this[internalSymbol].element);
	}

	for (const [, element] of this[internalSymbol].element
		.querySelectorAll(`[${ATTRIBUTE_UPDATER_BIND}]`)
		.entries()) {
		retrieveAndSetValue.call(this, element);
	}
}

/**
 * @private
 * @license AGPLv3
 * @since 1.8.0
 * @param {object} change
 * @return {void}
 */
function removeElement(change) {
	for (const [, element] of this[internalSymbol].element
		.querySelectorAll(`:scope [${ATTRIBUTE_UPDATER_REMOVE}]`)
		.entries()) {
		teardownManagedSubtree(element);
		element.parentNode.removeChild(element);
	}
}

/**
 * @private
 * @license AGPLv3
 * @since 1.8.0
 * @param {object} change
 * @return {void}
 * @throws {Error} the value is not iterable
 * @throws {Error} pipes are not allowed when cloning a node.
 * @throws {Error} no template was found with the specified key.
 * @throws {Error} the maximum depth for the recursion is reached.
 * @this Updater
 */
function insertElement(change) {
	const subject = this[internalSymbol].subject.getRealSubject();

	const mem = new WeakSet();
	let wd = 0;

	const container = this[internalSymbol].element;

	while (true) {
		let found = false;
		wd++;

		const p = clone(change?.["path"]);
		if (!isArray(p)) return;

		while (p.length > 0) {
			const current = p.join(".");

			let iterator = new Set();
			const query = `[${ATTRIBUTE_UPDATER_INSERT}*="path:${current}"]`;

			const e = container.querySelectorAll(query);

			if (e.length > 0) {
				iterator = new Set([...e]);
			}

			if (container.matches(query)) {
				iterator.add(container);
			}

			for (const [, containerElement] of iterator.entries()) {
				if (mem.has(containerElement)) continue;
				mem.add(containerElement);

				found = true;

				const attributes = containerElement.getAttribute(
					ATTRIBUTE_UPDATER_INSERT,
				);
				if (attributes === null) continue;

				const def = trimSpaces(attributes);
				const i = def.indexOf(" ");
				const key = trimSpaces(def.substr(0, i));
				const refPrefix = `${key}-`;
				const cmd = trimSpaces(def.substr(i));

				// this case is actually excluded by the query but is nevertheless checked again here
				if (cmd.indexOf("|") > 0) {
					throw new Error("pipes are not allowed when cloning a node.");
				}

				const pipe = new Pipe(cmd);
				this[internalSymbol].callbacks.forEach((f, n) => {
					pipe.setCallback(n, f);
				});

				let value;
				try {
					containerElement.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
					value = pipe.run(subject);
				} catch (e) {
					addErrorAttribute(containerElement, e);
				}

				const dataPath = cmd.split(":").pop();

				let insertPoint;
				if (containerElement.hasChildNodes()) {
					insertPoint = containerElement.lastChild;
				}

				if (!isIterable(value)) {
					throw new Error("the value is not iterable");
				}

				const available = new Set();

				for (const [i] of Object.entries(value)) {
					const ref = refPrefix + i;
					const currentPath = `${dataPath}.${i}`;

					available.add(ref);
					const refElement = containerElement.querySelector(
						`[${ATTRIBUTE_UPDATER_INSERT_REFERENCE}="${ref}"]`,
					);

					if (refElement instanceof HTMLElement) {
						insertPoint = refElement;
						continue;
					}

					appendNewDocumentFragment(containerElement, key, ref, currentPath);
				}

				const nodes = containerElement.querySelectorAll(
					`[${ATTRIBUTE_UPDATER_INSERT_REFERENCE}*="${refPrefix}"]`,
				);

				for (const [, node] of Object.entries(nodes)) {
					if (
						!available.has(
							node.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE),
						)
					) {
						try {
							teardownManagedSubtree(node);
							containerElement.removeChild(node);
						} catch (e) {
							addErrorAttribute(containerElement, e);
						}
					}
				}
			}

			p.pop();
		}

		if (found === false) break;
		if (wd++ > 200) {
			throw new Error("the maximum depth for the recursion is reached.");
		}
	}
}

/**
 *
 * @private
 * @license AGPLv3
 * @since 1.8.0
 * @param {HTMLElement} container
 * @param {string} key
 * @param {string} ref
 * @param {string} path
 * @throws {Error} no template was found with the specified key.
 */
function appendNewDocumentFragment(container, key, ref, path) {
	const template = findDocumentTemplate(key, container);

	const nodes = template.createDocumentFragment();
	for (const [, node] of Object.entries(nodes.childNodes)) {
		if (node instanceof HTMLElement) {
			applyRecursive(node, key, path);
			node.setAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE, ref);
		}

		container.appendChild(node);
	}
}

/**
 * @private
 * @license AGPLv3
 * @since 1.10.0
 * @param {HTMLElement} node
 * @param {string} key
 * @param {string} path
 * @return {void}
 */
function applyRecursive(node, key, path) {
	if (!(node instanceof HTMLElement)) {
		return;
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_REPLACE)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_REPLACE);
		node.setAttribute(
			ATTRIBUTE_UPDATER_REPLACE,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_PATCH)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_PATCH);
		node.setAttribute(
			ATTRIBUTE_UPDATER_PATCH,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_PATCH_KEY)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_PATCH_KEY);
		node.setAttribute(
			ATTRIBUTE_UPDATER_PATCH_KEY,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_PATCH_RENDER)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_PATCH_RENDER);
		node.setAttribute(
			ATTRIBUTE_UPDATER_PATCH_RENDER,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
		node.setAttribute(
			ATTRIBUTE_UPDATER_ATTRIBUTES,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_PROPERTIES)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_PROPERTIES);
		node.setAttribute(
			ATTRIBUTE_UPDATER_PROPERTIES,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_BIND)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_BIND);
		node.setAttribute(
			ATTRIBUTE_UPDATER_BIND,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	if (node.hasAttribute(ATTRIBUTE_UPDATER_INSERT)) {
		const value = node.getAttribute(ATTRIBUTE_UPDATER_INSERT);
		node.setAttribute(
			ATTRIBUTE_UPDATER_INSERT,
			value.replaceAll(`path:${key}`, `path:${path}`),
		);
	}

	for (const [, child] of Object.entries(node.childNodes)) {
		if (child instanceof HTMLElement) {
			applyRecursive(child, key, path);
		}
	}
}

/**
 * @private
 * @license AGPLv3
 * @since 1.8.0
 * @param {object} change
 * @return {void}
 * @this Updater
 */
function updateContent(change) {
	const subject = this[internalSymbol].subject.getRealSubject();

	const p = clone(change?.["path"]);
	runUpdateContent.call(this, this[internalSymbol].element, p, subject);
	runUpdatePatch.call(this, this[internalSymbol].element, p, subject);

	const slots = this[internalSymbol].element.querySelectorAll("slot");
	if (slots.length > 0) {
		for (const [, slot] of Object.entries(slots)) {
			for (const [, element] of Object.entries(slot.assignedNodes())) {
				runUpdateContent.call(this, element, p, subject);
				runUpdatePatch.call(this, element, p, subject);
			}
		}
	}
}

/**
 * @private
 * @license AGPLv3
 * @since 1.8.0
 * @param {HTMLElement} container
 * @param {array} parts
 * @param {object} subject
 * @return {void}
 */
function runUpdateContent(container, parts, subject) {
	if (!isArray(parts)) return;
	if (!(container instanceof HTMLElement)) return;
	parts = clone(parts);

	const mem = new WeakSet();

	while (parts.length > 0) {
		const current = parts.join(".");
		parts.pop();

		// Unfortunately, static data is always changed as well, since it is not possible to react to changes here.
		const query = `[${ATTRIBUTE_UPDATER_REPLACE}^="path:${current}"], [${ATTRIBUTE_UPDATER_REPLACE}^="static:"], [${ATTRIBUTE_UPDATER_REPLACE}^="i18n:"]`;
		const e = container.querySelectorAll(`${query}`);

		const iterator = new Set([...e]);

		if (container.matches(query)) {
			iterator.add(container);
		}

		/**
		 * @type {HTMLElement}
		 */
		for (const [element] of iterator.entries()) {
			if (mem.has(element)) continue;
			mem.add(element);

			const attributes = element.getAttribute(ATTRIBUTE_UPDATER_REPLACE);
			const cmd = trimSpaces(attributes);

			const pipe = new Pipe(cmd);
			this[internalSymbol].callbacks.forEach((f, n) => {
				pipe.setCallback(n, f);
			});

			let value;
			try {
				element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
				value = pipe.run(subject);
			} catch (e) {
				element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
			}

			if (value instanceof HTMLElement) {
				teardownChildNodes(element);
				while (element.firstChild) {
					element.removeChild(element.firstChild);
				}

				try {
					element.appendChild(value);
				} catch (e) {
					addErrorAttribute(element, e);
				}
			} else {
				teardownChildNodes(element);
				element.innerHTML = value;
			}
		}
	}
}

function runUpdatePatch(container, parts, subject) {
	if (!isArray(parts)) return;
	if (!(container instanceof HTMLElement)) return;
	parts = clone(parts);

	const mem = new WeakSet();

	while (parts.length > 0) {
		const current = parts.join(".");
		parts.pop();

		const query = `[${ATTRIBUTE_UPDATER_PATCH}^="path:${current}"], [${ATTRIBUTE_UPDATER_PATCH}^="static:"], [${ATTRIBUTE_UPDATER_PATCH}^="i18n:"]`;
		const e = container.querySelectorAll(`${query}`);

		const iterator = new Set([...e]);
		if (container.matches(query)) {
			iterator.add(container);
		}

		for (const [element] of iterator.entries()) {
			if (mem.has(element)) continue;
			mem.add(element);

			const attributes = element.getAttribute(ATTRIBUTE_UPDATER_PATCH);
			const cmd = trimSpaces(attributes);

			const pipe = new Pipe(cmd);
			this[internalSymbol].callbacks.forEach((f, n) => {
				pipe.setCallback(n, f);
			});

			let value;
			try {
				element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
				value = pipe.run(subject);
			} catch (e) {
				element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
				continue;
			}

			applyPatchValue.call(this, element, value);
		}
	}
}

function applyPatchValue(element, value) {
	if (!(element instanceof HTMLElement)) {
		return;
	}

	if (isArray(value)) {
		const keyDefinition = element.getAttribute(ATTRIBUTE_UPDATER_PATCH_KEY);
		if (isString(keyDefinition) && trimSpaces(keyDefinition) !== "") {
			replaceKeyedPatchedChildren.call(
				this,
				element,
				value,
				keyDefinition,
				element.getAttribute(ATTRIBUTE_UPDATER_PATCH_RENDER),
			);
			return;
		}

		replacePatchedChildren(element, value);
		return;
	}

	if (isInstance(value, DocumentFragment)) {
		replacePatchedChildren(element, value);
		return;
	}

	if (value instanceof HTMLElement) {
		const existingChildren = Array.from(element.children);
		if (existingChildren.length === 1 && existingChildren[0] === value) {
			return;
		}

		teardownChildNodes(element);
		while (element.firstChild) {
			element.removeChild(element.firstChild);
		}

		try {
			element.appendChild(value);
		} catch (e) {
			addErrorAttribute(element, e);
		}
		return;
	}

	const nextValue = value === null || value === undefined ? "" : String(value);

	if (element.children.length > 0) {
		teardownChildNodes(element);
	}

	element.textContent = nextValue;
}

function replaceKeyedPatchedChildren(
	element,
	values,
	keyDefinition,
	renderDefinition,
) {
	if (!(element instanceof HTMLElement) || !isArray(values)) {
		return;
	}

	const keyPipe = new Pipe(trimSpaces(keyDefinition));
	this[internalSymbol].callbacks.forEach((f, n) => {
		keyPipe.setCallback(n, f);
	});
	const renderPipe = createPatchRenderPipe.call(this, renderDefinition);

	const existing = new Map();
	for (const node of Array.from(element.childNodes)) {
		const key = node?.[patchNodeKeySymbol];
		if (key !== undefined && !existing.has(key)) {
			existing.set(key, node);
		}
	}

	const nextNodes = [];
	for (const value of values) {
		let key;
		try {
			key = keyPipe.run(value);
		} catch (e) {
			addErrorAttribute(element, e);
			return;
		}
		let renderedValue;
		try {
			renderedValue = resolvePatchRenderValue(value, renderPipe);
		} catch (e) {
			addErrorAttribute(element, e);
			return;
		}

		const normalizedKey = key === null || key === undefined ? "" : String(key);
		if (existing.has(normalizedKey)) {
			const reused = existing.get(normalizedKey);
			existing.delete(normalizedKey);
			const nextNode = preparePatchedNode(reused, renderedValue);
			nextNode[patchNodeKeySymbol] = normalizedKey;
			if (nextNode !== reused && reused.parentNode === element) {
				element.replaceChild(nextNode, reused);
			}
			nextNodes.push(nextNode);
			continue;
		}

		let created;
		try {
			created = createSinglePatchedNode(renderedValue);
		} catch (e) {
			addErrorAttribute(element, e);
			return;
		}
		created[patchNodeKeySymbol] = normalizedKey;
		nextNodes.push(created);
	}

	for (const node of existing.values()) {
		if (node instanceof HTMLElement) {
			teardownManagedSubtree(node);
		}
		if (node.parentNode === element) {
			element.removeChild(node);
		}
	}

	for (const node of nextNodes) {
		if (node.parentNode !== element) {
			element.appendChild(node);
			continue;
		}

		if (element.lastChild !== node) {
			element.appendChild(node);
		}
	}
}

function preparePatchedNode(node, value) {
	if (node?.nodeType === Node.TEXT_NODE) {
		if (
			value === null ||
			value === undefined ||
			isString(value) ||
			typeof value === "number" ||
			typeof value === "boolean"
		) {
			node.textContent =
				value === null || value === undefined ? "" : String(value);
			return node;
		}

		return createSinglePatchedNode(value);
	}

	if (node instanceof HTMLElement) {
		if (value === node) {
			return node;
		}

		return createSinglePatchedNode(value);
	}

	return node;
}

function createPatchRenderPipe(renderDefinition) {
	if (!isString(renderDefinition) || trimSpaces(renderDefinition) === "") {
		return null;
	}

	const renderPipe = new Pipe(trimSpaces(renderDefinition));
	this[internalSymbol].callbacks.forEach((f, n) => {
		renderPipe.setCallback(n, f);
	});

	return renderPipe;
}

function resolvePatchRenderValue(value, renderPipe) {
	if (!(renderPipe instanceof Pipe)) {
		return value;
	}

	return renderPipe.run(value);
}

function replacePatchedChildren(element, value) {
	if (!(element instanceof HTMLElement)) {
		return;
	}

	const fragment = document.createDocumentFragment();

	if (isArray(value)) {
		for (const entry of value) {
			appendPatchChild(fragment, entry);
		}
	} else if (isInstance(value, DocumentFragment)) {
		fragment.appendChild(value);
	}

	teardownChildNodes(element);
	while (element.firstChild) {
		element.removeChild(element.firstChild);
	}

	element.appendChild(fragment);
}

function appendPatchChild(fragment, value) {
	if (!(fragment instanceof DocumentFragment)) {
		return;
	}

	if (value === null || value === undefined) {
		return;
	}

	if (isArray(value)) {
		for (const entry of value) {
			appendPatchChild(fragment, entry);
		}
		return;
	}

	if (isInstance(value, DocumentFragment) || value instanceof HTMLElement) {
		fragment.appendChild(value);
		return;
	}

	fragment.appendChild(document.createTextNode(String(value)));
}

function createPatchedNodes(value) {
	const fragment = document.createDocumentFragment();
	appendPatchChild(fragment, value);
	return Array.from(fragment.childNodes);
}

function createSinglePatchedNode(value) {
	const nodes = createPatchedNodes(value);
	if (nodes.length === 0) {
		return document.createTextNode("");
	}

	if (nodes.length !== 1) {
		throw new Error("keyed patch values must resolve to a single node");
	}

	return nodes[0];
}

function teardownChildNodes(root) {
	if (!(root instanceof HTMLElement)) {
		return;
	}

	for (const child of Array.from(root.children)) {
		teardownManagedSubtree(child);
	}
}

function teardownManagedSubtree(root) {
	if (!(root instanceof HTMLElement)) {
		return;
	}

	const elements = [root, ...root.querySelectorAll("*")];
	for (const element of elements) {
		if (!hasObjectLinkSafe(element, customElementUpdaterLinkSymbol)) {
			continue;
		}

		try {
			const linked = getLinkedObjects(element, customElementUpdaterLinkSymbol);
			for (const updaterSet of linked) {
				if (!(updaterSet instanceof Set)) {
					continue;
				}

				for (const updater of updaterSet) {
					if (typeof updater?.dispose === "function") {
						updater.dispose();
					}
				}
			}

			removeObjectLink(element, customElementUpdaterLinkSymbol);
		} catch (e) {
			addErrorAttribute(element, e);
		}
	}
}

function hasObjectLinkSafe(element, symbol) {
	try {
		return hasObjectLink(element, symbol);
	} catch (e) {
		return false;
	}
}

/**
 * @private
 * @since 1.8.0
 * @param {object} change
 * @return {void}
 */
function updateAttributes(change) {
	const subject = this[internalSymbol].subject.getRealSubject();
	const p = clone(change?.["path"]);
	runUpdateAttributes.call(this, this[internalSymbol].element, p, subject);
}

/**
 * @private
 * @since 4.125.0
 * @param {object} change
 * @return {void}
 */
function updateProperties(change) {
	const subject = this[internalSymbol].subject.getRealSubject();
	const p = clone(change?.["path"]);
	runUpdateProperties.call(this, this[internalSymbol].element, p, subject);
}

/**
 * @private
 * @param {HTMLElement} container
 * @param {array} parts
 * @param {object} subject
 * @return {void}
 * @this Updater
 */
function runUpdateAttributes(container, parts, subject) {
	if (!isArray(parts)) return;
	parts = clone(parts);

	const mem = new WeakSet();

	while (parts.length > 0) {
		const current = parts.join(".");
		parts.pop();

		let iterator = new Set();

		const query = `[${ATTRIBUTE_UPDATER_SELECT_THIS}][${ATTRIBUTE_UPDATER_ATTRIBUTES}], [${ATTRIBUTE_UPDATER_ATTRIBUTES}*="path:${current}"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="static:"], [${ATTRIBUTE_UPDATER_ATTRIBUTES}^="i18n:"]`;

		const e = container.querySelectorAll(query);

		if (e.length > 0) {
			iterator = new Set([...e]);
		}

		if (container.matches(query)) {
			iterator.add(container);
		}

		for (const [element] of iterator.entries()) {
			if (mem.has(element)) continue;
			mem.add(element);

			// this case occurs when the ATTRIBUTE_UPDATER_SELECT_THIS attribute is set
			if (!element.hasAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES)) {
				continue;
			}

			const attributes = element.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);

			for (let [, def] of Object.entries(attributes.split(","))) {
				def = trimSpaces(def);
				const i = def.indexOf(" ");
				const name = trimSpaces(def.substr(0, i));
				const cmd = trimSpaces(def.substr(i));

				const pipe = new Pipe(cmd);

				this[internalSymbol].callbacks.forEach((f, n) => {
					pipe.setCallback(n, f, element);
				});

				let value;
				try {
					element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
					value = pipe.run(subject);
				} catch (e) {
					addErrorAttribute(element, e);
				}

				if (value === undefined) {
					element.removeAttribute(name);
				} else if (element.getAttribute(name) !== value) {
					element.setAttribute(name, value);
				}

				handleInputControlAttributeUpdate.call(this, element, name, value);
			}
		}
	}
}

/**
 * @private
 * @param {HTMLElement} container
 * @param {array} parts
 * @param {object} subject
 * @return {void}
 * @this Updater
 */
function runUpdateProperties(container, parts, subject) {
	if (!isArray(parts)) return;
	parts = clone(parts);

	const mem = new WeakSet();

	while (parts.length > 0) {
		const current = parts.join(".");
		parts.pop();

		let iterator = new Set();

		const query = `[${ATTRIBUTE_UPDATER_SELECT_THIS}][${ATTRIBUTE_UPDATER_PROPERTIES}], [${ATTRIBUTE_UPDATER_PROPERTIES}*="path:${current}"], [${ATTRIBUTE_UPDATER_PROPERTIES}^="static:"], [${ATTRIBUTE_UPDATER_PROPERTIES}^="i18n:"]`;

		const e = container.querySelectorAll(query);

		if (e.length > 0) {
			iterator = new Set([...e]);
		}

		if (container.matches(query)) {
			iterator.add(container);
		}

		for (const [element] of iterator.entries()) {
			if (mem.has(element)) continue;
			mem.add(element);

			// this case occurs when the ATTRIBUTE_UPDATER_SELECT_THIS attribute is set
			if (!element.hasAttribute(ATTRIBUTE_UPDATER_PROPERTIES)) {
				continue;
			}

			const properties = element.getAttribute(ATTRIBUTE_UPDATER_PROPERTIES);

			for (let [, def] of Object.entries(properties.split(","))) {
				def = trimSpaces(def);
				const i = def.indexOf(" ");
				const name = trimSpaces(def.substr(0, i));
				const cmd = trimSpaces(def.substr(i));

				const pipe = new Pipe(cmd);

				this[internalSymbol].callbacks.forEach((f, n) => {
					pipe.setCallback(n, f, element);
				});

				let value;
				try {
					element.removeAttribute(ATTRIBUTE_ERRORMESSAGE);
					value = pipe.run(subject);
				} catch (e) {
					addErrorAttribute(element, e);
				}

				handleInputControlPropertyUpdate.call(this, element, name, value);
			}
		}
	}
}

/**
 * @private
 * @param {HTMLElement|*} element
 * @param {string} name
 * @param {string|number|undefined} value
 * @return {void}
 * @this Updater
 */

function handleInputControlAttributeUpdate(element, name, value) {
	if (element instanceof HTMLSelectElement) {
		switch (element.type) {
			case "select-multiple":
				const selectedValues = isArray(value)
					? value
					: value === undefined || value === null
						? []
						: [`${value}`];
				for (const [, opt] of Object.entries(element.options)) {
					opt.selected = selectedValues.indexOf(opt.value) !== -1;
				}

				break;
			case "select-one":
				// Only one value may be selected
				if (value === undefined || value === null) {
					element.selectedIndex = -1;
					break;
				}

				element.selectedIndex = -1;
				for (const [index, opt] of Object.entries(element.options)) {
					if (opt.value === value) {
						element.selectedIndex = index;
						break;
					}
				}

				break;
		}
	} else if (element instanceof HTMLInputElement) {
		switch (element.type) {
			case "radio":
				if (name === "checked") {
					element.checked = value !== undefined;
				}
				break;

			case "checkbox":
				if (name === "checked") {
					element.checked = value !== undefined;
				}
				break;

			case "color":
				if (name === "value") {
					// Setting an empty string on a color input is invalid (#rrggbb required)
					if (value === undefined || value === "") {
						break;
					}

					element.value = value;
				}

				break;

			case "text":
			default:
				if (name === "value") {
					element.value = value === undefined ? "" : value;
				}
				break;
		}
	} else if (element instanceof HTMLTextAreaElement) {
		if (name === "value") {
			element.value = value === undefined ? "" : value;
		}
	} else if (name === "value" && element instanceof CustomElement) {
		if ("value" in element) {
			element.value = value === undefined ? "" : value;
		}
	}
}

/**
 * @private
 * @param {HTMLElement|*} element
 * @param {string} name
 * @param {*} value
 * @return {void}
 * @this Updater
 */
function handleInputControlPropertyUpdate(element, name, value) {
	if (element instanceof HTMLSelectElement && name === "value") {
		switch (element.type) {
			case "select-multiple":
				if (isArray(value)) {
					for (const [, opt] of Object.entries(element.options)) {
						opt.selected = value.indexOf(opt.value) !== -1;
					}
					return;
				}
				break;
			case "select-one":
				if (value === undefined) {
					element.selectedIndex = -1;
					return;
				}
				break;
		}
	}

	if (element instanceof HTMLInputElement) {
		switch (name) {
			case "checked":
				element.checked =
					value === true || value === "true" || value === "1" || value === "on";
				return;
			case "value":
				if (element.type === "color") {
					if (value === undefined || value === "") {
						return;
					}
				}
				break;
		}
	}

	if (element instanceof HTMLTextAreaElement && name === "value") {
		element.value = value === undefined ? "" : value;
		return;
	}

	const optionPath = getControlOptionPathFromPropertyName(name);
	if (
		optionPath !== null &&
		typeof element?.setOption === "function" &&
		typeof element?.getOption === "function"
	) {
		try {
			const current = element.getOption(optionPath);
			const normalized = normalizeValueForOptionTarget(value, current);
			element.setOption(optionPath, normalized);
		} catch (e) {
			addErrorAttribute(element, e);
		}
		return;
	}

	if (
		optionPath !== null &&
		typeof element?.setOption === "function" &&
		typeof element?.getOption !== "function"
	) {
		try {
			element.setOption(optionPath, value);
		} catch (e) {
			addErrorAttribute(element, e);
		}
		return;
	}

	try {
		element[name] = value;
	} catch (e) {
		addErrorAttribute(element, e);
	}
}

/**
 * @private
 * @param {string} name
 * @return {string|null}
 */
function getControlOptionPathFromPropertyName(name) {
	if (!isString(name)) {
		return null;
	}

	let path = null;

	if (name.startsWith("option:")) {
		path = name.substring(7);
	} else if (name.startsWith("option.")) {
		path = name.substring(7);
	} else if (name.startsWith("options.")) {
		path = name.substring(8);
	}

	if (!isString(path)) {
		return null;
	}

	path = path
		.split(".")
		.map((part) => trimSpaces(part))
		.filter((part) => part !== "")
		.join(".");

	if (path === "") {
		return null;
	}

	return path;
}

/**
 * @private
 * @param {*} value
 * @param {*} current
 * @return {*}
 */
function normalizeValueForOptionTarget(value, current) {
	if (current === undefined || current === null) {
		return value;
	}

	if (typeof current === "boolean") {
		return (
			value === true || value === "true" || value === "1" || value === "on"
		);
	}

	if (typeof current === "number") {
		const numberValue = Number(value);
		return isNaN(numberValue) ? current : numberValue;
	}

	if (typeof current === "string") {
		return value === undefined || value === null ? "" : `${value}`;
	}

	if (isArray(current)) {
		if (isArray(value)) {
			return value;
		}

		if (isString(value)) {
			if (value.trim() === "") {
				return [];
			}
			return value.split("::");
		}

		if (value === undefined || value === null) {
			return [];
		}
		return [value];
	}

	if (typeof current === "object") {
		if (value !== null && typeof value === "object") {
			return value;
		}

		if (isString(value)) {
			try {
				return JSON.parse(value);
			} catch (e) {
				return current;
			}
		}
	}

	return value;
}

/**
 * @param {NodeList|HTMLElement|Set<HTMLElement>} elements
 * @param {Symbol} symbol
 * @param {object} object
 * @param {object} config
 *
 * Config: enableEventProcessing {boolean} - default: false - enables the event processing
 * Config: batchUpdates {boolean} - default: false - batches updateContent/updateAttributes per diff
 *
 * @return {Promise[]}
 * @license AGPLv3
 * @since 1.23.0
 * @throws {TypeError} elements is not an instance of NodeList, HTMLElement or Set
 * @throws {TypeError} the context of the function is not an instance of HTMLElement
 * @throws {TypeError} symbol must be an instance of Symbol
 */
function addObjectWithUpdaterToElement(elements, symbol, object, config = {}) {
	if (!(this instanceof HTMLElement)) {
		throw new TypeError(
			"the context of this function must be an instance of HTMLElement",
		);
	}

	if (!(typeof symbol === "symbol")) {
		throw new TypeError("symbol must be an instance of Symbol");
	}

	const updaters = new Set();

	if (elements instanceof NodeList) {
		elements = new Set([...elements]);
	} else if (elements instanceof HTMLElement) {
		elements = new Set([elements]);
	} else if (elements instanceof Set) {
	} else {
		throw new TypeError(
			`elements is not a valid type. (actual: ${typeof elements})`,
		);
	}

	const result = [];

	const updaterCallbacks = [];
	const cb = this?.[updaterTransformerMethodsSymbol];
	if (this instanceof HTMLElement && typeof cb === "function") {
		const callbacks = cb.call(this);
		if (typeof callbacks === "object") {
			for (const [name, callback] of Object.entries(callbacks)) {
				if (typeof callback === "function") {
					updaterCallbacks.push([name, callback]);
				} else {
					addErrorAttribute(
						this,
						`onUpdaterPipeCallbacks: ${name} is not a function`,
					);
				}
			}
		} else {
			addErrorAttribute(
				this,
				`onUpdaterPipeCallbacks do not return an object with functions`,
			);
		}
	}

	elements.forEach((element) => {
		if (!(element instanceof HTMLElement)) return;
		if (element instanceof HTMLTemplateElement) return;

		const u = new Updater(element, object);
		updaters.add(u);

		if (updaterCallbacks.length > 0) {
			for (const [name, callback] of updaterCallbacks) {
				u.setCallback(name, callback);
			}
		}

		result.push(
			u.run().then(() => {
				if (config.batchUpdates === true) {
					u.setBatchUpdates(true);
				}
				if (config.eventProcessing === true) {
					u.enableEventProcessing();
				}

				return u;
			}),
		);
	});

	if (updaters.size > 0) {
		addToObjectLink(this, symbol, updaters);
	}

	return result;
}
