/**
 * 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 { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import { CustomElement } from "../../dom/customelement.mjs";
import {
	assembleMethodSymbol,
	registerCustomElement,
} from "../../dom/customelement.mjs";
import { TableOfContentStyleSheet } from "./stylesheet/table-of-content.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { getWindow } from "../../dom/util.mjs";
import "../layout/popper.mjs";

export { TableOfContent };

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

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

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

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

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

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

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

/**
 * A TableOfContent
 *
 * @fragments /fragments/components/navigation/table-of-content/
 *
 * @example /examples/components/navigation/table-of-content-simple Table of content
 *
 * @since 3.65.0
 * @copyright Volker Schukai
 * @summary A table-of-content navigation that links headings and helps users scan long structured pages.
 * @fires new-top The new top position
 */
class TableOfContent extends CustomElement {
	/**
	 * This method is called by the `instanceof` operator.
	 * @return {symbol}
	 */
	static get [instanceSymbol]() {
		return Symbol.for(
			"@schukai/monster/components/navigation/table-of-content@@instance",
		);
	}

	/**
	 *
	 * @return {Components.Navigation.TableOfContent
	 */
	[assembleMethodSymbol]() {
		super[assembleMethodSymbol]();
		initControlReferences.call(this);
		initEventHandler.call(this);
		return this;
	}

	/**
	 * 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.
	 *
	 * @property {Object} templates Template definitions
	 * @property {string} templates.main Main template
	 * @property {Object} labels Label definitions
	 * @property {Object} features Features
	 * @property {boolean} features.showScrollToTop=true Show scroll to top
	 * @property {boolean} features.showScrollToBottom=true Show scroll to bottom
	 * @property {number} offset=100 Navigation offset from top
	 * @property {string} position="right" Navigation position (right, left)
	 * @property {Object} classes CSS classes
	 * @property {boolean} disabled=false Disabled state
	 */
	get defaults() {
		return Object.assign({}, super.defaults, {
			templates: {
				main: getTemplate(),
			},
			labels: {
				scrollToTop: "⇧",
				scrollToBottom: "⇩",
			},
			classes: {},
			disabled: false,
			features: {
				showScrollToTop: true,
				showScrollToBottom: true,
			},
			offset: 50,
			position: "right",
		});
	}

	/**
	 * @return {void}
	 */
	connectedCallback() {
		super.connectedCallback();

		initNavigation.call(this);

		const position = this.getOption("position");
		if (position === "left") {
			this[navigationElementSymbol].classList.remove("right");
			this[navigationElementSymbol].classList.add("left");
		} else {
			this[navigationElementSymbol].classList.remove("left");
			this[navigationElementSymbol].classList.add("right");
		}

		setTimeout(() => {
			this[scrollableParentSymbol] = findScrollableParent(this);

			if (this[scrollableParentSymbol] === getWindow()) {
				if (
					["absolute", "relative", "fixed", "sticky"].indexOf(
						this[scrollableParentSymbol]?.style?.position,
					) === -1
				) {
					this.style.position = "relative";
				}

				this[scrollableParentSymbol].addEventListener(
					"scroll",
					this[windowEventHandlerSymbol],
				);
				calcAndSetNavigationTopWindowContext.call(this);
			} else {
				if (
					["absolute", "relative", "fixed", "sticky"].indexOf(
						this[scrollableParentSymbol]?.style?.position,
					) === -1
				) {
					this[scrollableParentSymbol].style.position = "relative";
				}

				this[scrollableParentSymbol].addEventListener(
					"scroll",
					this[scrollableEventHandlerSymbol],
				);
				calcAndSetNavigationTopScrollableParentContext.call(this);
			}
		}, 10);
	}

	/**
	 * @return {void}
	 */
	disconnectedCallback() {
		super.disconnectedCallback();

		if (!this[scrollableParentSymbol]) {
			return;
		}

		if (this[scrollableParentSymbol] === getWindow()) {
			this[scrollableParentSymbol].removeEventListener(
				"scroll",
				this[windowEventHandlerSymbol],
			);
		} else {
			this[scrollableParentSymbol].removeEventListener(
				"scroll",
				this[scrollableEventHandlerSymbol],
			);
		}
	}

	/**
	 * @return {string}
	 */
	static getTag() {
		return "monster-table-of-content";
	}

	/**
	 * @return {CSSStyleSheet[]}
	 */
	static getCSSStyleSheet() {
		return [TableOfContentStyleSheet];
	}
}

/**
 * @private
 * @param element
 * @returns {number|number|*|number}
 */
function getScrollHeight(element) {
	if (element instanceof ShadowRoot) {
		return element.host.scrollHeight;
	}

	if (element === getWindow()) {
		return element.document.documentElement.scrollHeight;
	}

	return element.scrollHeight;
}

/**
 * @private
 * @return {void}
 * @fires new-top - The new top position
 */
function calcAndSetNavigationTopWindowContext() {
	const rect = this.getBoundingClientRect();
	const thisTop = rect.top;
	const thisBottom = rect.bottom;
	let top = 0;
	if (thisTop < 0) {
		top = +(-1 * thisTop);
	}

	const offset = this.getOption("offset");
	if (offset > 0) {
		top += offset;
	}

	if (thisBottom < 0) {
		return;
	}

	fireCustomEvent(this, "new-top", { top: top });

	this[navigationElementSymbol].style.top = top + "px";
}

/**
 * @private
 * @return {void}
 * @fires new-top - The new top position
 */
function calcAndSetNavigationTopScrollableParentContext() {
	if (!this[scrollableParentSymbol]) {
		return;
	}

	const parentRect = this[scrollableParentSymbol].getBoundingClientRect();
	const rect = this.getBoundingClientRect();
	const thisBottom = rect.bottom - parentRect.top;
	let top = this[scrollableParentSymbol].scrollTop || 0;

	const offset = this.getOption("offset");
	if (offset > 0) {
		top += offset;
	}

	if (thisBottom < 0) {
		return;
	}

	fireCustomEvent(this, "new-top", { top: top });

	this[navigationElementSymbol].style.top = top + "px";
}

/**
 * @private
 */
function initNavigation() {
	const headings = getHeadings.call(this);

	for (const heading of headings) {
		const div = document.createElement("div");
		div.classList.add("heading-strip");
		div.classList.add("level-" + heading.tagName.toLowerCase());
		this[navigationControlElementSymbol].appendChild(div);
	}

	let startLevel = 7;
	for (const heading of headings) {
		if (parseInt(heading.tagName.substring(1)) < startLevel) {
			startLevel = parseInt(heading.tagName.substring(1));
		}
	}

	if (startLevel === 7) {
		// no headings found
		return;
	}

	this[navigationListElementSymbol].appendChild(
		createListFromHeadings.call(this, headings, startLevel).sublist,
	);

	const footer = document.createElement("div");
	footer.classList.add("footer");

	if (this.getOption("features.showScrollToTop")) {
		const scrollToTop = document.createElement("div");
		scrollToTop.textContent = this.getOption("labels.scrollToTop");
		scrollToTop.classList.add("scroll-to-top");
		scrollToTop.addEventListener("click", () => {
			if (!this[scrollableParentSymbol]) {
				return;
			}
			this[scrollableParentSymbol].scrollTo(0, 0);
		});
		footer.appendChild(scrollToTop);
	}

	if (this.getOption("features.showScrollToBottom")) {
		const scrollToBottom = document.createElement("div");
		scrollToBottom.textContent = this.getOption("labels.scrollToBottom");
		scrollToBottom.classList.add("scroll-to-bottom");
		scrollToBottom.addEventListener("click", () => {
			if (!this[scrollableParentSymbol]) {
				return;
			}

			this[scrollableParentSymbol].scrollTo(
				0,
				getScrollHeight(this[scrollableParentSymbol]),
			);
		});
		footer.appendChild(scrollToBottom);
	}

	if (footer.children.length > 0) {
		this[navigationListElementSymbol].appendChild(footer);
	}
}

/**
 * Recursively creates a nested list (UL) from a list of heading elements.
 * @param {HTMLElement[]} nodeList - The list of heading elements.
 * @param {number} currentLevel - The current heading level we are processing.
 * @return {{sublist: HTMLUListElement, lastIndex: number}} An object containing the sublist and the index of the last processed element.
 */
function createListFromHeadings(nodeList, currentLevel = 1) {
	const self = this;
	let ul = document.createElement("ul");
	let i = 0;

	while (i < nodeList.length) {
		const node = nodeList[i];
		const level = parseInt(node.tagName.substring(1));

		if (level === currentLevel) {
			const li = document.createElement("li");
			li.textContent = node.textContent;

			li.addEventListener("click", (e) => {
				e.stopPropagation();
				getWindow().requestAnimationFrame(() => {
					if (!node) {
						return;
					}

					node.scrollIntoView({
						behavior: "smooth",
						block: "start",
						inline: "nearest",
					});
				});
			});

			ul.appendChild(li);
			i++;
		} else if (level > currentLevel) {
			if (ul.lastChild) {
				const { sublist, lastIndex } = createListFromHeadings.call(
					self,
					nodeList.slice(i),
					level,
				);
				ul.lastChild.appendChild(sublist);
				i += lastIndex;
			} else {
				throw new Error(
					"Heading structure error: higher level " +
						level +
						" found without a parent (level " +
						currentLevel +
						")",
				);
			}
		} else {
			break;
		}
	}

	return { sublist: ul, lastIndex: i };
}

/**
 * @private
 * @return {*[]}
 */
function getHeadings() {
	const allHeadings = [];

	const slots = this.shadowRoot.querySelectorAll("slot");

	slots.forEach((slot) => {
		const slottedElements = slot.assignedElements();

		slottedElements.forEach((element) => {
			if (element instanceof HTMLHeadingElement) {
				allHeadings.push(element);
				return;
			}

			const headings = element.querySelectorAll("h1, h2, h3, h4, h5, h6");
			let nodeList = Array.from(headings);

			nodeList = nodeList.filter((node) => {
				return !node.hasAttribute("data-monster-table-of-content-omit");
			});

			allHeadings.push(...nodeList);
		});
	});

	return allHeadings;
}

/**
 * @private
 * @return {initEventHandler}
 */
function initEventHandler() {
	const self = this;
	let ticking = false;

	this[windowEventHandlerSymbol] = function () {
		if (!ticking) {
			getWindow().requestAnimationFrame(() => {
				calcAndSetNavigationTopWindowContext.call(self);
				ticking = false;
			});
			ticking = true;
		}
	};

	this[scrollableEventHandlerSymbol] = function () {
		if (!ticking) {
			getWindow().requestAnimationFrame(() => {
				calcAndSetNavigationTopScrollableParentContext.call(self);
				ticking = false;
			});
			ticking = true;
		}
	};

	const observer = new IntersectionObserver(
		(entries) => {
			entries.forEach((entry) => {
				if (entry.isIntersecting) {
					getWindow().requestAnimationFrame(() => {
						if (!this[scrollableParentSymbol]) {
							return;
						}

						if (self[scrollableParentSymbol] === getWindow()) {
							calcAndSetNavigationTopWindowContext.call(self);
						} else {
							calcAndSetNavigationTopScrollableParentContext.call(self);
						}

						ticking = false;
					});
					ticking = true;
				}
			});
		},
		{
			root: null,
			rootMargin: "0px",
			threshold: 0.1,
		},
	);

	observer.observe(this);

	return this;
}

/**
 * Finds the nearest scrollable parent, including within Shadow DOMs.
 *
 * @param {HTMLElement} element - The starting element for the search.
 * @return {HTMLElement|Window} - The found scrollable parent or the Window object.
 */
function findScrollableParent(element) {
	function isScrollable(elementToCheck) {
		const styles = window.getComputedStyle(elementToCheck);
		const overflowY = styles.overflowY;
		const canScrollY =
			(overflowY === "scroll" || overflowY === "auto") &&
			elementToCheck.scrollHeight > elementToCheck.clientHeight;
		const overflowX = styles.overflowX;
		const canScrollX =
			(overflowX === "scroll" || overflowX === "auto") &&
			elementToCheck.scrollWidth > elementToCheck.clientWidth;

		return canScrollY || canScrollX;
	}

	// Helper function to find the next parent element, checking for Shadow DOM
	function getNextParent(elem) {
		// If the element is in a Shadow DOM, attempt to climb up to the host
		if (elem.parentNode && elem.parentNode.host) {
			return elem.parentNode.host;
		} else if (elem.assignedSlot) {
			// If the element is assigned to a slot, move to the slot's parent
			return elem.assignedSlot.parentNode;
		}
		// Otherwise, climb up the regular DOM
		return elem.parentElement;
	}

	let parent = getNextParent(element);
	while (parent) {
		if (isScrollable(parent)) {
			return parent;
		}
		parent = getNextParent(parent); // Move to the next parent element
	}
	return window; // Fallback if no scrollable element is found
}

/**
 * @private
 * @return {void}
 */
function initControlReferences() {
	this[tableOfContentElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="control"]`,
	);

	this[navigationElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="navigation"]`,
	);

	this[navigationControlElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="navigation-control"]`,
	);

	this[navigationListElementSymbol] = this.shadowRoot.querySelector(
		`[${ATTRIBUTE_ROLE}="navigation-list"]`,
	);
}

/**
 * @private
 * @return {string}
 */
function getTemplate() {
	// language=HTML
	return `
        <div data-monster-role="control" part="control">
            <div class="navigation" data-monster-role="navigation">
                <monster-popper data-monster-option-mode="enter" data-monster-option-popper-content-overflow="both">
                    <div slot="button" data-monster-role="navigation-control">
                    </div>
                    <div data-monster-role="navigation-list">
                    </div>
                </monster-popper>
            </div>
            <slot></slot>
        </div>`;
}

registerCustomElement(TableOfContent);
