/**
 * 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 { addAttributeToken } from "../../dom/attributes.mjs";
import {
	ATTRIBUTE_ERRORMESSAGE,
	ATTRIBUTE_ROLE,
	ATTRIBUTE_UPDATER_INSERT_REFERENCE,
} from "../../dom/constants.mjs";
import {
	assembleMethodSymbol,
	CustomElement,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { findTargetElementFromEvent } from "../../dom/events.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { isFunction, isString } from "../../types/is.mjs";
import { ID } from "../../types/id.mjs";
import { CommonStyleSheet } from "../stylesheet/common.mjs";
import { TreeMenuStyleSheet } from "./stylesheet/tree-menu.mjs";
import { ATTRIBUTE_INTEND } from "./../constants.mjs";

export { HtmlTreeMenu };

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

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

/**
 * HtmlTreeMenu
 *
 * @fragments /fragments/components/tree-menu/html-tree-menu/
 *
 * @example /examples/components/tree-menu/html-tree-menu-simple Basic HTML tree menu
 * @example /examples/components/tree-menu/html-tree-menu-lazy Lazy loading
 *
 * @since 4.62.0
 * @summary A TreeMenu control that builds its entries from nested HTML lists.
 * @fires entries-imported
 */
class HtmlTreeMenu extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for("@schukai/monster/components/tree-menu/html@@instance");
	}

	/**
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 * @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} lazy
	 * @property {boolean} lazy.enabled enables lazy loading by endpoint
	 * @property {string} lazy.attribute="data-monster-endpoint" attribute for the endpoint
	 * @property {Object} lazy.fetchOptions fetch options for lazy requests
	 * @property {Object} features
	 * @property {boolean} features.selectParents=false allow selecting entries with children
	 * @property {Object} actions
	 * @property {Function} actions.open the action to open an entry (entry, index, event)
	 * @property {Function} actions.close the action to close an entry (entry, index, event)
	 * @property {Function} actions.select the action to select an entry (entry, index, event)
	 * @property {Function} actions.onexpand the action to expand an entry (entry, index, event)
	 * @property {Function} actions.oncollapse the action to collapse an entry (entry, index, event)
	 * @property {Function} actions.onselect the action to select an entry (entry, index, event)
	 * @property {Function} actions.onnavigate the action to navigate (entry, index, event)
	 * @property {Function} actions.onlazyload the action before lazy load (entry, index, event)
	 * @property {Function} actions.onlazyloaded the action after lazy load (entry, index, event)
	 * @property {Function} actions.onlazyerror the action on lazy error (entry, index, event)
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			classes: {
				control: "monster-theme-primary-1",
				label: "monster-theme-primary-1",
			},
			lazy: {
				enabled: true,
				attribute: "data-monster-endpoint",
				fetchOptions: {
					method: "GET",
				},
			},
			features: {
				selectParents: false,
			},
			templates: {
				main: getTemplate(),
			},
			actions: {
				open: null,
				close: null,
				select: (entry) => {
					console.warn("select action is not defined", entry);
				},
				onexpand: null,
				oncollapse: null,
				onselect: null,
				onnavigate: null,
				onlazyload: null,
				onlazyloaded: null,
				onlazyerror: null,
			},
			updater: {
				batchUpdates: true,
			},
			entries: [],
		});
	}

	/**
	 * @return {void}
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		this[entryIndexSymbol] = new Map();
		initEventHandler.call(this);
		importEntries.call(this);
	}

	/**
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [CommonStyleSheet, TreeMenuStyleSheet];
	}

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

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

		value = String(value);
		const index = findEntryIndex.call(this, value);
		if (index === -1) {
			return;
		}

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

		if (!currentNode) {
			return;
		}

		const allowParentSelect = this.getOption("features.selectParents") === true;
		if (currentEntry?.["has-children"] === true && allowParentSelect) {
			applySelection.call(this, currentEntry, Number(index), currentNode);
			return;
		}

		currentNode.click();
	}

	/**
	 * Find an entry by value.
	 *
	 * @param {string} value
	 * @return {Object|null}
	 */
	findEntry(value) {
		const index = findEntryIndex.call(this, String(value));
		if (index === -1) {
			return null;
		}
		return {
			entry: this.getOption("entries." + index),
			index,
			node: getEntryNode.call(this, index),
		};
	}

	/**
	 * Open a node by value.
	 *
	 * @param {string} value
	 * @return {void}
	 */
	openEntry(value) {
		toggleEntryState.call(this, String(value), "open");
	}

	/**
	 * Expand a node and all its descendants by value.
	 *
	 * @param {string} value
	 * @return {void}
	 */
	expandEntry(value) {
		expandEntry.call(this, String(value));
	}

	/**
	 * Collapse a node and all its descendants by value.
	 *
	 * @param {string} value
	 * @return {void}
	 */
	collapseEntry(value) {
		collapseEntry.call(this, String(value));
	}

	/**
	 * Close a node by value.
	 *
	 * @param {string} value
	 * @return {void}
	 */
	closeEntry(value) {
		toggleEntryState.call(this, String(value), "close");
	}

	/**
	 * Show a node by value.
	 *
	 * @param {string} value
	 * @return {void}
	 */
	showEntry(value) {
		setEntryVisibility.call(this, String(value), "visible");
	}

	/**
	 * Hide a node by value.
	 *
	 * @param {string} value
	 * @return {void}
	 */
	hideEntry(value) {
		setEntryVisibility.call(this, String(value), "hidden");
	}

	/**
	 * Remove a node by value.
	 *
	 * @param {string} value
	 * @return {void}
	 */
	removeEntry(value) {
		removeEntry.call(this, String(value));
	}

	/**
	 * Insert a node.
	 *
	 * @param {Object} entry
	 * @param {string|null} parentValue
	 * @return {void}
	 */
	insertEntry(entry, parentValue = null) {
		insertEntry.call(this, entry, parentValue);
	}

	/**
	 * Insert a node before a reference entry.
	 *
	 * @param {Object} entry
	 * @param {string} referenceValue
	 * @return {void}
	 */
	insertEntryBefore(entry, referenceValue) {
		insertEntryAt.call(this, entry, String(referenceValue), "before");
	}

	/**
	 * Insert a node after a reference entry.
	 *
	 * @param {Object} entry
	 * @param {string} referenceValue
	 * @return {void}
	 */
	insertEntryAfter(entry, referenceValue) {
		insertEntryAt.call(this, entry, String(referenceValue), "after");
	}
}

/**
 * @private
 */
function initEventHandler() {
	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);

		const allowParentSelect = this.getOption("features.selectParents") === true;
		if (currentEntry["has-children"] === false) {
			const href = currentEntry.href;
			const isNavigation = isString(href) && href !== "";
			const doNavigate = getAction.call(this, ["onnavigate", "navigate"]);

			if (isNavigation) {
				let allowNavigation = true;
				if (isFunction(doNavigate)) {
					const result = doNavigate.call(this, currentEntry, index, event);
					if (result === false) {
						allowNavigation = false;
					}
				}

				const navEvent = dispatchEntryEvent.call(
					this,
					"monster-html-tree-menu-navigate",
					{
						entry: currentEntry,
						index,
						event,
					},
				);
				if (navEvent.defaultPrevented) {
					allowNavigation = false;
				}

				if (!allowNavigation) {
					event.preventDefault();
					return;
				}

				if (isAnchorEvent(event) === false) {
					window.location.assign(href);
				}
				return;
			}

			applySelection.call(this, currentEntry, Number(index), container, event);
			return;
		}

		const currentState = this.getOption("entries." + index + ".state");
		const newState = currentState === "close" ? "open" : "close";
		if (newState === "open") {
			const entry = this.getOption("entries." + index);
			if (shouldLazyLoad.call(this, entry)) {
				void ensureEntryLoaded
					.call(this, Number(index), event)
					.then((loaded) => {
						if (loaded) {
							applyEntryState.call(this, Number(index), newState, event);
						}
					});
				return;
			}
		}

		applyEntryState.call(this, Number(index), newState, event);

		if (allowParentSelect) {
			applySelection.call(this, currentEntry, Number(index), container, event);
		}
	};

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

/**
 * Import menu entries from HTML list.
 *
 * @private
 */
function importEntries() {
	const rootList = this.querySelector("ul,ol");
	if (!(rootList instanceof HTMLElement)) {
		addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "no list found");
		return;
	}

	rootList.setAttribute("hidden", "");
	rootList.classList.add("hidden");

	const entries = [];
	buildEntriesFromList.call(this, rootList, 0, false, entries);
	this.setOption("entries", entries);
	rebuildEntryIndex.call(this);
}

/**
 * @private
 * @param {HTMLElement} list
 * @param {number} level
 * @param {boolean} ancestorHidden
 * @param {Array} entries
 */
function buildEntriesFromList(list, level, ancestorHidden, entries) {
	const lazyConfig = this.getOption("lazy", {});
	const lazyEnabled = lazyConfig?.enabled !== false;
	const lazyAttribute = lazyConfig?.attribute || "";
	const items = Array.from(list.children).filter((node) => node.matches("li"));

	for (const li of items) {
		const childList = li.querySelector(":scope > ul,:scope > ol");
		const hasChildList =
			childList instanceof HTMLElement &&
			Array.from(childList.children).some((node) => node.matches("li"));
		const endpoint =
			lazyEnabled && isString(lazyAttribute) && lazyAttribute !== ""
				? li.getAttribute(lazyAttribute)
				: null;
		const hasLazyEndpoint = isString(endpoint) && endpoint !== "";
		const hasChildren = hasChildList || hasLazyEndpoint;

		const label = getLabelHtml(li, childList);
		const value = getEntryValue(li);
		const href = getEntryHref(li, childList);
		const liHidden = isHiddenElement(li);
		const childHidden = childList ? isHiddenElement(childList) : false;
		let state = "close";
		if (hasChildren && !(childHidden || hasLazyEndpoint)) {
			state = "open";
		}
		const visibility = ancestorHidden || liHidden ? "hidden" : "visible";

		entries.push({
			value,
			label,
			icon: "",
			intend: level,
			state,
			visibility,
			["has-children"]: hasChildren,
			href,
			["lazy-endpoint"]: hasLazyEndpoint ? endpoint : null,
			["lazy-loaded"]: hasLazyEndpoint ? hasChildList : true,
			["lazy-loading"]: false,
		});

		if (hasChildList) {
			buildEntriesFromList.call(
				this,
				childList,
				level + 1,
				ancestorHidden || liHidden || state === "close",
				entries,
			);
		}
	}
}

/**
 * @private
 * @param {HTMLElement} element
 * @return {boolean}
 */
function isHiddenElement(element) {
	return (
		element.hasAttribute("hidden") ||
		element.classList.contains("hidden") ||
		element.classList.contains("hide") ||
		element.classList.contains("none")
	);
}

/**
 * @private
 * @param {HTMLElement} li
 * @param {HTMLElement|null} childList
 * @return {string}
 */
function getLabelHtml(li, childList) {
	const clone = li.cloneNode(true);
	if (childList) {
		const nested = clone.querySelector("ul,ol");
		if (nested) {
			nested.remove();
		}
	}
	const html = clone.innerHTML.trim();
	if (html !== "") {
		return html;
	}
	return li.textContent.trim();
}

/**
 * @private
 * @param {HTMLElement} li
 * @param {HTMLElement|null} childList
 * @return {string|null}
 */
function getEntryHref(li, childList) {
	const clone = li.cloneNode(true);
	if (childList) {
		const nested = clone.querySelector("ul,ol");
		if (nested) {
			nested.remove();
		}
	}
	const anchor = clone.querySelector("a[href]");
	const href = anchor?.getAttribute("href") || li.getAttribute("href");
	if (isString(href) && href !== "") {
		return href;
	}
	return null;
}

/**
 * @private
 * @param {HTMLElement} li
 * @return {string}
 */
function getEntryValue(li) {
	const value =
		li.getAttribute("data-monster-value") ||
		li.getAttribute("data-value") ||
		li.getAttribute("id");
	if (isString(value) && value !== "") {
		return value;
	}
	return new ID().toString();
}

/**
 * @private
 * @param {string} value
 * @return {number}
 */
function findEntryIndex(value) {
	if (this[entryIndexSymbol].has(value)) {
		return this[entryIndexSymbol].get(value);
	}
	const entries = this.getOption("entries", []);
	return entries.findIndex((entry) => String(entry.value) === value);
}

/**
 * @private
 */
function rebuildEntryIndex() {
	this[entryIndexSymbol].clear();
	const entries = this.getOption("entries", []);
	for (let i = 0; i < entries.length; i += 1) {
		this[entryIndexSymbol].set(String(entries[i].value), i);
	}
}

/**
 * @private
 * @param {number} index
 * @return {HTMLElement|null}
 */
function getEntryNode(index) {
	return this.shadowRoot.querySelector(
		`[data-monster-insert-reference=entries-${index}]`,
	);
}

/**
 * @private
 * @param {Object} entry
 * @return {Object}
 */
function normalizeEntry(entry) {
	const rawEndpoint =
		entry?.endpoint || entry?.["lazy-endpoint"] || entry?.lazyEndpoint;
	const endpoint =
		isString(rawEndpoint) && rawEndpoint !== "" ? rawEndpoint : null;
	const hasChildren = entry?.["has-children"] === true || endpoint !== null;
	return {
		value: String(entry?.value || new ID().toString()),
		label: entry?.label || "",
		icon: entry?.icon || "",
		href: entry?.href || null,
		intend: 0,
		state: "close",
		visibility: "visible",
		["has-children"]: hasChildren,
		["lazy-endpoint"]: endpoint,
		["lazy-loaded"]: endpoint === null,
		["lazy-loading"]: false,
	};
}

/**
 * @private
 * @param {Array} entries
 * @param {number} index
 * @return {number}
 */
function getSubtreeEndIndex(entries, index) {
	const targetIntend = entries[index].intend;
	let end = index + 1;
	while (end < entries.length && entries[end].intend > targetIntend) {
		end += 1;
	}
	return end;
}

/**
 * @private
 * @param {string} value
 * @param {string} state
 */
function toggleEntryState(value, state) {
	const index = findEntryIndex.call(this, value);
	if (index === -1) {
		return;
	}
	if (state === "open") {
		const entry = this.getOption("entries." + index);
		if (shouldLazyLoad.call(this, entry)) {
			void ensureEntryLoaded.call(this, index).then((loaded) => {
				if (loaded) {
					applyEntryState.call(this, index, state);
				}
			});
			return;
		}
	}
	applyEntryState.call(this, index, state);
}

/**
 * @private
 * @param {number} index
 * @param {string} state
 * @param {Event|undefined} event
 */
function applyEntryState(index, state, event) {
	const entry = this.getOption("entries." + index);
	if (!entry || entry["has-children"] === false) {
		return;
	}

	const actionName = state === "open" ? "onexpand" : "oncollapse";
	const doAction = getAction.call(this, [actionName, state]);
	if (isFunction(doAction)) {
		doAction.call(this, entry, index, event);
	}
	const eventName =
		state === "open"
			? "monster-html-tree-menu-expand"
			: "monster-html-tree-menu-collapse";
	fireCustomEvent(this, eventName, {
		entry,
		index,
		event,
	});

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

	nextEntries[index] = Object.assign({}, entry, {
		state,
	});
	changed = true;
	const newVisibility = state === "open" ? "visible" : "hidden";
	const targetIntend = entry.intend;
	const end = getSubtreeEndIndex(entries, index);
	const childIntend = targetIntend + 1;

	for (let i = index + 1; i < end; i += 1) {
		const childEntry = entries[i];

		if (state === "open") {
			if (childEntry.intend !== childIntend) {
				continue;
			}
			if (childEntry.visibility === newVisibility) {
				continue;
			}
		} else {
			if (childEntry.visibility === newVisibility) {
				continue;
			}
		}

		const updates = { visibility: newVisibility };
		if (state === "close" && childEntry["has-children"]) {
			updates.state = "close";
		}

		nextEntries[i] = Object.assign({}, childEntry, updates);
		changed = true;
	}

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

/**
 * @private
 * @param {string} value
 * @param {string} visibility
 */
function setEntryVisibility(value, visibility) {
	const index = findEntryIndex.call(this, value);
	if (index === -1) {
		return;
	}

	const entries = this.getOption("entries", []);
	const target = entries[index];
	if (!target) {
		return;
	}

	const nextEntries = [...entries];
	let changed = false;
	if (target.visibility !== visibility) {
		nextEntries[index] = Object.assign({}, target, {
			visibility,
		});
		changed = true;
	}
	const end = getSubtreeEndIndex(entries, index);
	for (let i = index + 1; i < end; i += 1) {
		if (visibility === "hidden") {
			if (entries[i].visibility !== "hidden") {
				nextEntries[i] = Object.assign({}, entries[i], {
					visibility: "hidden",
				});
				changed = true;
			}
		}
	}

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

/**
 * @private
 * @param {string} value
 */
function expandEntry(value) {
	const index = findEntryIndex.call(this, value);
	if (index === -1) {
		return;
	}

	const entry = this.getOption("entries." + index);
	if (!entry || entry["has-children"] === false) {
		return;
	}

	if (shouldLazyLoad.call(this, entry)) {
		void ensureEntryLoaded.call(this, index).then((loaded) => {
			if (loaded) {
				expandEntryByIndex.call(this, index);
			}
		});
		return;
	}

	expandEntryByIndex.call(this, index);
}

/**
 * @private
 * @param {string} value
 */
function collapseEntry(value) {
	const index = findEntryIndex.call(this, value);
	if (index === -1) {
		return;
	}

	const entry = this.getOption("entries." + index);
	if (!entry || entry["has-children"] === false) {
		return;
	}
	if (entry.state === "close") {
		return;
	}

	const doAction = getAction.call(this, ["oncollapse", "close"]);
	if (isFunction(doAction)) {
		doAction.call(this, entry, index);
	}
	fireCustomEvent(this, "monster-html-tree-menu-collapse", {
		entry,
		index,
	});

	const entries = this.getOption("entries", []);
	const nextEntries = [...entries];
	let changed = false;
	nextEntries[index] = Object.assign({}, entries[index], {
		state: "close",
	});
	changed = true;
	const end = getSubtreeEndIndex(entries, index);
	for (let i = index + 1; i < end; i += 1) {
		const childEntry = entries[i];
		const hasChildren = childEntry["has-children"] === true;
		if (
			childEntry.visibility === "hidden" &&
			(!hasChildren || childEntry.state === "close")
		) {
			continue;
		}

		const updates = {
			visibility: "hidden",
		};
		if (hasChildren && childEntry.state !== "close") {
			updates.state = "close";
		}

		const updatedChild = Object.assign({}, childEntry, updates);
		nextEntries[i] = updatedChild;
		changed = true;
	}

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

/**
 * @private
 * @param {string} value
 */
function removeEntry(value) {
	const entries = this.getOption("entries", []);
	const index = findEntryIndex.call(this, value);
	if (index === -1) {
		return;
	}

	const targetIntend = entries[index].intend;
	let parentIndex = -1;
	for (let i = index - 1; i >= 0; i -= 1) {
		if (entries[i].intend < targetIntend) {
			parentIndex = i;
			break;
		}
	}
	const newEntries = [];
	for (let i = 0; i < entries.length; i += 1) {
		if (i === index) {
			continue;
		}
		if (i > index && entries[i].intend > targetIntend) {
			continue;
		}
		newEntries.push(entries[i]);
	}

	if (parentIndex !== -1) {
		const parentValue = String(entries[parentIndex].value);
		const parentEntryIndex = newEntries.findIndex(
			(entry) => String(entry.value) === parentValue,
		);
		if (parentEntryIndex !== -1) {
			const parentIntend = newEntries[parentEntryIndex].intend;
			const hasChildren = newEntries.some(
				(entry, idx) => idx > parentEntryIndex && entry.intend > parentIntend,
			);
			if (!hasChildren) {
				newEntries[parentEntryIndex] = Object.assign(
					{},
					newEntries[parentEntryIndex],
					{
						["has-children"]: false,
						state: "close",
					},
				);
			}
		}
	}

	this.setOption("entries", newEntries);
	rebuildEntryIndex.call(this);
}

/**
 * @private
 * @param {Object} entry
 * @param {string|null} parentValue
 */
function insertEntry(entry, parentValue) {
	const entries = this.getOption("entries", []);
	const newEntry = normalizeEntry(entry);

	let insertIndex = entries.length;
	if (isString(parentValue) && parentValue !== "") {
		const parentIndex = findEntryIndex.call(this, parentValue);
		if (parentIndex !== -1) {
			const parent = entries[parentIndex];
			newEntry.intend = parent.intend + 1;
			newEntry.visibility = parent.state === "open" ? "visible" : "hidden";
			entries[parentIndex] = Object.assign({}, parent, {
				["has-children"]: true,
			});

			insertIndex = parentIndex + 1;
			while (
				insertIndex < entries.length &&
				entries[insertIndex].intend > parent.intend
			) {
				insertIndex += 1;
			}
		}
	}

	const nextEntries = [
		...entries.slice(0, insertIndex),
		newEntry,
		...entries.slice(insertIndex),
	];
	this.setOption("entries", nextEntries);
	rebuildEntryIndex.call(this);
}

/**
 * @private
 * @param {Object} entry
 * @param {string} referenceValue
 * @param {string} position
 */
function insertEntryAt(entry, referenceValue, position) {
	const entries = this.getOption("entries", []);
	const referenceIndex = findEntryIndex.call(this, referenceValue);

	if (referenceIndex === -1) {
		insertEntry.call(this, entry, null);
		return;
	}

	const referenceEntry = entries[referenceIndex];
	const newEntry = normalizeEntry(entry);
	newEntry.intend = referenceEntry.intend;
	newEntry.visibility = referenceEntry.visibility;

	let parentIndex = -1;
	for (let i = referenceIndex - 1; i >= 0; i -= 1) {
		if (entries[i].intend < referenceEntry.intend) {
			parentIndex = i;
			break;
		}
	}

	if (parentIndex !== -1) {
		entries[parentIndex] = Object.assign({}, entries[parentIndex], {
			["has-children"]: true,
		});
	}

	let insertIndex = referenceIndex;
	if (position === "after") {
		insertIndex = getSubtreeEndIndex(entries, referenceIndex);
	}

	const nextEntries = [
		...entries.slice(0, insertIndex),
		newEntry,
		...entries.slice(insertIndex),
	];

	this.setOption("entries", nextEntries);
	rebuildEntryIndex.call(this);
}

/**
 * @private
 * @param {number} index
 */
function expandEntryByIndex(index) {
	const entry = this.getOption(`entries.${index}`);
	if (!entry || entry["has-children"] === false) {
		return;
	}

	const doAction = getAction.call(this, ["onexpand", "open"]);
	if (isFunction(doAction)) {
		doAction.call(this, entry, index);
	}
	fireCustomEvent(this, "monster-html-tree-menu-expand", {
		entry,
		index,
	});

	const entries = this.getOption("entries", []);
	const nextEntries = [...entries];
	let changed = false;
	nextEntries[index] = Object.assign({}, entry, {
		state: "open",
		visibility: "visible",
	});
	changed = true;
	const end = getSubtreeEndIndex(entries, index);
	for (let i = index + 1; i < end; i += 1) {
		const childEntry = entries[i];
		const changedVisibility = childEntry.visibility !== "visible";
		const changedState =
			childEntry["has-children"] && childEntry.state !== "open";

		if (!changedVisibility && !changedState) {
			continue;
		}

		const updatedChild = Object.assign({}, childEntry, {
			visibility: "visible",
		});
		if (childEntry["has-children"]) {
			updatedChild.state = "open";
		}
		nextEntries[i] = updatedChild;
		changed = true;
	}

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

/**
 * @private
 * @param {Object} entry
 * @return {boolean}
 */
function shouldLazyLoad(entry) {
	if (!entry) {
		return false;
	}
	const lazyConfig = this.getOption("lazy", {});
	if (lazyConfig?.enabled === false) {
		return false;
	}
	const endpoint = entry["lazy-endpoint"];
	return (
		isString(endpoint) &&
		endpoint !== "" &&
		entry["lazy-loaded"] !== true &&
		entry["lazy-loading"] !== true
	);
}

/**
 * @private
 * @param {number} index
 * @param {Event|undefined} event
 * @return {Promise<boolean>}
 */
async function ensureEntryLoaded(index, event) {
	const entry = this.getOption(`entries.${index}`);
	if (!shouldLazyLoad.call(this, entry)) {
		return true;
	}

	const beforeLoadAction = getAction.call(this, ["onlazyload"]);
	if (isFunction(beforeLoadAction)) {
		beforeLoadAction.call(this, entry, index, event);
	}
	fireCustomEvent(this, "monster-html-tree-menu-lazy-load", {
		entry,
		index,
		event,
	});

	this.setOption(`entries.${index}.lazy-loading`, true);

	const endpoint = entry["lazy-endpoint"];
	const lazyConfig = this.getOption("lazy", {});
	const fetchOptions = Object.assign(
		{
			method: "GET",
		},
		lazyConfig?.fetchOptions || {},
	);

	let response = null;
	try {
		response = await fetch(endpoint, fetchOptions);
		if (!response.ok) {
			throw new Error("failed to load lazy entry");
		}
	} catch (e) {
		this.setOption(`entries.${index}.lazy-loading`, false);
		addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
		const errorAction = getAction.call(this, ["onlazyerror"]);
		if (isFunction(errorAction)) {
			errorAction.call(this, entry, index, event);
		}
		fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
			entry,
			index,
			event,
		});
		return false;
	}

	let html = "";
	try {
		html = await response.text();
	} catch (e) {
		this.setOption(`entries.${index}.lazy-loading`, false);
		addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, `${e}`);
		const errorAction = getAction.call(this, ["onlazyerror"]);
		if (isFunction(errorAction)) {
			errorAction.call(this, entry, index, event);
		}
		fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
			entry,
			index,
			event,
		});
		return false;
	}

	const list = parseLazyList(html);
	if (!list) {
		this.setOption(`entries.${index}.lazy-loading`, false);
		addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, "lazy entry has no list");
		const errorAction = getAction.call(this, ["onlazyerror"]);
		if (isFunction(errorAction)) {
			errorAction.call(this, entry, index, event);
		}
		fireCustomEvent(this, "monster-html-tree-menu-lazy-error", {
			entry,
			index,
			event,
		});
		return false;
	}

	const childEntries = [];
	buildEntriesFromList.call(this, list, entry.intend + 1, false, childEntries);

	const entries = this.getOption("entries", []);
	const insertIndex = getSubtreeEndIndex(entries, index);
	const updatedEntry = Object.assign({}, entries[index], {
		["lazy-loaded"]: true,
		["lazy-loading"]: false,
		["has-children"]: childEntries.length > 0,
		state: childEntries.length > 0 ? "open" : "close",
	});

	const nextEntries = [
		...entries.slice(0, insertIndex),
		...childEntries,
		...entries.slice(insertIndex),
	];
	nextEntries[index] = updatedEntry;

	this.setOption("entries", nextEntries);
	rebuildEntryIndex.call(this);

	const loadedEntry = this.getOption(`entries.${index}`);
	const loadedAction = getAction.call(this, ["onlazyloaded"]);
	if (isFunction(loadedAction)) {
		loadedAction.call(this, loadedEntry, index, event);
	}
	fireCustomEvent(this, "monster-html-tree-menu-lazy-loaded", {
		entry: loadedEntry,
		index,
		event,
	});

	return childEntries.length > 0;
}

/**
 * @private
 * @param {string} html
 * @return {HTMLElement|null}
 */
function parseLazyList(html) {
	if (!isString(html) || html.trim() === "") {
		return null;
	}

	const parser = new DOMParser();
	const doc = parser.parseFromString(html, "text/html");
	const list = doc.querySelector("ul,ol");
	if (list) {
		return list;
	}

	const items = Array.from(doc.body.children).filter((node) =>
		node.matches("li"),
	);
	if (items.length === 0) {
		return null;
	}

	const fallback = document.createElement("ul");
	for (const item of items) {
		fallback.appendChild(document.importNode(item, true));
	}
	return fallback;
}

/**
 * @private
 * @param {string[]} names
 * @return {Function|null}
 */
function getAction(names) {
	for (const name of names) {
		const action = this.getOption(`actions.${name}`);
		if (isFunction(action)) {
			return action;
		}
	}
	return null;
}

/**
 * @private
 * @param {Event} event
 * @return {boolean}
 */
function isAnchorEvent(event) {
	if (!event || typeof event.composedPath !== "function") {
		return false;
	}
	const path = event.composedPath();
	for (const node of path) {
		if (node instanceof HTMLAnchorElement && node.hasAttribute("href")) {
			return true;
		}
	}
	return false;
}

/**
 * @private
 * @param {string} type
 * @param {Object} detail
 * @return {CustomEvent}
 */
function dispatchEntryEvent(type, detail) {
	const event = new CustomEvent(type, {
		bubbles: true,
		cancelable: true,
		composed: true,
		detail,
	});
	this.dispatchEvent(event);
	return event;
}

/**
 * @private
 * @param {Object} entry
 * @param {number} index
 * @param {HTMLElement} container
 * @param {Event|undefined} event
 */
function applySelection(entry, index, container, event) {
	this.shadowRoot
		.querySelectorAll("[data-monster-role=entry].selected")
		.forEach((node) => {
			node.classList.remove("selected");
		});

	let intend = entry.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");

	const doSelect = getAction.call(this, ["onselect", "select"]);
	if (isFunction(doSelect)) {
		doSelect.call(this, entry, index, event);
	}

	fireCustomEvent(this, "monster-html-tree-menu-select", {
		entry,
		index,
		event,
	});
}

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