/**
 * 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 "../datatable/datasource/dom.mjs";
import "../form/field-set.mjs";
import "../form/context-error.mjs";
import { DeadMansSwitch } from "../../util/deadmansswitch.mjs";
import { DataSet } from "../datatable/dataset.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
	getSlottedElements,
} from "../../dom/customelement.mjs";
import { datasourceLinkedElementSymbol } from "../datatable/util.mjs";
import { FormStyleSheet } from "./stylesheet/form.mjs";
import { addAttributeToken } from "../../dom/attributes.mjs";
import { getDocument } from "../../dom/util.mjs";
import { InvalidStyleSheet } from "./stylesheet/invalid.mjs";
import { isObject } from "../../types/is.mjs";
import { ID } from "../../types/id.mjs";

export { Form };

/**
 * @private
 * @type {symbol}
 */
const debounceWriteBackSymbol = Symbol("debounceWriteBack");

/**
 * @private
 * @type {symbol}
 */
const debounceBindSymbol = Symbol("debounceBind");

/**
 * A form control that can be used to group form elements.
 *
 * @fragments /fragments/components/form/form/
 *
 * @example /examples/components/form/form-simple
 *
 * @issue https://localhost.alvine.dev:8440/development/issues/closed/281.html
 * @issue https://localhost.alvine.dev:8440/development/issues/closed/217.html
 *
 * @since 1.0.0
 * @copyright Volker Schukai
 * @summary A structured form container for Monster controls, validation flows and consistent submission handling.
 * @fires monster-options-set
 * @fires monster-selected
 * @fires monster-change
 * @fires monster-changed
 */
class Form extends DataSet {
	/**
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 * @property {Object} classes Class definitions
	 * @property {string} classes.form Form class
	 * @property {Object} writeBack Write back definitions
	 * @property {string[]} writeBack.events Write back events
	 * @property {Object} bind Bind definitions
	 * @property {Object} reportValidity Report validity definitions
	 * @property {string} reportValidity.selector Report validity selector
	 * @property {boolean} features.mutationObserver Mutation observer feature
	 * @property {boolean} features.writeBack Write back feature
	 * @property {boolean} features.bind Bind feature
	 */
	get defaults() {
		const obj = Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},

			classes: {
				form: "",
			},

			labels: {},

			writeBack: {
				events: ["keyup", "click", "change", "drop", "touchend", "input"],
				transformer: null,
				callbacks: null,
			},

			reportValidity: {
				selector:
					"input,select,textarea,monster-select,monster-toggle-switch,monster-password,monster-choice-cards",
			},

			eventProcessing: true,
		});

		obj["features"]["mutationObserver"] = false;
		obj["features"]["writeBack"] = true;

		return obj;
	}

	/**
	 *
	 * @return {string}
	 */
	static getTag() {
		return "monster-form";
	}

	/**
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [FormStyleSheet, InvalidStyleSheet];
	}

	/**
	 *
	 */
	[assembleMethodSymbol]() {
		const selector = this.getOption("datasource.selector");

		if (!selector) {
			this[datasourceLinkedElementSymbol] = getDocument().createElement(
				"monster-datasource-dom",
			);
		}

		super[assembleMethodSymbol]();

		initControlReferences.call(this);
		initEventHandler.call(this);
		initDataSourceHandler.call(this);
	}

	/**
	 * This method is called when the component is created.
	 * @since 3.70.0
	 * @return {Promise}
	 */
	refresh() {
		return this.write().then(() => {
			super.refresh();
			return this;
		});
	}

	/**
	 * Run reportValidation on all child html form controls.
	 *
	 * @since 2.10.0
	 * @return {boolean}
	 */
	reportValidity() {
		let valid = true;

		const selector = this.getOption("reportValidity.selector");
		const nodes = getSlottedElements.call(this, selector);

		nodes.forEach((node) => {
			if (typeof node.reportValidity === "function") {
				if (node.reportValidity() === false) {
					valid = false;
				}
			}
		});

		return valid;
	}
}

/**
 * @private
 * @return {initEventHandler}
 */
function initEventHandler() {
	this[debounceBindSymbol] = {};

	if (this.getOption("features.writeBack") === true) {
		setTimeout(() => {
			const events = this.getOption("writeBack.events");
			for (const event of events) {
				this.addEventListener(event, (e) => {
					const target = e?.target;
					const targetTag = target?.tagName?.toLowerCase?.();
					if (targetTag === "monster-select" && e.type !== "change") {
						return;
					}

					if (e?.target && typeof e.target.setCustomValidity === "function") {
						e.target.setCustomValidity("");
						e.target.removeAttribute("data-monster-validation-error");
					}

					if (this.getOption("logLevel") === "debug") {
						console.log("monster-form: event triggered write", {
							element: e.target,
							event: e.type,
						});
					}

					if (!this.reportValidity()) {
						this.classList.add("invalid");
						setTimeout(() => {
							this.classList.remove("invalid");
						}, 1000);

						return;
					}

					if (this[debounceWriteBackSymbol] instanceof DeadMansSwitch) {
						try {
							this[debounceWriteBackSymbol].touch();
							return;
						} catch (e) {
							if (e.message !== "has already run") {
								throw e;
							}
							delete this[debounceWriteBackSymbol];
						}
					}

					this[debounceWriteBackSymbol] = new DeadMansSwitch(200, () => {
						setTimeout(() => {
							this.write().catch((e) => {
								addAttributeToken(this, "error", e.message || `${e}`);
							});
						}, 0);
					});
				});
			}
		}, 0);
	}

	return this;
}

/**
 * @private
 */
function initDataSourceHandler() {
	setTimeout(() => {
		const datasource = this[datasourceLinkedElementSymbol];
		if (!(datasource instanceof HTMLElement)) {
			return;
		}

		datasource.addEventListener("monster-datasource-validation", (event) => {
			applyValidationErrors.call(this, event?.detail);
		});

		datasource.addEventListener("monster-datasource-fetch", () => {
			clearValidationErrors.call(this);
		});

		datasource.addEventListener("monster-datasource-fetched", () => {
			clearValidationErrors.call(this);
		});
	}, 20);
}

/**
 * @private
 */
function clearValidationErrors() {
	const selector = this.getOption("reportValidity.selector");
	const nodes = getSlottedElements.call(this, selector);
	nodes.forEach((node) => {
		if (typeof node.setCustomValidity === "function") {
			node.setCustomValidity("");
		}
		node.removeAttribute?.("data-monster-validation-error");
	});

	const errors = this.querySelectorAll(
		"monster-context-error[data-monster-validation-for]",
	);
	errors.forEach((node) => {
		if (typeof node.resetErrorMessage === "function") {
			node.resetErrorMessage();
		} else {
			node.removeAttribute("data-monster-validation-for");
		}
	});
}

/**
 * @private
 * @param {object} detail
 */
function applyValidationErrors(detail) {
	if (!detail || !isObject(detail.errors)) {
		return;
	}

	clearValidationErrors.call(this);

	const datasource = this[datasourceLinkedElementSymbol];
	const mapping =
		datasource?.getOption?.("validation.map") ||
		datasource?.getOption?.("validation")?.map ||
		{};
	const labels = this.getOption("labels", {});

	const selector = this.getOption("reportValidity.selector");
	for (const [key, message] of Object.entries(detail.errors)) {
		if (!key) {
			continue;
		}

		let bindPath = mapping?.[key];
		if (isObject(bindPath)) {
			continue;
		}

		let code = null;
		let msg = message;
		if (isObject(message)) {
			code = message?.code || null;
			msg = message?.message || "";
		}
		if (code && typeof labels?.[code] === "string") {
			msg = labels[code];
		}

		let target = null;
		const candidates = getSlottedElements.call(this, selector);
		for (const candidate of candidates) {
			if (!(candidate instanceof HTMLElement)) {
				continue;
			}

			const bind = candidate.getAttribute("data-monster-bind");
			const name = candidate.getAttribute("name");
			const id = candidate.getAttribute("id");

			let matches = false;
			if (typeof bindPath === "string") {
				if (bind === bindPath) {
					matches = true;
				} else if (
					!bindPath.startsWith("path:") &&
					bind === `path:${bindPath}`
				) {
					matches = true;
				}
			}

			if (bind === `path:${key}` || bind === `path:data.${key}`) {
				matches = true;
			}

			if (name === key || id === key) {
				matches = true;
			}

			if (matches) {
				target = candidate;
				break;
			}
		}

		if (!target) {
			continue;
		}

		const targetName = target.getAttribute("name");
		const targetBind = target.getAttribute("data-monster-bind");
		const matchesKey =
			targetName === key ||
			targetBind === `path:${key}` ||
			targetBind === `path:data.${key}`;
		const matchesMapped =
			typeof bindPath === "string" &&
			(targetBind === bindPath || targetBind === `path:${bindPath}`);
		if (!matchesKey && !matchesMapped) {
			continue;
		}

		if (typeof target.setCustomValidity === "function") {
			target.setCustomValidity(`${msg}`);
		}
		target.setAttribute("data-monster-validation-error", `${msg}`);

		const errorElement = getOrCreateValidationErrorElement.call(
			this,
			target,
			key,
		);
		if (errorElement && typeof errorElement.setErrorMessage === "function") {
			errorElement.setErrorMessage(`${msg}`, false);
		}
	}
}

/**
 * @private
 * @param {HTMLElement} target
 * @param {string} key
 * @return {HTMLElement|null}
 */
function getOrCreateValidationErrorElement(target, key) {
	const marker =
		target.getAttribute("name") ||
		`${key}` ||
		target.getAttribute("id") ||
		`v-${key}`;

	const label = findLabelForTarget.call(this, target, key);
	if (!label) {
		return null;
	}

	let errorElement = label.querySelector(
		`monster-context-error[data-monster-validation-for="${marker}"]`,
	);
	const contextEndContainer = getOrCreateContextEndContainer(label);
	if (errorElement) {
		if (errorElement.parentElement !== contextEndContainer) {
			contextEndContainer.appendChild(errorElement);
		}
		errorElement.style.marginInlineStart = "";
		return errorElement;
	}

	errorElement = document.createElement("monster-context-error");
	errorElement.setAttribute("data-monster-validation-for", marker);
	contextEndContainer.appendChild(errorElement);

	return errorElement;
}

/**
 * @private
 * @param {HTMLLabelElement} label
 * @return {HTMLSpanElement}
 */
function getOrCreateContextEndContainer(label) {
	let container = label.querySelector('[data-monster-role="context-end"]');
	if (container) {
		return container;
	}

	container = document.createElement("span");
	container.setAttribute("data-monster-role", "context-end");
	container.style.display = "inline-flex";
	container.style.alignItems = "center";
	container.style.marginInlineStart = "auto";

	const contextSelectors =
		":scope > monster-context-help," +
		":scope > monster-context-error," +
		":scope > monster-context-note," +
		":scope > monster-context-hint," +
		":scope > monster-context-warning," +
		":scope > monster-context-info," +
		":scope > monster-context-success";
	const contextElements = label.querySelectorAll(contextSelectors);
	for (const contextElement of contextElements) {
		container.appendChild(contextElement);
	}

	label.appendChild(container);
	return container;
}

/**
 * @private
 * @param {HTMLElement} target
 * @param {string} key
 * @return {HTMLLabelElement|null}
 */
function findLabelForTarget(target, key) {
	const id = target.getAttribute("id");
	if (id) {
		const labelById = getSlottedElements.call(this, `label[for="${id}"]`);
		const next = labelById?.values()?.next();
		if (next && next.value instanceof HTMLLabelElement) {
			return next.value;
		}
	}

	const labelKey = target.getAttribute("name") || key;
	if (labelKey) {
		const labelByName = getSlottedElements.call(
			this,
			`label[data-monster-label="${labelKey}"]`,
		);
		const next = labelByName?.values()?.next();
		if (next && next.value instanceof HTMLLabelElement) {
			return next.value;
		}
	}

	let prev = target.previousElementSibling;
	while (prev) {
		if (prev instanceof HTMLLabelElement) {
			return prev;
		}
		prev = prev.previousElementSibling;
	}

	return null;
}

/**
 * @private
 * @return {FilterButton}
 */
function initControlReferences() {
	if (!this.shadowRoot) {
		throw new Error("no shadow-root is defined");
	}
	return this;
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <div data-monster-role="control" part="control">
            <form data-monster-attributes="disabled path:disabled | if:true, class path:classes.form"
                  data-monster-role="form"
                  part="form">
                <slot data-monster-role="slot"></slot>
            </form>
        </div>
    `;
}

registerCustomElement(Form);
