import { showBalloonMessage } from "../../debug/debug.js";
import type { Context } from "../../engine_context.js";
import { hasCommercialLicense, onLicenseCheckResultChanged, Telemetry } from "../../engine_license.js";
import { isLocalNetwork } from "../../engine_networking_utils.js";
import { DeviceUtilities, getParam } from "../../engine_utils.js";
import { onXRSessionStart, XRSessionEventArgs } from "../../xr/events.js";
import { ButtonsFactory } from "../buttons.js";
import { ensureFonts, iconFontUrl, loadFont } from "../fonts.js";
import { getIconElement } from "../icons.js";
import { NeedleLogoElement } from "../logo-element.js";
import { getElementPriority, setElementPriority as _setElementPriority } from "./menu-priority.js";
import { NeedleSpatialMenu } from "./needle-menu-spatial.js";

declare global {
    interface HTMLElementTagNameMap {
        "needle-logo-element": NeedleLogoElement;
    }
}

const elementName = "needle-menu";
const debug = getParam("debugmenu");
const debugNonCommercial = getParam("debugnoncommercial");

/** This is the model for the postMessage event that the needle engine will send to create menu items */
export declare type NeedleMenuPostMessageModel = {
    type: "needle:menu",
    button?: {
        label?: string,
        /** Google icon name */
        icon?: string,
        /** currently only URLs are supported */
        onclick?: string,
        target?: "_blank" | "_self" | "_parent" | "_top",
        /** Low priority is icon is on the left, high priority is icon is on the right. Default is 0 */
        priority?: number,
    }
}

/**
 * Used by the NeedleMenuElement to create a button at {@link NeedleMenuElement#appendChild}
 */
export declare type ButtonInfo = {
    /** Invoked when the button is clicked */
    onClick: (evt: Event) => void,
    /** Visible button text */
    label: string,
    /** Material icon name: https://fonts.google.com/icons */
    icon?: string,
    /** "left" or "right" to place the icon on the left or right side of the button. Default is "left" */
    iconSide?: "left" | "right",
    /** 
     * Priority controls the order of buttons in the menu.   
     * If not enough space is available to show all buttons - the highest priority elements will always be visible
     * 
     * **Sorting**  
     * Low priority is icon is on the left, 
     * high priority is icon is on the right.   
     * @default undefined 
     */
    priority?: number;
    /** Experimental. Allows to put two buttons in one row for the compact layout */
    class?: "row2";
    title?: string;
}

/**
 * The NeedleMenu is a menu that can be displayed in the needle engine webcomponent or in VR/AR sessions.  
 * 
 * The menu can be used to add buttons to the needle engine that can be used to interact with the application.  
 * 
 * The menu can be positioned at the top or the bottom of the <needle-engine> webcomponent.
 * 
 * @example Add a new button using the NeedleMenu
 * ```typescript
 * onStart(ctx => {
 *   ctx.menu.appendChild({ 
 *    label: "Open Google",
 *    icon: "google",
 *    onClick: () => { window.open("https://www.google.com", "_blank") }
 *   });
 * })
 * ```
 * 
 * Buttons can be added to the menu using the {@link NeedleMenu#appendChild} method or by sending a postMessage event to the needle engine with the type "needle:menu". Use the {@link NeedleMenuPostMessageModel} model to create buttons with postMessage.
 * @example Create a button using a postmessage
 * ```javascript
 * window.postMessage({
 *    type: "needle:menu",
 *    button: {
 *      label: "Open Google",
 *      icon: "google",
 *      onclick: "https://www.google.com",
 *      target: "_blank",
 *    }
 * }, "*");
 * ```
 * 
 * @example Access the menu from a component
 * ```typescript
 * import { Behaviour, OnStart } from '@needle-tools/engine';
 * 
 * export class MyComponent extends Behaviour {
 * 
 *   start() {
 *    this.context.menu.appendChild({ ... });
 *   }
 * }
 * ```
 * 
 * @category HTML
 */
export class NeedleMenu {

    static setElementPriority(button: HTMLElement, priority: number) {
        _setElementPriority(button, priority);
    }

    static getElementPriority(button: HTMLElement): number | undefined {
        return getElementPriority(button);
    }

    private readonly _context: Context;
    private readonly _menu: NeedleMenuElement;
    private readonly _spatialMenu: NeedleSpatialMenu;

    constructor(context: Context) {
        this._menu = NeedleMenuElement.getOrCreate(context.domElement, context);
        this._menu.ensureInitialized();
        this._context = context;
        this._spatialMenu = new NeedleSpatialMenu(context, this._menu);
        window.addEventListener("message", this.onPostMessage);
        onXRSessionStart(this.onStartXR);
    }

    /** @ignore internal method */
    onDestroy() {
        window.removeEventListener("message", this.onPostMessage);
        this._menu.remove();
        this._spatialMenu.onDestroy();
    }

    private onPostMessage = (e: MessageEvent) => {
        // lets just allow the same origin for now
        if (e.origin !== globalThis.location.origin) return;
        if (typeof e.data === "object") {
            const data = e.data as NeedleMenuPostMessageModel;
            const type = data.type;
            if (type === "needle:menu") {
                const buttoninfo = data.button;
                if (buttoninfo) {
                    if (!buttoninfo.label) return console.error("NeedleMenu: buttoninfo.label is required");
                    if (!buttoninfo.onclick) return console.error("NeedleMenu: buttoninfo.onclick is required");
                    const button = document.createElement("button");
                    button.textContent = buttoninfo.label;
                    if (buttoninfo.icon) {
                        const icon = getIconElement(buttoninfo.icon);
                        button.prepend(icon);
                    }
                    if (buttoninfo.priority) {
                        button.setAttribute("priority", buttoninfo.priority.toString());
                    }
                    button.onclick = () => {
                        if (buttoninfo.onclick) {
                            const isLink = buttoninfo.onclick.startsWith("http") || buttoninfo.onclick.startsWith("www.");
                            const target = buttoninfo.target || "_blank";
                            if (isLink) {
                                globalThis.open(buttoninfo.onclick, target);
                            }
                            else console.error("NeedleMenu: onclick is not a valid link", buttoninfo.onclick);
                        }
                    }
                    Telemetry.sendEvent(this._context, "needle-menu", {
                        action: "button_added_via_postmessage",
                    });
                    this._menu.appendChild(button);
                }
                else if (debug) console.error("NeedleMenu: unknown postMessage event", data);
            }
            else if (debug) console.warn("NeedleMenu: unknown postMessage type", type, data);
        }
    };

    private onStartXR = (args: XRSessionEventArgs) => {
        if (args.session.isScreenBasedAR) {
            this._menu["previousParent"] = this._menu.parentNode;
            this._context.arOverlayElement.appendChild(this._menu);
            args.session.session.addEventListener("end", this.onExitXR);

            // Close the foldout if it's open on entering AR
            this._menu.closeFoldout();
        }
    }

    private onExitXR = () => {
        if (this._menu["previousParent"]) {
            this._menu["previousParent"].appendChild(this._menu);
            delete this._menu["previousParent"];
        }
    }

    /** Experimental: Change the menu position to be at the top or the bottom of the needle engine webcomponent
     * @param position "top" or "bottom"
     */
    setPosition(position: "top" | "bottom") {
        this._menu.setPosition(position);
    }

    /**
     * Call to show or hide the menu.  
     * NOTE: Hiding the menu is a PRO feature and requires a needle engine license. Hiding the menu will not work in production without a license.
     */
    setVisible(visible: boolean) {
        this._menu.setVisible(visible);
    }

    /** When set to false, the Needle Engine logo will be hidden. Hiding the logo requires a needle engine license */
    showNeedleLogo(visible: boolean) {
        this._menu.showNeedleLogo(visible);
        this._spatialMenu?.showNeedleLogo(visible);
        // setTimeout(()=>this.showNeedleLogo(!visible), 1000);
    }
    /** @returns true if the logo is visible */
    get logoIsVisible() {
        return this._menu.logoIsVisible;
    }
    /** When enabled=true the menu will be visible in VR/AR sessions */
    showSpatialMenu(enabled: boolean) {
        this._spatialMenu.setEnabled(enabled);
    }

    setSpatialMenuVisible(display: boolean) {
        this._spatialMenu.setDisplay(display);
    }

    get spatialMenuIsVisible() {
        return this._spatialMenu.isVisible;
    }

    /**
     * Call to add or remove a button to the menu to show a QR code for the current page  
     * If enabled=true then a button will be added to the menu that will show a QR code for the current page when clicked.
     */
    showQRCodeButton(enabled: boolean | "desktop-only"): HTMLButtonElement | null {
        if (enabled === "desktop-only") {
            enabled = !DeviceUtilities.isMobileDevice();
        }
        if (!enabled) {
            const button = ButtonsFactory.getOrCreate().qrButton;
            if (button) button.style.display = "none";
            return button ?? null;
        }
        else {
            const button = ButtonsFactory.getOrCreate().createQRCode();
            button.style.display = "";
            this._menu.appendChild(button);
            return button;
        }
    }

    /** Call to add or remove a button to the menu to mute or unmute the application  
     * Clicking the button will mute or unmute the application
    */
    showAudioPlaybackOption(visible: boolean): void {
        if (!visible) {
            this._muteButton?.remove();
            return;
        }
        this._muteButton = ButtonsFactory.getOrCreate().createMuteButton(this._context);
        this._menu.appendChild(this._muteButton);
    }
    private _muteButton?: HTMLButtonElement;


    showFullscreenOption(visible: boolean): void {
        if (!visible) {
            this._fullscreenButton?.remove();
            return;
        }
        this._fullscreenButton = ButtonsFactory.getOrCreate().createFullscreenButton(this._context);
        if (this._fullscreenButton) {
            this._menu.appendChild(this._fullscreenButton);
        }
    }
    private _fullscreenButton?: HTMLButtonElement | null;



    appendChild(child: HTMLElement | ButtonInfo) {
        return this._menu.appendChild(child);
    }

}

// #region Web component

/**
 * `<needle-menu>` web component — lightweight menu used by Needle Engine.
 *
 * This element is intended as an internal UI primitive for hosting application
 * menus and buttons. Use the higher-level `NeedleMenu` API from the engine
 * code to manipulate it programmatically. Public DOM-facing methods are
 * documented (appendChild / append / prepend / setPosition / setVisible).
 *
 * @element needle-menu
 */
export class NeedleMenuElement extends HTMLElement {

    static create() {
        // Ensure the element is registered before creating — guards against
        // cases where this is called before initWebComponents() has run.
        if (!customElements.get(elementName))
            customElements.define(elementName, NeedleMenuElement);
        return document.createElement(elementName);
    }

    static getOrCreate(domElement: HTMLElement, context: Context) {
        let element = domElement.querySelector(elementName) as NeedleMenuElement | null;
        if (!element && domElement.shadowRoot) {
            element = domElement.shadowRoot.querySelector(elementName);
        }
        // if no needle-menu was found in the domelement then we search the document body
        if (!element) {
            element = window.document.body.querySelector(elementName) as NeedleMenuElement | null;
        }
        if (!element) {
            // OK no menu element exists yet anywhere
            element = NeedleMenuElement.create() as NeedleMenuElement;
            if (domElement.shadowRoot)
                domElement.shadowRoot.appendChild(element);
            else
                domElement.appendChild(element);
        }
        element._domElement = domElement;
        element._context = context;
        return element as NeedleMenuElement;
    }

    private _domElement: HTMLElement | null = null;
    private _context: Context | null = null;
    private _didInitialize = false;

    constructor() {
        super();
    }

    private initializeDom() {

        const template = document.createElement('template');
        // TODO: make host full size again and move the buttons to a wrapper so that we can later easily open e.g. foldouts/dropdowns / use the whole canvas space
        template.innerHTML = `<style>

        /** Styling attributes that ensure the nested menu z-index does not cause it to overlay elements outside of <needle-engine> */
        :host {
            position: absolute;
            width: 100%;
            height: 100%;
            z-index: 0;
            top: 0;
            pointer-events: none;
        }

        /** we put base styles in a layer to allow overrides more easily (e.g. the button.mode requested animation should override the base styles) */
        @layer base {

            #root {
                position: absolute;
                width: auto;
                max-width: 95%;
                left: 50%;
                transform: translateX(-50%);
                top: min(20px, 10vh);
                padding: 0.3rem;
                display: flex;
                visibility: visible;
                flex-direction: row-reverse; /* if we overflow this should be right aligned so the logo is always visible */
                pointer-events: all;
                z-index: 1000;
            }

            /** hide the menu if it's empty **/
            #root.has-no-options.logo-hidden {
                display: none; 
            }

            /** using a div here because then we can change the class for placement **/
            #root.bottom {
                top: auto;
                bottom: min(30px, 10vh);
            }
            #root.top {
                top: calc(.7rem + env(safe-area-inset-top));
            }
            
            .wrapper {
                position: relative;
                display: flex;
                flex-direction: row;
                justify-content: center;
                align-items: stretch;
                gap: 0px;
                padding: 0 0rem;
            }

            .wrapper > *, .options > button, .options > select, ::slotted(*) {
                position: relative;
                border: none;
                border-radius: 0;
                outline: 1px solid rgba(0,0,0,0);
                display: flex;
                justify-content: center;
                align-items: center;
                max-height: 2.3rem;
                max-width: 100%;

                /** basic font settings for all entries **/
                font-size: 1rem;
                font-family: 'Roboto Flex', sans-serif;
                font-optical-sizing: auto;
                font-weight: 400;
                font-variation-settings: "wdth" 100;
                color: rgb(20,20,20);
            }

            .options > select[multiple]:hover {
                max-height: 300px;
            }

            .floating-panel-style {
                background: rgba(255, 255, 255, .4);
                outline: rgb(0 0 0 / 5%) 1px solid;
                border: 1px solid rgba(255, 255, 255, .1);
                box-shadow: 0px 7px 0.5rem 0px rgb(0 0 0 / 6%), inset 0px 0px 1.3rem rgba(0,0,0,.05);
                border-radius: 1.5rem;
                /** 
                 * to make nested background filter work 
                 * https://stackoverflow.com/questions/60997948/backdrop-filter-not-working-for-nested-elements-in-chrome 
                 **/
                &::before {
                    content: '';
                    position: absolute;
                    width: 100%;
                    height: 100%;
                    top: 0;
                    left: 0;
                    z-index: -1;
                    border-radius: 1.5rem;
                    -webkit-backdrop-filter: blur(8px);
                    backdrop-filter: blur(8px);
                }
            }

            a {
                color: inherit;
                text-decoration: none;
            }

            .options {
                display: flex;
                flex-direction: row;
                align-items: center;
            }

            .options > *, ::slotted(*) {
                max-height: 2.25rem;
                padding: .4rem .5rem;
            }
        
            :host .options > *, ::slotted(*) {
                background: transparent;
                border: none;
                white-space: nowrap;
                transition: all 0.1s linear .02s;
                border-radius: 1.5rem;
                user-select: none;
            }
            :host .options > *:hover, ::slotted(*:hover) {
                cursor: pointer;
                color: black;
                background: rgba(245, 245, 245, .8);
                box-shadow: inset 0 0 1rem rgba(0,0,30,.2);
                outline: rgba(0,0,0,.1) 1px solid;
            }
            :host .options > *:active, ::slotted(*:active) {
                background: rgba(255, 255, 255, .8);
                box-shadow: inset 0px 1px 1px rgba(255,255,255,.5), inset 0 0 2rem rgba(0,0,30,.2), inset 0px 2px 4px rgba(0,0,20,.5);
                transition: all 0.05s linear;
            }
            :host .options > *:focus, ::slotted(*:focus) {
                outline: rgba(255,255,255,.5) 1px solid;
            }
            :host .options > *:focus-visible, ::slotted(*:focus-visible) {
                outline: rgba(0,0,0,.5) 1px solid;
            }

            :host .options > *:disabled, ::slotted(*:disabled) {
                background: rgba(0,0,0,.05);
                color: rgba(60,60,60,.7);
                pointer-events: none;
            }
        }

        button, ::slotted(button) {
            gap: 0.3rem;
        }

        /** XR button animation **/
        
        :host button.this-mode-is-requested {
            background: repeating-linear-gradient(to right, #fff 0%, #fff 40%, #aaffff 55%, #fff 80%);
            background-size: 200% auto;
            background-position: 0 100%;
            animation: AnimationName .7s ease infinite forwards;
        }
        :host button.other-mode-is-requested {
            opacity: .5;
        }
        
        @keyframes AnimationName {
            0% { 
                background-position: 0% 0 
            }
            100% { 
                background-position: -200% 0
            }
        }




        .logo {
            cursor: pointer;
            padding-left: 0.5em;
            padding-bottom: .02em;
            margin-right: 0.6em;
        }
        .logo-hidden {
            .logo {
                display: none;
            }
        }
        :host .has-options .logo {
            border-left: 1px solid rgba(40,40,40,.4);
            margin-left: 0.3em;
            margin-right: 0.6em;
        }

        .logo > span {
            white-space: nowrap;
        }



        /** COMPACT */

        /** Hide the menu button normally **/
        .compact-menu-button { display: none; }

        /** Hide the compact only options when not in compact mode */
        .options.compact-only { display: none; }

        /** And show it when we're in compact mode **/
        .compact .compact-menu-button {
            position: relative;
            display: block;
            background: none;
            border: none;
            border-radius: 2rem;

            margin: 0;
            padding: 0 .3rem;
            padding-top: .2rem;

            z-index: 100;

            color: #000;

            &:hover {
                background: rgba(255,255,255,.2);
                cursor: pointer;
            }
            &:focus {
                outline: 1px solid rgba(255,255,255,.5);
            }
            &:focus-visible {
                outline: 1px solid rgba(0,0,0,.5);
            }
            & .expanded-click-area {
                position: absolute;
                left: 0;
                right: 0;
                top: 10%;
                bottom: 10%;
                transform: scale(1.8);
            }
        }  
        .has-no-options .compact-menu-button {
            display: none;
        }
        .open .compact-menu-button {
            background: rgba(255,255,255,.2);
        }
        .logo-visible .compact-menu-button { 
            margin-left: .2rem;
        }
        
        /** Open and hide menu **/
        .compact .foldout { 
            display: none;
        }
        .open .options, .open .foldout {
            display: flex;
            justify-content: center;
        }
        .compact .wrapper {
            padding: 0;
        }
        .compact .wrapper, .compact .options {
            height: auto;
            max-height: initial;
            flex-direction: row;
            gap: .12rem;
        }
        .compact .options { 
            flex-wrap: wrap;
            gap: .3rem;
        }
        .compact .top .options {
            height: auto;
            flex-direction: row;
        }
        .compact .bottom .wrapper {
            height: auto;
            flex-direction: column;
        }

        .compact .foldout {
            max-height: min(100ch, calc(100vh - 100px));
            overflow: auto;
            overflow-x: hidden;
            align-items: center;

            position: fixed;
            bottom: calc(100% + 5px);
            z-index: 100;
            width: auto;
            max-width: 90vw;
            left: 50%;
            transform: translateX(-50%);
            padding: .2rem 1em;

        }
        .compact.logo-hidden .foldout {
            /** for when there's no logo we want to center the foldout **/
            min-width: 24ch;
        }
        
        .compact.top .foldout {
            top: calc(100% + 5px);
            bottom: auto;
        }

        ::-webkit-scrollbar {
            max-width: 7px;
            background: rgba(100,100,100,.2);
            border-radius: .2rem;
        }
        ::-webkit-scrollbar-thumb {
            background: rgba(255, 255, 255, .3);
            border-radius: .2rem;
        }
        ::-webkit-scrollbar-thumb:hover {
            background: rgb(150,150,150);
        }

        .compact .options > *, .compact .options > ::slotted(*) {
            font-size: 1.2em;
            padding: .6em .5em;
            width: 100%;
        }
        .compact.has-options .logo {
            border: none;
            padding-left: 0;
            margin-bottom: .02em;
        }
        .compact .options.compact-only {
            display: initial;
            & > * {
                min-height: 1em;
                padding: .4em .4em;
            }
        }
        .compact .options {
            /** e.g. if we have a very wide menu item like a select with long option names we don't want to overflow **/
            max-width: 100%;

            & > button, & > select {
                display: flex;
                flex-basis: 100%;
                min-height: 3rem;
                min-width: 3rem;
            }
            & > button.row2 {
                //border: 1px solid red !important;
                display: flex;
                flex: 1;
                flex-basis: 30%;
            }
        }

        /** If there's really not enough space then just hide all options **/
        @media (max-width: 100px) or (max-height: 100px){
            .foldout {
                display: none !important;
            }
            .compact-menu-button {
                display: none !important;
            }
        }

        </style>
        
        <div id="root" class="logo-hidden floating-panel-style bottom">
            <div class="wrapper">
                <div class="options compact-only" part="options">
                </div>
                <div class="foldout">
                    <div class="options main-container" part="options">
                        <slot></slot>
                    </div>
                    <div class="options" part="options">
                        <slot name="end"></slot>
                    </div>
                </div>
                <div style="user-select:none;" class="logo">
                    <span class="madewith notranslate" style="display:none;">powered by</span>
                </div>
            </div>
            <button class="compact-menu-button">
                <div class="expanded-click-area"></div>
            </button>
        </div>
        `;

        // we dont need to expose the shadow root
        const shadow = this.attachShadow({ mode: 'open' });

        // we need to add the icons to both the shadow dom as well as the HEAD to work 
        // https://github.com/google/material-design-icons/issues/1165
        ensureFonts();
        loadFont(iconFontUrl, { loadedCallback: () => { this.handleSizeChange() } });
        loadFont(iconFontUrl, { element: shadow });

        const content = template.content.cloneNode(true) as DocumentFragment;
        shadow?.appendChild(content);
        this.root = shadow.querySelector("#root") as HTMLDivElement;

        this.wrapper = this.root?.querySelector(".wrapper") as HTMLDivElement;
        this.options = this.root?.querySelector(".options.main-container") as HTMLDivElement;
        this.optionsCompactMode = this.root?.querySelector(".options.compact-only") as HTMLDivElement;
        this.logoContainer = this.root?.querySelector(".logo") as HTMLDivElement;
        this.compactMenuButton = this.root?.querySelector(".compact-menu-button") as HTMLButtonElement;
        this.compactMenuButton.append(getIconElement("more_vert"));
        this.foldout = this.root?.querySelector(".foldout") as HTMLDivElement;

        this.root?.appendChild(this.wrapper);
        this.wrapper.classList.add("wrapper");

        const logo = NeedleLogoElement.create();
        logo.setType("compact");
        logo.style.minHeight = "1rem";
        this.logoContainer.append(logo);
        this.logoContainer.addEventListener("click", () => {
            globalThis.open("https://needle.tools", "_blank");
        });

        try {
            // if the user has a license then we CAN hide the needle logo
            // calling this method immediately will cause an issue with vite bundling tho
            window.requestAnimationFrame(() => onLicenseCheckResultChanged(res => {
                if (res == true && hasCommercialLicense() && !debugNonCommercial) {
                    let visible = this._userRequestedLogoVisible;
                    if (visible === undefined) visible = false;
                    this.___onSetLogoVisible(visible);
                }
                else {
                    this.___onSetLogoVisible(true);
                }
            }));
        } catch (e) {
            console.error("[Needle Menu] License check failed.", e);
        }

        this.compactMenuButton.addEventListener("click", evt => {
            evt.preventDefault();
            this.root.classList.toggle("open");
        });


        let context = this._context;
        // we need to assign it in the timeout because the reference is set *after* the element is initialized
        setTimeout(() => context = this._context);

        // watch changes
        let changeEventCounter = 0;
        const forceVisible = (parent, visible) => {
            if (debug) console.log("Set menu visible", visible);
            if (context?.isInAR && context.arOverlayElement) {
                if (parent != context.arOverlayElement) {
                    context.arOverlayElement.appendChild(this);
                }
            }
            else if (this.parentNode != this._domElement?.shadowRoot)
                this._domElement?.shadowRoot?.appendChild(this);
            this.style.display = visible ? "flex" : "none";
            this.style.visibility = "visible";
            this.style.opacity = "1";
        }

        let isHandlingMutation = false;
        const rootObserver = new MutationObserver(mutations => {
            if (isHandlingMutation) {
                return;
            }
            try {
                isHandlingMutation = true;
                this.onChangeDetected(mutations);

                // ensure the menu is not hidden or removed
                const requiredParent = this?.parentNode;
                if (this.style.display != "flex" || this.style.visibility != "visible" || this.style.opacity != "1" || requiredParent != this._domElement?.shadowRoot) {
                    if (!hasCommercialLicense()) {
                        const change = changeEventCounter++;
                        // if a user doesn't have a local pro license *but* for development the menu is hidden then we show a warning
                        if (isLocalNetwork() && this._userRequestedMenuVisible === false) {
                            // set visible once so that the check above is not triggered again
                            if (change === 0) {
                                // if the user requested visible to false before this code is called for the first time we want to respect the choice just in this case
                                forceVisible(requiredParent, this._userRequestedMenuVisible);
                            }
                            // warn only once
                            if (change === 1) {
                                console.warn(`Needle Menu Warning: You need a PRO license to hide the Needle Engine menu → The menu will be visible in your deployed website if you don't have a PRO license. See https://needle.tools/pricing for details.`);
                            }
                        }
                        else {
                            if (change === 0) {
                                forceVisible(requiredParent, true);
                            }
                            else {
                                setTimeout(() => forceVisible(requiredParent, true), 5)
                            }
                        }
                    }
                }
            }
            finally {
                isHandlingMutation = false;
            }
        });
        rootObserver.observe(this.root, { childList: true, subtree: true, attributes: true });



        if (debug) {
            this.___insertDebugOptions();
        }
    }

    ensureInitialized() {
        if (!this._didInitialize) {
            this._didInitialize = true;
            this.initializeDom();
        }
    }

    private _sizeChangeInterval;

    connectedCallback() {
        this.ensureInitialized();
        window.addEventListener("resize", this.handleSizeChange);
        this.handleMenuVisible();
        this._sizeChangeInterval = setInterval(() => this.handleSizeChange(undefined, false), 5000);
        // the dom element is set after initialization runs
        setTimeout(() => {
            this._domElement?.addEventListener("resize", this.handleSizeChange);
            this._domElement?.addEventListener("click", this.#onClick);
        }, 1)
    }
    disconnectedCallback() {
        window.removeEventListener("resize", this.handleSizeChange);
        clearInterval(this._sizeChangeInterval);
        this._domElement?.removeEventListener("resize", this.handleSizeChange);
        this._context?.domElement.removeEventListener("click", this.#onClick);
    }

    #onClick = (e: Event) => {
        // detect a click outside the opened foldout to automatically close it
        if (!e.defaultPrevented
            && e.target == this._domElement
            && (e instanceof PointerEvent && e.button === 0)
            && this.root.classList.contains("open")) {
            // The menu is open, it's a click outside the foldout?
            const rect = this.foldout.getBoundingClientRect();
            const pointerEvent = e as PointerEvent;
            if (!(pointerEvent.clientX > rect.left
                && pointerEvent.clientX < rect.right
                && pointerEvent.clientY > rect.top
                && pointerEvent.clientY < rect.bottom)) {
                this.root.classList.toggle("open", false);
            }
        }
    }

    /** @private user preference for logo visibility */
    private _userRequestedLogoVisible?: boolean = undefined;
    showNeedleLogo(visible: boolean) {
        this._userRequestedLogoVisible = visible;
        if (!visible) {
            if (!hasCommercialLicense() || debugNonCommercial) {
                console.warn("[Needle Engine] You need a PRO license to hide the Needle Engine logo in production.");
                const localNetwork = isLocalNetwork()
                if (!localNetwork) return;
            }
        }
        this.___onSetLogoVisible(visible);
    }
    /** @returns true if the logo is visible */
    get logoIsVisible() {
        return !this.root.classList.contains("logo-hidden");
    }

    private ___onSetLogoVisible(visible: boolean) {
        this.logoContainer.style.display = "";
        this.logoContainer.style.opacity = "1";
        this.logoContainer.style.visibility = "visible";
        if (visible) {
            this.root.classList.remove("logo-hidden");
            this.root.classList.add("logo-visible");
        }
        else {
            this.root.classList.remove("logo-visible");
            this.root.classList.add("logo-hidden");
        }
    }

    setPosition(position: "top" | "bottom") {
        // ensure the position is of a known type:
        if (position !== "top" && position !== "bottom") {
            return console.error("NeedleMenu.setPosition: invalid position", position);
        }
        this.root.classList.remove("top", "bottom");
        this.root.classList.add(position);
    }

    /** @private user preference for menu visibility */
    private _userRequestedMenuVisible?: boolean = undefined;
    setVisible(visible: boolean) {
        this._userRequestedMenuVisible = visible;
        this.style.display = visible ? "flex" : "none";
    }

    /**
     * If the menu is in compact mode and the foldout is currently open (to show all menu options) then this will close the foldout
     */
    closeFoldout() {
        this.root.classList.remove("open");
    }

    // private _root: ShadowRoot | null = null;
    /** @private root container element inside shadow DOM */
    private root!: HTMLDivElement;
    /** @private wraps the whole content (internal layout) */
    private wrapper!: HTMLDivElement;
    /** @private contains the buttons and dynamic elements */
    private options!: HTMLDivElement;
    /** @private contains options visible when in compact mode */
    private optionsCompactMode!: HTMLDivElement;
    /** @private contains the needle-logo html element */
    private logoContainer!: HTMLDivElement;
    /** @private compact menu button element */
    private compactMenuButton!: HTMLButtonElement;
    /** @private foldout container used in compact mode */
    private foldout!: HTMLDivElement;


    private readonly trackedElements: WeakSet<Node> = new WeakSet();
    private trackElement(el: Node) {
        if (this.trackedElements.has(el)) return;
        this.trackedElements.add(el);
        el.addEventListener("click", (evt) => {
            Telemetry.sendEvent(this._context, "needle-menu", {
                action: "button_clicked",
                element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName,
                label: el.textContent,
                title: (el instanceof HTMLElement) ? el.title : undefined,
                pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined,
            });
        });
        // el.addEventListener("pointerenter", (evt) => {
        //     Telemetry.sendEvent(this._context, "needle-menu", {
        //         action: "button_hovered",
        //         element: evt.target instanceof Node ? evt.target.nodeName : el.nodeName,
        //         label: el.textContent,
        //         title: (el instanceof HTMLElement) ? el.title : undefined,
        //         pointerid: (evt instanceof PointerEvent) ? evt.pointerId : undefined,
        //     });
        // });
    }

    append(...nodes: (string | Node)[]): void {
        for (const node of nodes) {
            if (typeof node === "string") {
                const element = document.createTextNode(node);
                this.trackElement(element);
                this.options.appendChild(element);
            } else {
                this.trackElement(node);
                this.options.appendChild(node);
            }
        }
    }
    /**
     * Appends a button or HTML element to the needle-menu options.
     * @param node a Node or ButtonInfo to create a button from
     * @returns the appended Node
     * 
     * @example Append a button
     * ```javascript
     * const button = document.createElement("button");
     * button.textContent = "Click Me";
     * needleMenu.appendChild(button);
     * ```
     * @example Append a button using ButtonInfo
     * ```javascript
     * needleMenu.appendChild({
     *    label: "Click Me",
     *    onClick: () => { alert("Button clicked!"); },
     *    icon: "info",
     *    title: "This is a button",
     * });
     * ```
     */
    appendChild<T extends Node>(node: T | ButtonInfo): T {

        if (!(node instanceof Node)) {
            const button = document.createElement("button");
            button.textContent = node.label;
            button.onclick = node.onClick;
            button.setAttribute("priority", node.priority?.toString() ?? "0");
            if (node.title) {
                button.title = node.title;
            }
            if (node.icon) {
                const icon = getIconElement(node.icon);
                if (node.iconSide === "right") {
                    button.appendChild(icon);
                } else {
                    button.prepend(icon);
                }
            }
            if (node.class) {
                button.classList.add(node.class);
            }

            node = button as unknown as T;
        }
        this.trackElement(node);
        const res = this.options.appendChild(node);
        return res;
    }
    prepend(...nodes: (string | Node)[]): void {
        for (const node of nodes) {
            if (typeof node === "string") {
                const element = document.createTextNode(node);
                this.trackElement(element);
                this.options.prepend(element);
            } else {
                this.trackElement(node);
                this.options.prepend(node);
            }
        }
    }

    private _isHandlingChange = false;
    /** During modification of options container (e.g. when moving items into the extra buttons container) the mutation observer should not trigger an update event immediately. This is a workaround for the total size required for all elements not being calculated reliably. */
    private _pauseMutationObserverOptionsContainer = false;

    /** Called when any change in the web component is detected (including in children and child attributes) */
    private onChangeDetected(_mut: MutationRecord[]) {
        if (this._isHandlingChange) return;
        this._isHandlingChange = true;
        try {
            // if (debug) console.log("NeedleMenu.onChangeDetected", _mut);
            this.handleMenuVisible();
            for (const mut of _mut) {
                if (mut.target == this.options) {
                    if (!this._pauseMutationObserverOptionsContainer)
                        this.onOptionsChildrenChanged(mut)
                }
            }
        }
        finally {
            this._isHandlingChange = false;
        }
    }

    private onOptionsChildrenChanged(_mut: MutationRecord) {
        this.root.classList.toggle("has-options", this.hasAnyVisibleOptions);
        this.root.classList.toggle("has-no-options", !this.hasAnyVisibleOptions);
        this.handleSizeChange(undefined, true);

        if (_mut.type === "childList") {
            if (_mut.addedNodes.length > 0) {
                const children = Array.from(this.options.children);
                children.sort((a, b) => {
                    const p1 = parseInt(a.getAttribute("priority") || "0");
                    const p2 = parseInt(b.getAttribute("priority") || "0");
                    return p1 - p2;
                });
                let sortingChanged = false;
                for (let i = 0; i < children.length; i++) {
                    const existing = this.options.children[i];
                    const child = children[i];
                    if (existing !== child) {
                        sortingChanged = true;
                        break;
                    }
                }
                if (sortingChanged) {
                    for (const child of children) {
                        this.options.appendChild(child);
                    }
                }
            }
        }
    }

    private _didSort: Map<HTMLElement, number> = new Map();


    /** checks if the menu has any content and should be rendered at all
     * if we dont have any content and logo then we hide the menu
     */
    private handleMenuVisible() {
        if (debug) console.log("Update VisibleState: Any Content?", this.hasAnyContent);
        if (this.hasAnyContent) {
            this.root.style.display = "";
        } else {
            this.root.style.display = "none";
        }
        this.root.classList.toggle("has-options", this.hasAnyVisibleOptions);
        this.root.classList.toggle("has-no-options", !this.hasAnyVisibleOptions);
    }

    /** @returns true if we have any content OR a logo */
    get hasAnyContent() {
        // is the logo visible?
        if (this.logoContainer.style.display != "none") return true;
        if (this.hasAnyVisibleOptions) return true;
        return false;
    }
    get hasAnyVisibleOptions() {
        // do we have any visible buttons?
        for (let i = 0; i < this.options.children.length; i++) {
            const child = this.options.children[i] as HTMLElement
            // is slot?
            if (child.tagName === "SLOT") {
                const slotElement = child as HTMLSlotElement;
                const nodes = slotElement.assignedNodes();
                for (const node of nodes) {
                    if (node instanceof HTMLElement) {
                        if (node.style.display != "none") return true;
                    }
                }
            }
            else if (child.style.display != "none") return true;
        }
        return false;
    }


    private _lastAvailableWidthChange = 0;
    private _timeoutHandleSize: number = 0;
    private _timeoutHandleCompactItems: number = 0;

    private handleSizeChange = (_evt?: Event, forceOrEvent?: boolean) => {
        if (!this._domElement) return;
        // if (this._isApplyingSizeUpdate) return;

        const width = this._domElement.clientWidth;
        if (width < 100) {
            clearTimeout(this._timeoutHandleSize!);
            this.root.classList.add("compact");
            this.foldout.classList.add("floating-panel-style");
            return;
        }

        const padding = 10 * 2;
        const availableWidth = width - padding;

        // if the available width has not changed significantly then we can skip the rest
        if (!forceOrEvent && Math.abs(availableWidth - this._lastAvailableWidthChange) < 1) return;
        this._lastAvailableWidthChange = availableWidth;

        clearTimeout(this._timeoutHandleSize!);

        this._timeoutHandleSize = setTimeout(() => {

            // console.warn("APPLY", this.root.classList.contains("compact") ? "COMPACT" : "FULL", "MODE (available width: " + availableWidth.toFixed(0) + "px)", getCurrentWidth());

            const spaceLeft = getSpaceLeft();
            if (spaceLeft < 0) {
                this.root.classList.add("compact")
                this.foldout.classList.add("floating-panel-style");
            }
            else if (spaceLeft > 0) {
                this.root.classList.remove("compact")
                this.foldout.classList.remove("floating-panel-style");
                if (getSpaceLeft() < 0) {
                    // ensure we still have enough space left
                    this.root.classList.add("compact")
                    this.foldout.classList.add("floating-panel-style");
                }
            }
            this._pauseMutationObserverOptionsContainer = true;
            this.updateCompactFoldoutItem();
            window.requestAnimationFrame(() => this._pauseMutationObserverOptionsContainer = false);

        }, 150) as unknown as number;

        const getCurrentWidth = () => {
            let totalWidthRequired = 0;
            totalWidthRequired += this.options.getBoundingClientRect().width;
            totalWidthRequired += this.optionsCompactMode.getBoundingClientRect().width;
            totalWidthRequired += 10 * this.options.childElementCount; // padding
            totalWidthRequired += this.logoContainer.style.display != "none" ? this.logoContainer.getBoundingClientRect().width : 0;;
            return totalWidthRequired;
        }

        let lastSpaceLeft = -1;
        const getSpaceLeft = () => {
            const spaceLeft = availableWidth - getCurrentWidth();
            if (debug && spaceLeft !== lastSpaceLeft) {
                lastSpaceLeft = spaceLeft;
                showBalloonMessage(`Menu space left: ${spaceLeft.toFixed(0)}px`);
            }
            return spaceLeft;
        }
    }

    private updateCompactFoldoutItem() {

        if (this.root.classList.contains("compact")) {

            // Find items in the folding list with the highest priority
            // The one with the highest priority will be added to the visible container
            let priorityItem: HTMLElement | null = null;
            let priorityValue: number = -10000000;
            const testItem = (element: ChildNode | null) => {
                if (element instanceof HTMLElement) {
                    const priority = NeedleMenu.getElementPriority(element);
                    if (priority !== undefined && priority >= priorityValue) {
                        // check if the element is hidden
                        // @TODO: use computed styles
                        const style = window.getComputedStyle(element);
                        if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
                            return;
                        }
                        priorityItem = element;
                        priorityValue = priority;
                    }
                }
            }
            for (let i = 0; i < this.options.children.length; i++) {
                testItem(this.options.children.item(i));
            }
            for (let i = 0; i < this.optionsCompactMode.children.length; i++) {
                testItem(this.optionsCompactMode.children.item(i));
            }

            if (priorityItem && !this.optionsCompactMode.contains(priorityItem)) {
                this.optionsCompactMode.childNodes.forEach(element => {
                    this.options.appendChild(element);
                });
                const item = priorityItem;
                this.optionsCompactMode.appendChild(item);
                // console.warn("In compact mode, moved item with priority " + priorityValue + " to compact foldout:", item);
            }
            else if (!priorityItem) {
                // console.warn("In compact mode but no item has priority, showing all items in foldout");
                this.optionsCompactMode.childNodes.forEach(element => {
                    this.options.appendChild(element);
                });
            }
        }
        else {
            // console.warn("Not in compact mode but trying to update compact foldout item");
            this.optionsCompactMode.childNodes.forEach(element => {
                this.options.appendChild(element);
            });
        }
    }
    // private _foldoutItemVisibleInterval = 0;


    private ___insertDebugOptions() {
        window.addEventListener("keydown", (e) => {
            if (e.key === "p") {
                this.setPosition(this.root.classList.contains("top") ? "bottom" : "top");
            }
        });
        const removeOptionsButton = document.createElement("button");
        removeOptionsButton.textContent = "Hide Buttons";
        removeOptionsButton.onclick = () => {
            const optionsChildren = new Array(this.options.children.length);
            for (let i = 0; i < this.options.children.length; i++) {
                optionsChildren[i] = this.options.children[i];
            }
            for (const child of optionsChildren) {
                this.options.removeChild(child);
            }
            setTimeout(() => {
                for (const child of optionsChildren) {
                    this.options.appendChild(child);
                }

            }, 1000)
        };
        this.appendChild(removeOptionsButton);
        const anotherButton = document.createElement("button");
        anotherButton.textContent = "Toggle Logo";
        anotherButton.addEventListener("click", () => {
            this.logoContainer.style.display = this.logoContainer.style.display === "none" ? "" : "none";
        });
        this.appendChild(anotherButton);
    }
}