/**
 * 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 { buildTree } from "../../data/buildtree.mjs";
import {
	addAttributeToken,
	findClosestByAttribute,
} from "../../dom/attributes.mjs";
import {
	ATTRIBUTE_ERRORMESSAGE,
	ATTRIBUTE_ROLE,
	ATTRIBUTE_UPDATER_INSERT_REFERENCE,
} from "../../dom/constants.mjs";
import { instanceSymbol } from "../../constants.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import {
	findTargetElementFromEvent,
	fireCustomEvent,
	fireEvent,
} from "../../dom/events.mjs";
import { Formatter } from "../../text/formatter.mjs";
import { isString } from "../../types/is.mjs";
import { Node } from "../../types/node.mjs";
import { NodeRecursiveIterator } from "../../types/noderecursiveiterator.mjs";
import { validateInstance } from "../../types/validate.mjs";
import { ATTRIBUTE_FORM_URL, ATTRIBUTE_INTEND } from "./constants.mjs";
import { Select } from "./select.mjs";
import { SelectStyleSheet } from "./stylesheet/select.mjs";
import { TreeSelectStyleSheet } from "./stylesheet/tree-select.mjs";
import { addErrorAttribute } from "../../dom/error.mjs";

export { TreeSelect, formatHierarchicalSelection };

/**
 * @private
 * @type {symbol}
 */
const internalNodesSymbol = Symbol("internalNodes");

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

/**
 * A tree select control is a select control that can be used to select a value from a tree structure.
 *
 * @fragments /fragments/components/form/tree-select
 *
 * @example /examples/components/form/tree-select-simple
 *
 * @since 1.9.0
 * @copyright Volker Schukai
 * @summary A hierarchical select control for nested options, categories and tree-based selection workflows.
 * @fires monster-options-set
 * @fires monster-selected
 * @fires monster-change
 * @fires monster-changed
 */
class TreeSelect extends Select {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 * @since 2.1.0
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/components/form/tree-select@@instance");
	}

	/**
	 * To set the options via the HTML tag, the attribute `data-monster-options` must be used.
	 * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
	 *
	 * The individual configuration values can be found in the table.
	 *
	 * @extends Select
	 * @property {Array} mapping.rootReferences=['0', undefined, null]
	 * @property {String} mapping.idTemplate=id
	 * @property {String} mapping.parentTemplate=parent
	 * @property {String} mapping.selection
	 * @property {String} mapping.labelTemplate
	 * @property {String} mapping.valueTemplate
	 * @property {String} mapping.filter The filter function to apply to each node, you can use run: syntax to execute a function, or use call:filterValueOfAttribute:data-my-attribute.
	 * @property {Object} formatter
	 * @property {String} formatter.separator=" / "
	 */
	get defaults() {
		return Object.assign(
			{},
			super.defaults,
			{
				mapping: {
					rootReferences: ["0", undefined, null],
					id: "id",
					parent: "parent",

					selector: "*",
					labelTemplate: "",
					valueTemplate: "",

					filter: null,
				},
				formatter: {
					selection: formatHierarchicalSelection,
					separator: " / ",
				},
				templates: {
					main: getTemplate(),
				},
			},
			initOptionsFromArguments.call(this),
		);
	}

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

	/**
	 *
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [SelectStyleSheet, TreeSelectStyleSheet];
	}

	/**
	 * Import Select Options from dataset
	 * Not to be confused with the control defaults/options
	 *
	 * @param {array|object|Map|Set} data
	 * @return {Select}
	 * @throws {Error} map is not iterable
	 */
	importOptions(data) {
		const self = this;

		this[internalNodesSymbol] = new Map();

		const id = this.getOption("mapping.id", "id");
		const parentID = this.getOption("mapping.parent", "parent");

		const mappingOptions = this.getOption("mapping", {});

		let filter = mappingOptions?.["filter"];

		if (isString(filter)) {
			if (0 === filter.indexOf("run:")) {
				const code = filter.replace("run:", "");
				filter = (m, v, k) => {
					const fkt = new Function("m", "v", "k", "control", code);
					return fkt(m, v, k, self);
				};
			} else if (0 === filter.indexOf("call:")) {
				const parts = filter.split(":");
				parts.shift(); // remove prefix
				const fkt = parts.shift();

				switch (fkt) {
					case "filterValueOfAttribute":
						const attribute = parts.shift();
						const attrValue = self.getAttribute(attribute);

						filter = (m, v, k) => {
							return m?.[id] != attrValue; // no type check, no !==
						};
						break;

					default:
						addErrorAttribute(
							this,
							new Error(`Unknown filter function ${fkt}`),
						);
				}
			}
		}

		const rootReferences = mappingOptions?.["rootReferences"];

		const selector = mappingOptions?.["selector"];
		const options = [];

		try {
			const nodes = buildTree(data, selector, id, parentID, {
				filter,
				rootReferences,
			});

			for (const node of nodes) {
				const iterator = new NodeRecursiveIterator(node);
				for (const n of iterator) {
					const formattedValues = formatKeyLabel.call(this, n);

					const label = formattedValues.label;
					const value = formattedValues.value;
					const intend = n.level;

					const visibility = intend > 0 ? "hidden" : "visible";
					const state = "close";

					this[internalNodesSymbol].set(value, n);

					options.push({
						value,
						label,
						intend,
						state,
						visibility,
						["has-children"]: n.hasChildNodes(),
					});
				}
			}

			this.setOption("options", options);

			fireCustomEvent(this, "monster-options-set", {
				options,
			});
		} catch (e) {
			addErrorAttribute(this, e);
		}

		return this;
	}

	/**
	 *
	 * @return {TreeSelect}
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		initEventHandler.call(this);
	}
}

/**
 * @private
 * @param event
 */
function handleOptionKeyboardEvents(event) {
	switch (event?.["code"]) {
		case "ArrowLeft":
			closeOrOpenCurrentOption.call(this, event, "close");
			event.preventDefault();
			break;
		case "ArrowRight":
			closeOrOpenCurrentOption.call(this, event, "open");
			event.preventDefault();
			break;
	}
}

/**
 * @private
 * @param {event} event
 */
function closeOrOpenCurrentOption(event, mode) {
	validateInstance(event, Event);

	if (typeof event.composedPath !== "function") {
		throw new Error("unsupported event");
	}

	const path = event.composedPath();
	const optionNode = path.shift();

	const state = optionNode.getAttribute("data-monster-state");
	if (state !== mode) {
		const handler = optionNode.querySelector(
			"[data-monster-role=folder-handler]",
		);
		if (handler instanceof HTMLElement) {
			fireEvent(handler, "click");
		}
	}
}

/**
 *
 * @param {Node} node
 * @return {array<label, value>}
 * @private
 */
function formatKeyLabel(node) {
	validateInstance(node, Node);

	const v = node.value;
	if (v === undefined) {
		throw new Error("the object has no value for the specified id");
	}

	const label = new Formatter(v).format(
		this.getOption("mapping.labelTemplate", ""),
	);

	const value = new Formatter(v).format(
		this.getOption("mapping.valueTemplate", ""),
	);

	return {
		value,
		label,
	};
}

/**
 * @private
 * @param {string} value
 * @return {Array}
 */
function buildTreeLabels(value) {
	if (!this[internalNodesSymbol]) {
		return [value];
	}

	let node = this[internalNodesSymbol].get(value);
	if (node === undefined) {
		node = this[internalNodesSymbol].get(parseInt(value));
	}

	const parts = [];

	if (node instanceof Node) {
		let ptr = node;
		while (ptr) {
			const formattedValues = formatKeyLabel.call(this, ptr);
			parts.unshift(formattedValues.label);
			ptr = ptr.parent;
		}
	}

	return parts;
}

/**
 * This formatter can format a label hierarchically.
 * The option `formatter.separator` determines the separator.
 *
 * ```
 * a / b / c
 * ```
 *
 * This function can be passed as argument of the option `formatter.selection:`.
 *
 * @since 1.9.0
 * @param {*} value
 * @return {string}
 */
function formatHierarchicalSelection(value) {
	return buildTreeLabels
		.call(this, value)
		.join(this.getOption("formatter.separator", " / "));
}

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

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

	this[openOptionEventHandler] = (event) => {
		const element = findTargetElementFromEvent(
			event,
			ATTRIBUTE_ROLE,
			"folder-handler",
		);
		if (!(element instanceof HTMLElement)) {
			return;
		}

		const container = findClosestByAttribute(element, ATTRIBUTE_ROLE, "option");
		const index = container
			.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
			.split("-")
			.pop();

		const currentState = this.getOption(`options.${index}.state`);

		const newState = currentState === "close" ? "open" : "close";
		this.setOption(`options.${index}.state`, newState);

		const newVisibility = newState === "open" ? "visible" : "hidden";

		if (container.hasAttribute(ATTRIBUTE_INTEND)) {
			const intend = container.getAttribute(ATTRIBUTE_INTEND);

			let ref = container.nextElementSibling;
			const childIntend = parseInt(intend) + 1;

			const cmp = (a, b) => {
				if (newState === "open") {
					return a === b;
				}

				return a >= b;
			};

			while (
				ref?.hasAttribute(ATTRIBUTE_INTEND) &&
				cmp(parseInt(ref.getAttribute(ATTRIBUTE_INTEND)), childIntend)
			) {
				const refIndex = ref
					.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
					.split("-")
					.pop();
				this.setOption(`options.${refIndex}.visibility`, newVisibility);

				if (newState === "close") {
					this.setOption(`options.${refIndex}.state`, "close");
				}

				ref = ref.nextElementSibling;
			}
		}
	};

	this[keyEventHandler] = (event) => {
		const path = event.composedPath();
		const element = path?.[0];

		let role;

		if (element instanceof HTMLElement) {
			if (element.hasAttribute(ATTRIBUTE_ROLE)) {
				role = element.getAttribute(ATTRIBUTE_ROLE);
			} else if (element === this) {
				show.call(this);
				focusFilter.call(this);
			} else {
				const e = element.closest(`[${ATTRIBUTE_ROLE}]`);
				if (e instanceof HTMLElement && e.hasAttribute()) {
					role = e.getAttribute(ATTRIBUTE_ROLE);
				}
			}
		} else {
			return;
		}

		switch (role) {
			case "option-label":
			case "option-control":
			case "option":
				handleOptionKeyboardEvents.call(this, event);
				break;
		}
	};

	this.shadowRoot.addEventListener("keydown", this[keyEventHandler]);
	this.shadowRoot.addEventListener("click", this[openOptionEventHandler]);
}

/**
 * This attribute can be used to pass a URL to this select.
 *
 * ```html
 * <monster-select data-monster-url="https://example.com/"></monster-select>
 * ```
 *
 * @private
 * @return {object}
 */
function initOptionsFromArguments() {
	const options = {};

	const url = this.getAttribute(ATTRIBUTE_FORM_URL);
	if (isString(url)) {
		options["url"] = new URL(url).toString();
	}

	return options;
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <template id="options">
            <div data-monster-role="option"
                 tabindex="-1"
                 data-monster-attributes="
                 data-monster-intend path:options.intend, 
                 data-monster-state path:options.state, 
                 data-monster-visibility path:options.visibility, 
                 data-monster-filtered path:options.filtered,
                 data-monster-has-children path:options.has-children">
                <div data-monster-role="folder-handler"></div>
                <label part="option" role="option">
                    <input data-monster-role="option-control"
                           data-monster-attributes="
            type path:type,
            role path:role,
            value path:options.value, 
            name path:name, 
            part path:type | prefix:option- | suffix: form
            " tabindex="-1">
                    <span data-monster-replace="path:options | index:label" part="option-label"></span>
                </label>
            </div>
        </template>

        <template id="selection">
            <div data-monster-role="badge"
                 part="badge"
                 data-monster-attributes="
                 data-monster-value path:selection | index:value, 
                 class path:classes | index:badge, 
        part path:type | suffix:-option | prefix: form-" tabindex="-1">
                <div data-monster-replace="path:selection | index:label" part="badge-label"
                     data-monster-role="badge-label"></div>
                <div part="remove-badge" data-monster-select-this
                     data-monster-attributes="class path:features.clear | ?::hidden "
                     data-monster-role="remove-badge" tabindex="-1"></div>
            </div>
        </template>

        <slot class="hidden"></slot>

        <div data-monster-role="control" part="control" tabindex="0">
            <div data-monster-role="container" part="container">
                \${selected}
            </div>

            <div data-monster-role="popper" part="popper" tabindex="-1" class="monster-color-primary-1">
                <div class="option-filter-control" role="search" part="popper-filter-control">
                    <input type="text" role="searchbox"
                           part="popper-filter" name="popper-filter"
                           data-monster-role="filter"
                           autocomplete="off"
                           tabindex="0">
                </div>
                <div part="content" class="flex" data-monster-replace="path:content">
                    <div part="options" data-monster-role="options" data-monster-insert="options path:options"
                         tabindex="-1"></div>
                </div>
                <div part="no-options" data-monster-role="no-options"
                     data-monster-replace="path:messages.emptyOptions"></div>
            </div>
            <div part="status-or-remove-badges" data-monster-role="status-or-remove-badges"
                 data-monster-attributes="class path:classes.statusOrRemoveBadge | suffix:\\ status-or-remove-badges"></div>
        </div>
    `;
}

registerCustomElement(TreeSelect);
