/**
 * Copyright schukai GmbH and contributors 2023. All Rights Reserved.
 * Node module: @schukai/monster
 * This file is licensed under the AGPLv3 License.
 * License text available at https://www.gnu.org/licenses/agpl-3.0.en.html
 */

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_INSERT,
	ATTRIBUTE_UPDATER_INSERT_REFERENCE,
	ATTRIBUTE_UPDATER_REMOVE,
	ATTRIBUTE_UPDATER_REPLACE,
	ATTRIBUTE_UPDATER_SELECT_THIS,
} from "./constants.mjs";

import { Base } from "../types/base.mjs";
import { isArray, 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 { addToObjectLink } from "./attributes.mjs";
import { findTargetElementFromEvent } from "./events.mjs";
import { findDocumentTemplate } from "./template.mjs";

export { Updater, addObjectWithUpdaterToElement };

/**
 * 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` 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.
 *
 * @externalExample ../../example/dom/updater.mjs
 * @license AGPLv3
 * @since 1.8.0
 * @copyright schukai GmbH
 * @memberOf Monster.DOM
 * @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 The updater class connects an object with the dom
 */
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 Monster.DOM.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,
		};

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

		this[internalSymbol].subject.attachObserver(
			new Observer(() => {
				const s = this[internalSymbol].subject.getRealSubject();

				const diffResult = diff(this[internalSymbol].last, s);
				this[internalSymbol].last = clone(s);

				for (const [, change] of Object.entries(diffResult)) {
					removeElement.call(this, change);
					insertElement.call(this, change);
					updateContent.call(this, change);
					updateAttributes.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;
	}

	/**
	 * With this method, the eventlisteners are hooked in and the magic begins.
	 *
	 * ```
	 * 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() {
		this.disableEventProcessing();

		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),
			);
		}

		return this;
	}

	/**
	 * The run method must be called for the update to start working.
	 * The method ensures that changes are detected.
	 *
	 * ```
	 * updater.run().then(() => {
	 *   updater.enableEventProcessing();
	 * });
	 * ```
	 *
	 * @summary Let the magic begin
	 * @return {Promise}
	 */
	run() {
		// the key __init__has no further meaning and is only
		// used to create the diff for empty objects.
		this[internalSymbol].last = { __init__: true };
		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() {
		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
	 * @returns {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) => {
		const element = findTargetElementFromEvent(event, ATTRIBUTE_UPDATER_BIND);

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

		retrieveAndSetValue.call(this, element);
	};

	return this[symbol];
}

/**
 * @throws {Error} the bind argument must start as a value with a path
 * @param {HTMLElement} element
 * @return void
 * @memberOf Monster.DOM
 * @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);

	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 customelements
	} else if (
		(element?.constructor?.prototype &&
			!!Object.getOwnPropertyDescriptor(
				element.constructor.prototype,
				"value",
			)?.["get"]) ||
		element.hasOwnProperty("value")
	) {
		value = element?.["value"];
	} else {
		throw new Error("unsupported object");
	}

	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);
	}
}

/**
 * @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()) {
		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 this;

		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) {
					containerElement.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
				}

				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, obj] 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 {
							containerElement.removeChild(node);
						} catch (e) {
							containerElement.setAttribute(
								ATTRIBUTE_ERRORMESSAGE,
								`${containerElement.getAttribute(ATTRIBUTE_ERRORMESSAGE)}, ${
									e.message
								}`.trim(),
							);
						}
					}
				}
			}

			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) {
		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_ATTRIBUTES)) {
			const value = node.getAttribute(ATTRIBUTE_UPDATER_ATTRIBUTES);
			node.setAttribute(
				ATTRIBUTE_UPDATER_ATTRIBUTES,
				value.replaceAll(`path:${key}`, `path:${path}`),
			);
		}

		for (const [, child] of Object.entries(node.childNodes)) {
			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);

	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);
			}
		}
	}
}

/**
 * @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)) return;
			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) {
				while (element.firstChild) {
					element.removeChild(element.firstChild);
				}

				try {
					element.appendChild(value);
				} catch (e) {
					element.setAttribute(
						ATTRIBUTE_ERRORMESSAGE,
						`${element.getAttribute(ATTRIBUTE_ERRORMESSAGE)}, ${
							e.message
						}`.trim(),
					);
				}
			} else {
				element.innerHTML = value;
			}
		}
	}
}

/**
 * @private
 * @license AGPLv3
 * @since 1.8.0
 * @param {string} path
 * @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
 * @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)) return;
			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) {
					element.setAttribute(ATTRIBUTE_ERRORMESSAGE, e.message);
				}

				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|*} 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":
				for (const [index, opt] of Object.entries(element.options)) {
					if (value.indexOf(opt.value) !== -1) {
						opt.selected = true;
					} else {
						opt.selected = false;
					}
				}

				break;
			case "select-one":
				// Only one value may be selected

				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") {
					if (value !== undefined) {
						element.checked = true;
					} else {
						element.checked = false;
					}
				}

				break;

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

				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;
		}
	}
}

/**
 * @param {NodeList|HTMLElement|Set<HTMLElement>} elements
 * @param {Symbol} symbol
 * @param {object} object
 * @return {Promise[]}
 * @license AGPLv3
 * @since 1.23.0
 * @memberOf Monster.DOM
 * @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) {
	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 = [];

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

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

		result.push(
			u.run().then(() => {
				return u.enableEventProcessing();
			}),
		);
	});

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

	return result;
}