/**
 * 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 { instanceSymbol } from "../../constants.mjs";
import { buildTree } from "../../data/buildtree.mjs";
import { Datasource } from "../datatable/datasource.mjs";
import { addAttributeToken } from "../../dom/attributes.mjs";
import {
	ATTRIBUTE_ERRORMESSAGE,
	ATTRIBUTE_ROLE,
	ATTRIBUTE_UPDATER_INSERT_REFERENCE,
} from "../../dom/constants.mjs";
import {
	assembleMethodSymbol,
	CustomElement,
	getSlottedElements,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { findTargetElementFromEvent, fireEvent } from "../../dom/events.mjs";
import { findElementWithSelectorUpwards } from "../../dom/util.mjs";
import { Formatter } from "../../text/formatter.mjs";
import { isFunction, isString } from "../../types/is.mjs";
import { Node } from "../../types/node.mjs";
import { NodeRecursiveIterator } from "../../types/noderecursiveiterator.mjs";
import { Observer } from "../../types/observer.mjs";
import { validateInstance } from "../../types/validate.mjs";
import {
	datasourceLinkedElementSymbol,
	handleDataSourceChanges,
} from "../datatable/util.mjs";
import { ATTRIBUTE_INTEND } from "./../constants.mjs";
import { CommonStyleSheet } from "../stylesheet/common.mjs";
import { TreeMenuStyleSheet } from "./stylesheet/tree-menu.mjs";

export { TreeMenu };

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

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

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

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

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

/**
 * TreeMenu
 *
 * @fragments /fragments/components/tree-menu/tree-menu/
 *
 * @example /examples/components/tree-menu/tree-menu-simple Basic tree menu
 *
 * @since 1.0.0
 * @summary A TreeMenu control
 * @fires entries-imported
 */
class TreeMenu extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/components/tree-menu@@instance");
	}

	/**
	 */
	constructor() {
		super();
		this[preventChangeSymbol] = false;
	}

	/**
	 * This method is called internal and should not be called directly.
	 *
	 * The defaults can be set either directly in the object or via an attribute in the HTML tag.
	 * The value of the attribute `data-monster-options` in the HTML tag must be a JSON string.
	 *
	 * ```
	 * <monster-treemenu data-monster-options="{}"></monster-treemenu>
	 * ```
	 *
	 * Since 1.18.0 the JSON can be specified as a DataURI.
	 *
	 * ```
	 * new Monster.Types.DataUrl(btoa(JSON.stringify({
	 *        shadowMode: 'open',
	 *    })),'application/json',true).toString()
	 * ```
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 * @property {Datasource} datasource data source
	 * @property {Object} mapping
	 * @property {String} mapping.selector Path to select the appropriate entries
	 * @property {String} mapping.labelTemplate template with the label placeholders in the form ${name}, where name is the key
	 * @property {String} mapping.keyTemplate template with the key placeholders in the form ${name}, where name is the key
	 * @property {String} mapping.rootReferences the root references
	 * @property {String} mapping.idTemplate template with the id placeholders in the form ${name}, where name is the key
	 * @property {String} mapping.parentKey the parent key
	 * @property {String} mapping.selection the selection
	 * @property {Function} mapping.filter a filter function to filter the entries
	 * @property {Object} classes
	 * @property {String} classes.control the class for the control element
	 * @property {String} classes.label the class for the label element
	 * @property {Object} actions
	 * @property {Function} actions.open the action to open an entry (arguments, etnry, index, event)
	 * @property {Function} actions.close the action to close an entry (arguments, etnry, index, event)
	 * @property {Function} actions.select the action to select an entry (arguments, etnry, index, event)
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			classes: {
				control: "monster-theme-primary-1",
				label: "monster-theme-primary-1",
			},

			mapping: {
				rootReferences: ["0", undefined, null],
				idTemplate: "id",
				parentKey: "parent",
				selector: "*",
				labelTemplate: "",
				valueTemplate: "",
				iconTemplate: "",
				filter: undefined,
			},

			templates: {
				main: getTemplate(),
			},

			datasource: {
				selector: null,
			},

			actions: {
				open: null,
				close: null,
				select: (entry) => {
					console.warn("select action is not defined", entry);
				},
			},

			updater: {
				batchUpdates: true,
			},

			data: [],
			entries: [],
		});
	}

	/**
	 * @return {void}
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();

		queueMicrotask(() => {
			initControlReferences.call(this);
			initEventHandler.call(this);
			initObserver.call(this);
			copyIconMap.call(this);
		});
	}

	/**
	 * This method is called internal and should not be called directly.
	 *
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [CommonStyleSheet, TreeMenuStyleSheet];
	}

	/**
	 * This method is called internal and should not be called directly.
	 *
	 * @return {string}
	 */
	static getTag() {
		return "monster-tree-menu";
	}

	/**
	 * @param {string} value
	 * @param value
	 */
	selectEntry(value) {
		this.shadowRoot
			.querySelectorAll("[data-monster-role=entry]")
			.forEach((entry) => {
				entry.classList.remove("selected");
			});

		value = String(value);

		const entries = this.getOption("entries");
		const index = entries.findIndex((entry) => entry.value === value);

		if (index === -1) {
			return;
		}

		const currentNode = this.shadowRoot.querySelector(
			"[data-monster-insert-reference=entries-" + index + "]",
		);

		if (!currentNode) {
			return;
		}

		currentNode.click();

		let intend = Number.parseInt(currentNode.getAttribute(ATTRIBUTE_INTEND));

		if (intend > 0) {
			const refSet = new Set();
			let ref = currentNode.previousElementSibling;
			while (ref?.hasAttribute(ATTRIBUTE_INTEND)) {
				const i = Number.parseInt(ref.getAttribute(ATTRIBUTE_INTEND));

				if (Number.isNaN(i)) {
					break;
				}

				if (i < intend) {
					if (ref.getAttribute("data-monster-state") !== "open") {
						ref.click();
					}

					if (i === 0) {
						break;
					}
					intend = i;
				}
				ref = ref.previousElementSibling;
			}
		}
	}
}

/**
 * @private
 */
function copyIconMap() {
	const nodes = getSlottedElements.call(this, "svg", null);
	if (nodes.size > 0) {
		for (const node of nodes) {
			this.shadowRoot.appendChild(node);
		}
	}
}

/**
 * @private
 */
function initEventHandler() {
	const selector = this.getOption("datasource.selector");

	if (isString(selector)) {
		const element = findElementWithSelectorUpwards(this, selector);
		if (element === null) {
			throw new Error("the selector must match exactly one element");
		}

		if (!(element instanceof HTMLElement)) {
			throw new TypeError("the element must be an HTMLElement");
		}

		customElements.whenDefined(element.tagName.toLocaleLowerCase()).then(() => {
			if (!(element instanceof Datasource)) {
				throw new TypeError("the element must be a datasource");
			}

			this[datasourceLinkedElementSymbol] = element;

			handleDataSourceChanges.call(this);
			element.datasource.attachObserver(
				new Observer(handleDataSourceChanges.bind(this)),
			);

			this.attachObserver(
				new Observer(() => {
					if (this[preventChangeSymbol] === true) {
						return;
					}
					this[preventChangeSymbol] = true;
					queueMicrotask(() => {
						importEntries.call(this);
					});
				}),
			);
		});
	}

	this[openEntryEventHandlerSymbol] = (event) => {
		const container = findTargetElementFromEvent(
			event,
			ATTRIBUTE_ROLE,
			"entry",
		);

		if (!(container instanceof HTMLElement)) {
			return;
		}

		const index = container
			.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
			.split("-")
			.pop();

		const currentEntry = this.getOption("entries." + index);

		if (currentEntry["has-children"] === false) {
			const doAction = this.getOption("actions.select");

			this.shadowRoot
				.querySelectorAll("[data-monster-role=entry].selected")
				.forEach((entry) => {
					entry.classList.remove("selected");
				});

			let intend = currentEntry.intend;
			if (intend > 0) {
				let ref = container.previousElementSibling;
				while (ref?.hasAttribute(ATTRIBUTE_INTEND)) {
					const i = Number.parseInt(ref.getAttribute(ATTRIBUTE_INTEND));

					if (Number.isNaN(i)) {
						break;
					}

					if (i < intend) {
						ref.classList.add("selected");
						if (i === 0) {
							break;
						}
						intend = i;
					}
					ref = ref.previousElementSibling;
				}
			}

			container.classList.add("selected");

			if (isFunction(doAction)) {
				doAction.call(this, currentEntry, index, event);
			}
			return;
		}

		const currentState = this.getOption("entries." + index + ".state");

		const newState = currentState === "close" ? "open" : "close";

		const doAction = this.getOption("actions." + newState);
		if (isFunction(doAction)) {
			doAction.call(this, this.getOption("entries." + index), index);
		}

		const entries = this.getOption("entries", []);
		const nextEntries = [...entries];
		let changed = false;

		nextEntries[index] = Object.assign({}, currentEntry, {
			state: newState,
		});
		changed = true;
		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)) {
				const refIntend = ref.getAttribute(ATTRIBUTE_INTEND);

				if (!cmp(Number.parseInt(refIntend), childIntend)) {
					if (refIntend === intend) {
						break;
					}
					ref = ref.nextElementSibling;
					continue;
				}

				const refIndex = ref
					.getAttribute(ATTRIBUTE_UPDATER_INSERT_REFERENCE)
					.split("-")
					.pop();
				const childIndex = Number.parseInt(refIndex);
				const childEntry = entries[childIndex];
				if (!childEntry) {
					ref = ref.nextElementSibling;
					continue;
				}

				nextEntries[childIndex] = Object.assign({}, childEntry, {
					visibility: newVisibility,
					...(newState === "close" ? { state: "close" } : {}),
				});
				changed = true;
				ref = ref.nextElementSibling;
			}
		}

		if (changed === true) {
			this.setOption("entries", nextEntries);
		}
	};

	const types = this.getOption("toggleEventType", ["click"]);
	for (const [, type] of Object.entries(types)) {
		this.shadowRoot.addEventListener(type, this[openEntryEventHandlerSymbol]);
	}

	return this;
}

/**
 * @private
 * @this {TreeMenu}
 */
function initObserver() {}

/**
 * Import Menu Entries from dataset
 *
 * @since 1.0.0
 * @return {TreeMenu}
 * @throws {Error} map is not iterable
 * @private
 */
function importEntries() {
	const data = this.getOption("data");
	this[internalNodesSymbol] = new Map();

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

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

	const id = this.getOption("mapping.idTemplate");
	const parentKey = this.getOption("mapping.parentKey");

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

	let filteredData;
	if (this[firstRunDoneSymbol] !== true) {
		filteredData = data?.filter(
			(entry) =>
				!entry[parentKey] ||
				entry[parentKey] === null ||
				entry[parentKey] === undefined ||
				entry[parentKey] === 0,
		);
		setTimeout(() => {
			this[firstRunDoneSymbol] = true;
			importEntries.call(this);
		}, 0);
	} else {
		filteredData = data;
	}

	let nodes;
	try {
		nodes = buildTree(filteredData, selector, id, parentKey, {
			filter,
			rootReferences,
		});
	} catch (error) {
		addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, String(error));
		return this;
	}

	const options = [];
	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 icon = formattedValues?.icon;
			const intend = n.level;

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

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

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

	this.setOption("entries", options);
	fireEvent(this, "entries-imported");

	return this;
}

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

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

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

	const iconID = new Formatter(node.value).format(
		this.getOption("mapping.iconTemplate"),
	);

	const icon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><use xlink:href="#${iconID}"></use></svg>`;

	return {
		value,
		label,
		icon,
	};
}

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

	this[controlElementSymbol] = this.shadowRoot.querySelector(
		"[data-monster-role=control]",
	);

	return this;
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <slot></slot>

        <template id="entries">
            <div data-monster-role="entry"
                 data-monster-attributes="
                 data-monster-intend path:entries.intend, 
                 data-monster-state path:entries.state, 
                 data-monster-visibility path:entries.visibility, 
                 data-monster-filtered path:entries.filtered,
                 data-monster-has-children path:entries.has-children">
                <div data-monster-role="button"
                     data-monster-attributes="
                                    value path:entries.value | tostring
                    " tabindex="0">
                    <div data-monster-role="status-badges"></div>
                    <div data-monster-role="icon" data-monster-replace="path:entries.icon">

                    </div>
                    <div data-monster-replace="path:entries.label"
                         part="entry-label"
                         data-monster-attributes="class static:id"></div>
                </div>
        </template>

        <div data-monster-role="control" part="control" data-monster-attributes="class path:classes.control">
            <div part="entries" data-monster-role="entries"
                 data-monster-insert="entries path:entries"
                 tabindex="-1"></div>
        </div>
    `;
}

registerCustomElement(TreeMenu);
