import { type Intersection, Mesh, Object3D } from "three";

import { isDevEnvironment, showBalloonMessage } from "../../engine/debug/index.js";
import { type InputEventNames, InputEvents, NEPointerEvent, NEPointerEventIntersection, PointerType } from "../../engine/engine_input.js";
import { onInitialized } from "../../engine/engine_lifecycle_api.js";
import { Mathf } from "../../engine/engine_math.js";
import { RaycastOptions, type RaycastTestObjectReturnType } from "../../engine/engine_physics.js";
import { Context } from "../../engine/engine_setup.js";
import { HideFlags, type IComponent } from "../../engine/engine_types.js";
import { getParam } from "../../engine/engine_utils.js";
import { Behaviour, GameObject } from "../Component.js";
import type { ICanvasGroup } from "./Interfaces.js";
import { hasPointerEventComponent, type IPointerEventHandler, type IPointerUpHandler, PointerEventData } from "./PointerEvents.js";
import { ObjectRaycaster, Raycaster } from "./Raycaster.js";
import { UIRaycastUtils } from "./RaycastUtils.js";
import { $shadowDomOwner } from "./Symbols.js";
import { isUIObject } from "./Utils.js";

const debug = getParam("debugeventsystem");

export enum EventSystemEvents {
    BeforeHandleInput = "BeforeHandleInput",
    AfterHandleInput = "AfterHandleInput",
}

export declare type AfterHandleInputEvent = {
    sender: EventSystem,
    args: PointerEventData,
    hasActiveUI: boolean
}

declare type IComponentCanMaybeReceiveEvents = IPointerEventHandler & IComponent & { interactable?: boolean };

onInitialized((ctx) => {
    EventSystem.createIfNoneExists(ctx);
})

/**
 * [EventSystem](https://engine.needle.tools/docs/api/EventSystem) is responsible for managing and dispatching input events to UI components within the scene.
 * @summary Manages and dispatches input events to UI components
 * @category User Interface
 * @group Components
 */
export class EventSystem extends Behaviour {
    //@ts-ignore
    static ensureUpdateMeshUI(instance, context: Context, force: boolean = false) {
        MeshUIHelper.update(instance, context, force);
    }
    static markUIDirty(_context: Context) {
        MeshUIHelper.markDirty();
    }

    static createIfNoneExists(context: Context) {
        if (!context.scene.getComponent(EventSystem)) {
            context.scene.addComponent(EventSystem);
        }
    }

    static get(ctx: Context): EventSystem | null {
        this.createIfNoneExists(ctx);
        return ctx.scene.getComponent(EventSystem);
    }

    /** Get the currently active event system */
    static get instance(): EventSystem | null {
        return this.get(Context.Current);
    }

    private readonly raycaster: Raycaster[] = [];
    register(rc: Raycaster) {
        if (rc && this.raycaster && !this.raycaster.includes(rc))
            this.raycaster?.push(rc);
    }
    unregister(rc: Raycaster) {
        const i = this.raycaster?.indexOf(rc);
        if (i !== undefined && i !== -1) {
            this.raycaster?.splice(i, 1);
        }
    }

    get hasActiveUI() {
        return this.currentActiveMeshUIComponents.length > 0;
    }

    get isHoveringObjects() { return this.hoveredByID.size > 0; }

    awake(): void {
        // We only want ONE eventsystem on the root scene
        // as long as this component is not implemented in core we need to check this here
        if (this.gameObject as Object3D !== this.context.scene) {
            console.debug(`[Needle Engine] EventSystem is only allowed on the scene root. Disabling EventSystem on '${this.gameObject.name}'`);
            this.enabled = false;
        }
    }

    start() {
        if (!this.context.scene.getComponent(Raycaster)) {
            this.context.scene.addComponent(ObjectRaycaster);
        }
    }

    onEnable(): void {
        this.context.input.addEventListener(InputEvents.PointerDown, this.onPointerEvent);
        this.context.input.addEventListener(InputEvents.PointerUp, this.onPointerEvent);
        this.context.input.addEventListener(InputEvents.PointerMove, this.onPointerEvent);
    }

    onDisable(): void {
        this.context.input.removeEventListener(InputEvents.PointerDown, this.onPointerEvent);
        this.context.input.removeEventListener(InputEvents.PointerUp, this.onPointerEvent);
        this.context.input.removeEventListener(InputEvents.PointerMove, this.onPointerEvent);
    }

    /**
     * all pointers that have pressed something
     * 
     * key: pointerId
     * value: object that was pressed, data of the pointer event, handlers that are releavant to the event
    */
    private pressedByID: Map<number, { obj: Object3D | null, data: PointerEventData, handlers: Set<IPointerEventHandler> }> = new Map();
    /**
     * all hovered objects
     * 
     * key: pointerId
     * value: object that is hovered, data of the pointer event
     */
    private hoveredByID: Map<number, { obj: Object3D, data: PointerEventData }> = new Map();

    onBeforeRender() {
        this.resetMeshUIStates();
    }

    /**
     * Handle an pointer event from the input system
     */
    private onPointerEvent = (pointerEvent: NEPointerEvent) => {
        if (pointerEvent === undefined) return;
        if (pointerEvent.propagationStopped) return;
        if (pointerEvent.defaultPrevented) return;
        if (pointerEvent.used) return;

        // Use the pointerID, this is the touch id or the mouse (always 0, could be index of the mouse DEVICE) or the index of the XRInputSource
        const data = new PointerEventData(this.context.input, pointerEvent);
        this._currentPointerEventName = pointerEvent.type;

        data.inputSource = this.context.input;
        data.isClick = pointerEvent.isClick;
        data.isDoubleClick = pointerEvent.isDoubleClick;
        // using the input type directly instead of input API -> otherwise onMove events can sometimes be getPointerUp() == true
        data.isDown = pointerEvent.type == InputEvents.PointerDown;
        data.isUp = pointerEvent.type == InputEvents.PointerUp;
        data.isPressed = this.context.input.getPointerPressed(pointerEvent.pointerId);

        // raycast
        const options = new RaycastOptions();
        if (pointerEvent.hasRay) {
            options.ray = pointerEvent.ray;
        }
        else {
            options.screenPoint = this.context.input.getPointerPositionRC(pointerEvent.pointerId)!;
        }
        options.allowSlowRaycastFallback = pointerEvent.isClick || pointerEvent.isDoubleClick;

        const hits = this.performRaycast(options) as Array<NEPointerEventIntersection>;
        if (debug) {
            if (data.isDown) console.log("DOWN", { id: data.pointerId, hits: hits.length });
            else if (data.isUp) console.log("UP", { id: data.pointerId, hits: hits.length });
            if (data.isClick) console.log("CLICK", { id: data.pointerId, hits: hits.length });
        }

        if (hits) {
            for (const hit of hits) {
                hit.event = pointerEvent;
                pointerEvent.intersections.push(hit);
            }
            if (pointerEvent.origin.onPointerHits) {
                pointerEvent.origin.onPointerHits({
                    sender: this,
                    event: pointerEvent,
                    hits
                });
            }
        }


        if (debug && data.isClick) {
            showBalloonMessage("EventSystem: " + data.pointerId + " - " + this.context.time.frame + " - Up:" + data.isUp + ", Down:" + data.isDown)
        }

        const evt: AfterHandleInputEvent = {
            sender: this,
            args: data,
            hasActiveUI: this.currentActiveMeshUIComponents.length > 0,
        }

        this.dispatchEvent(new CustomEvent(EventSystemEvents.BeforeHandleInput, { detail: evt }));

        // then handle the intersections and call the callbacks on the regular objects
        this.handleIntersections(hits, data);

        this.dispatchEvent(new CustomEvent<AfterHandleInputEvent>(EventSystemEvents.AfterHandleInput, { detail: evt }));
    }

    private readonly _sortedHits: Intersection[] = [];

    /** 
     * cache for objects that we want to raycast against. It's cleared before each call to performRaycast invoking raycasters
     */
    private readonly _testObjectsCache = new Map<Object3D, boolean>();
    /** that's the raycaster that is CURRENTLY being used for raycasting (the shouldRaycastObject method uses this) */
    private _currentlyActiveRaycaster: Raycaster | null = null;
    private _currentPointerEventName: InputEventNames | null = null;

    /** 
     * Checks if an object that we encounter has an event component and if it does, we add it to our objects cache
     * If it doesnt we tell our raycasting system to ignore it and continue in the child hierarchy
     * We do this to avoid raycasts against objects that are not going to be used by the event system
     * Because there's no component callback to be invoked anyways. 
     * This is especially important to avoid expensive raycasts against SkinnedMeshes
     * 
     * Further optimizations would be to check what type of event we're dealing with
     * For example if an event component has only an onPointerClick method we don't need to raycast during movement events
     * */
    private shouldRaycastObject = (obj: Object3D): RaycastTestObjectReturnType => {
        // TODO: this implementation below should be removed and we should regularly raycast objects in the scene unless marked as "do not raycast"
        // with the introduction of the mesh-bvh based raycasting the performance impact should be greatly reduced. But this needs further testing 

        const raycasterOnObject = obj && "getComponent" in obj ? obj.getComponent(Raycaster) : null;
        if (raycasterOnObject && raycasterOnObject != this._currentlyActiveRaycaster) {
            return false;
        }
        // if (this._currentPointerEventName == "pointermove") {
        //     console.log(this.context.time.frame, obj.name, obj.type, obj.guid)
        // }

        // check if this object is actually a UI shadow hierarchy object
        let uiOwner: Object3D | null = null;
        const isUI = isUIObject(obj);
        // if yes we want to grab the actual object that is the owner of the shadow dom
        // and check that object for the event component
        if (isUI) {
            uiOwner = obj[$shadowDomOwner]?.gameObject;
        }

        // check if the object was seen previously
        if (this._testObjectsCache.has(obj) || (uiOwner && this._testObjectsCache.has(uiOwner))) {
            // if yes we check if it was previously stored as "YES WE NEED TO RAYCAST THIS"
            const prev = this._testObjectsCache.get(obj)!;
            if (prev === false) return "continue in children"
            return true;
        }
        else {

            // if the object has another raycaster component than the one that is currently raycasting, we ignore this here
            // because then this other raycaster is responsible for raycasting this object
            // const rc = GameObject.getComponent(obj, Raycaster);
            // if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return false;

            // the object was not yet seen so we test if it has an event component
            let hasEventComponent = hasPointerEventComponent(obj, this._currentPointerEventName);
            if (!hasEventComponent && uiOwner) hasEventComponent = hasPointerEventComponent(uiOwner, this._currentPointerEventName);

            if (hasEventComponent) {
                // it has an event component: we add it and all its children to the cache
                // we don't need to do the same for the shadow component hierarchy 
                // because the next object that will be detecting that the shadow owner was already seen
                this._testObjectsCache.set(obj, true);
                for (const ch of obj.children) this.shouldRaycastObject_AddToYesCache(ch);
                return true;
            }
            this._testObjectsCache.set(obj, false);
            return "continue in children"
        }
    }
    private shouldRaycastObject_AddToYesCache(obj: Object3D) {
        // if the object has another raycaster component than the one that is currently raycasting, we ignore this here
        // because then this other raycaster is responsible for raycasting this object
        // const rc = GameObject.getComponent(obj, Raycaster);
        // if (rc?.activeAndEnabled && rc !== this._currentlyActiveRaycaster) return;

        this._testObjectsCache.set(obj, true);
        for (const ch of obj.children) {
            this.shouldRaycastObject_AddToYesCache(ch);
        }
    }

    /** the raycast filter is always overriden */
    private performRaycast(opts: RaycastOptions): Intersection[] | null {
        if (!this.raycaster) return null;
        // we clear the cache of previously seen objects
        this._testObjectsCache.clear();
        this._sortedHits.length = 0;
        opts.testObject = this.shouldRaycastObject;

        for (const rc of this.raycaster) {
            if (!rc.activeAndEnabled) continue;

            this._currentlyActiveRaycaster = rc;
            const res = rc.performRaycast(opts);
            this._currentlyActiveRaycaster = null;

            if (res && res.length > 0) {
                // console.log(res.length, res.map(r => r.object.name));
                this._sortedHits.push(...res);
            }
        }
        this._sortedHits.sort((a, b) => {
            return a.distance - b.distance;
        });
        return this._sortedHits;
    }

    private assignHitInformation(args: PointerEventData, hit?: Intersection) {
        if (!hit) {
            args.intersection = undefined;
            args.point = undefined;
            args.normal = undefined;
            args.face = undefined;
            args.distance = undefined;
            args.instanceId = undefined;
        }
        else {
            args.intersection = hit;
            args.point = hit.point;
            args.normal = hit.normal;
            args.face = hit.face;
            args.distance = hit.distance;
            args.instanceId = hit.instanceId;
        }
    }

    private handleIntersections(hits: Intersection[] | null | undefined, args: PointerEventData): boolean {

        if (hits?.length) {
            hits = this.sortCandidates(hits);
            for (const hit of hits) {
                if (args.event.immediatePropagationStopped) {
                    return false;
                }
                this.assignHitInformation(args, hit);
                if (this.handleEventOnObject(hit.object, args)) {
                    return true;
                }
            }
        }

        // first invoke captured pointers
        this.assignHitInformation(args, hits?.[0]);
        this.invokePointerCapture(args);

        // pointer has not hit any object to handle

        // thus is not hovering over anything
        const hoveredData = this.hoveredByID.get(args.pointerId);
        if (hoveredData) {
            this.propagatePointerExit(hoveredData.obj, hoveredData.data, null);
        }
        this.hoveredByID.delete(args.pointerId);

        // if it was up, it means it should notify things that it down on before
        if (args.isUp) {
            this.pressedByID.get(args.pointerId)?.handlers.forEach(h => this.invokeOnPointerUp(args, h));
            this.pressedByID.delete(args.pointerId);
        }

        return false;
    }

    private _sortingBuffer: Intersection[] = [];
    private _noDepthTestingResults: Intersection[] = [];

    private sortCandidates(hits: Intersection[]): Intersection[] {
        // iterate over all hits and filter for nodepth objects and normal hit objects
        // the no-depth objects will be handled first starting from the closest
        // assuming the hits array is sorted by distance (closest > furthest)
        this._sortingBuffer.length = 0;
        this._noDepthTestingResults.length = 0;
        for (let i = 0; i < hits.length; i++) {
            const hit = hits[i];
            const object = hit.object as Mesh;
            if (object.material) {
                if (object.material["depthTest"] === false) {
                    this._noDepthTestingResults.push(hit);
                    continue;
                }
            }
            this._sortingBuffer.push(hit);
        }
        for (const obj of this._sortingBuffer) {
            this._noDepthTestingResults.push(obj);
        }
        return this._noDepthTestingResults;
    }

    private out: { canvasGroup?: ICanvasGroup } = {};

    /** 
     * Handle hit result by preparing all needed information before propagation.
     * Then calling propagate.
     */
    private handleEventOnObject(object: Object3D, args: PointerEventData): boolean {
        // ensures that invisible objects are ignored
        if (!this.testIsVisible(object)) {
            if (args.isClick && debug)
                console.log("not allowed", object);
            return false;
        }

        // Event without pointer can't be handled
        if (args.pointerId === undefined) {
            if (debug) console.error("Event without pointer can't be handled", args);
            return false;
        }

        // Correct the handled object to match the relevant object in shadow dom (?)
        args.object = object;

        const parent = object.parent as any;
        let isShadow = false;
        const clicked = args.isClick ?? false;

        let canvasGroup: ICanvasGroup | null = null;

        // handle potential shadow dom built from three mesh ui
        if (parent && parent.isUI) {
            const pressedOrClicked = (args.isPressed || args.isClick) ?? false;
            if (parent[$shadowDomOwner]) {
                const actualGo = parent[$shadowDomOwner].gameObject;
                if (actualGo) {
                    const res = UIRaycastUtils.isInteractable(actualGo, this.out);
                    if (!res) return false;
                    canvasGroup = this.out.canvasGroup ?? null;
                    const handled = this.handleMeshUIIntersection(object, pressedOrClicked);
                    if (!clicked && handled) {
                        // return true;
                    }
                    object = actualGo;
                    isShadow = true;
                }
            }

            // adding this to have a way for allowing to receive events on TMUI elements without shadow hierarchy
            // if(parent["needle:use_eventsystem"] == true){
            //     // if use_eventsystem is true, we want to handle the event
            // }
            // else if (!isShadow) {
            //     const obj = this.handleMeshUiObjectWithoutShadowDom(parent, pressedOrClicked);
            //     if (obj) return true;
            // }
        }

        if (clicked && debug)
            console.log(this.context.time.frame, object);

        // Handle OnPointerExit -> in case when we are about to hover something new
        // TODO: we need to keep track of the components that already received a PointerEnterEvent -> we can have a hierarchy where the hovered object changes but the component is on the parent and should not receive a PointerExit event because it's still hovered (just another child object)
        const hovering = this.hoveredByID.get(args.pointerId);
        const prevHovering = hovering?.obj;
        const isNewlyHovering = prevHovering !== object;

        // trigger onPointerExit
        if (isNewlyHovering && prevHovering) {
            this.propagatePointerExit(prevHovering, hovering.data, object);
        }

        // save hovered object
        const entry = this.hoveredByID.get(args.pointerId);
        if (!entry)
            this.hoveredByID.set(args.pointerId, { obj: object, data: args });
        else {
            entry.obj = object;
            entry.data = args;
        }

        // create / update pressed entry
        if (args.isDown) {
            const data = this.pressedByID.get(args.pointerId);
            if (!data)
                this.pressedByID.set(args.pointerId, { obj: object, data: args, handlers: new Set<IPointerEventHandler>() });
            else {
                data.obj = object;
                data.data = args;
            }
        }
        if (canvasGroup === null || canvasGroup.interactable) {
            this.handleMainInteraction(object, args, prevHovering ?? null);
        }

        return true;
    }

    /**
     * Propagate up in hiearchy and call the callback for each component that is possibly a handler
     */
    private propagate(object: Object3D | null, onComponent: (behaviour: Behaviour) => void) {

        while (true) {

            if (!object) break;

            GameObject.foreachComponent(object, comp => {
                // TODO: implement Stop Immediate Propagation
                onComponent(comp);
            }, false);

            // walk up
            object = object.parent;
        }

    }

    /**
     * Propagate up in hierarchy and call handlers based on the pointer event data
     */
    private handleMainInteraction(object: Object3D, args: PointerEventData, prevHovering: Object3D | null) {
        const pressedEvent = this.pressedByID.get(args.pointerId);
        const hoveredObjectChanged = prevHovering !== object;

        // TODO: should we not move this check up before we even raycast for "pointerMove" events? We dont need to do any processing if the pointer didnt move
        let isMoving = true;
        switch (args.event.pointerType) {
            case "mouse":
            case "touch":
                const posLastFrame = this.context.input.getPointerPositionLastFrame(args.pointerId!)!;
                const posThisFrame = this.context.input.getPointerPosition(args.pointerId!)!;
                isMoving = posLastFrame && !Mathf.approximately(posLastFrame, posThisFrame);
                break;
            case "controller":
            case "hand":
                // for hands and controller we assume they are never totally still (except for simulated environments)
                // we might want to add a threshold here (e.g. if a user holds their hand very still or controller)
                // so maybe check the angle every frame?
                break;
        }

        this.propagate(object, (behaviour) => {
            const comp = behaviour as IComponentCanMaybeReceiveEvents;

            if (comp.interactable === false) return;
            if (!comp.activeAndEnabled || !comp.enabled) return;

            if (comp.onPointerEnter) {
                if (hoveredObjectChanged) {
                    this.handlePointerEnter(comp, args);
                }
            }

            if (args.isDown) {
                if (comp.onPointerDown) {
                    comp.onPointerDown(args);
                    // Set the handler that we called the down event on
                    // So we can call the up event on the same handler 
                    // In a scenario where we Down on one object and Up on another
                    pressedEvent?.handlers.add(comp);
                    this.handlePointerCapture(args, comp);
                }
            }

            if (comp.onPointerMove) {
                if (isMoving)
                    comp.onPointerMove(args);
                this.handlePointerCapture(args, comp);
            }

            if (args.isUp) {
                if (comp.onPointerUp) {
                    this.invokeOnPointerUp(args, comp);

                    // We don't want to call Up twice if we Down and Up on the same object
                    // But if we Down on one and Up on another we want to call Up on the first one as well
                    // For example if the object was cloned by the Duplicatable
                    // The original component that received the down event SHOULD also receive the up event
                    pressedEvent?.handlers.delete(comp);
                }

                // handle touch onExit (touchUp) since the pointer stops existing
                // mouse onExit (mouseUp) is handled when we hover over something else / on nothing
                // Mouse 0 is always persistent
                if (comp.onPointerExit && args.event?.pointerType === PointerType.Touch) {
                    this.handlePointerExit(comp, args);
                    this.hoveredByID.delete(args.pointerId!);
                }
            }

            if (args.isClick) {
                if (comp.onPointerClick) {
                    comp.onPointerClick(args);
                }
            }
        });

        // after the propagation end, call UP on any objects that were DOWNED and didn't recieve an UP while propagating
        // If user drags away from the object, then it doesn't get the UP event
        if (args.isUp) {
            pressedEvent?.handlers.forEach((handler) => {
                this.invokeOnPointerUp(args, handler);
            });

            this.pressedByID.delete(args.pointerId);
        }
    }

    /** Propagate up in hierarchy and call onPointerExit */
    private propagatePointerExit(object: Object3D, args: PointerEventData, newObject: Object3D | null) {
        this.propagate(object, (behaviour) => {
            if (!behaviour.gameObject || behaviour.destroyed) return;

            const inst: any = behaviour;
            if (inst.onPointerExit || inst.onPointerEnter) {
                // if the newly hovered object is a child of the current object, we don't want to call onPointerExit
                if (newObject && this.isChild(newObject, behaviour.gameObject)) {
                    return;
                }
                this.handlePointerExit(inst, args);
            }
        });
    }

    /** handles onPointerUp - this will also release the pointerCapture */
    private invokeOnPointerUp(evt: PointerEventData, handler: IPointerUpHandler) {
        handler.onPointerUp?.call(handler, evt);
        this.releasePointerCapture(evt, handler);
    }

    /** Responsible for invoking onPointerEnter (and updating onPointerExit). We invoke onPointerEnter once per active pointerId */
    private handlePointerEnter(comp: IComponentCanMaybeReceiveEvents, args: PointerEventData) {
        if (comp.onPointerEnter) {
            if (this.updatePointerState(comp, args.pointerId, this.pointerEnterSymbol, true)) {
                comp.onPointerEnter(args);
            }
        }
        this.updatePointerState(comp, args.pointerId, this.pointerExitSymbol, false);
    }

    /** Responsible for invoking onPointerExit (and updating onPointerEnter). We invoke onPointerExit once per active pointerId */
    private handlePointerExit(comp: IComponentCanMaybeReceiveEvents, evt: PointerEventData) {
        if (comp.onPointerExit) {
            if (this.updatePointerState(comp, evt.pointerId, this.pointerExitSymbol, true)) {
                comp.onPointerExit(evt);
            }
        }
        this.updatePointerState(comp, evt.pointerId, this.pointerEnterSymbol, false);
    }

    /** updates the pointer state list for a component
     * @param comp the component to update
     * @param pointerId the pointerId to update
     * @param symbol the symbol to use for the state
     * @param add if true, the pointerId is added to the state list, if false the pointerId will be removed
     */
    private updatePointerState(comp: IComponentCanMaybeReceiveEvents, pointerId: number, symbol: symbol, add: boolean) {
        let state = comp[symbol];

        if (add) {
            // the pointer is already in the state list
            if (state && state.includes(pointerId)) return false;
            state = state || [];
            state.push(pointerId);
            comp[symbol] = state;
            return true;
        }
        else {
            if (!state || !state.includes(pointerId)) return false;
            const i = state.indexOf(pointerId);
            if (i !== -1) {
                state.splice(i, 1);
            }
            return true;
        }
    }

    /** the list of component handlers that requested pointerCapture for a specific pointerId */
    private readonly _capturedPointer: { [id: number]: IPointerEventHandler[] } = {};

    /** check if the event was marked to be captured: if yes add the current component to the captured list */
    private handlePointerCapture(evt: PointerEventData, comp: IPointerEventHandler) {
        if (evt.z__pointer_ctured) {
            evt.z__pointer_ctured = false;
            const id = evt.pointerId;
            // only the onPointerMove event is called with captured pointers so we don't need to add it to our list if it doesnt implement onPointerMove
            if (comp.onPointerMove) {
                const list = this._capturedPointer[id] || [];
                list.push(comp);
                this._capturedPointer[id] = list;
            }
            else {
                if (isDevEnvironment() && !comp["z__warned_no_pointermove"]) {
                    comp["z__warned_no_pointermove"] = true;
                    console.warn("PointerCapture was requested but the component doesn't implement onPointerMove. It will not receive any pointer events");
                }
            }
        }
        else if (evt.z__pointer_cture_rleased) {
            evt.z__pointer_cture_rleased = false;
            this.releasePointerCapture(evt, comp);
        }
    }

    /** removes the component from the pointer capture list */
    releasePointerCapture(evt: PointerEventData, component: IPointerEventHandler) {
        const id = evt.pointerId;
        if (this._capturedPointer[id]) {
            const i = this._capturedPointer[id].indexOf(component);
            if (i !== -1) {
                this._capturedPointer[id].splice(i, 1);
                if (debug) console.log("released pointer capture", id, component, this._capturedPointer)
            }
        }
    }
    /** invoke the pointerMove event on all captured handlers */
    private invokePointerCapture(evt: PointerEventData) {
        if (evt.event.type === InputEvents.PointerMove) {
            const id = evt.pointerId;
            const captured = this._capturedPointer[id];
            if (captured) {
                if (debug) console.log("Captured", id, captured)
                for (let i = 0; i < captured.length; i++) {
                    const handler = captured[i];
                    // check if it was destroyed
                    const comp = handler as IComponent;
                    if (comp.destroyed) {
                        captured.splice(i, 1);
                        i--;
                        continue;
                    }
                    // invoke pointer move
                    handler.onPointerMove?.call(handler, evt);
                }
            }
        }
    }

    private readonly pointerEnterSymbol = Symbol("pointerEnter");
    private readonly pointerExitSymbol = Symbol("pointerExit");

    private isChild(obj: Object3D, possibleChild: Object3D): boolean {
        if (!obj || !possibleChild) return false;
        if (obj === possibleChild) return true;
        if (!obj.parent) return false;
        return this.isChild(obj.parent, possibleChild);
    }

    private handleMeshUiObjectWithoutShadowDom(obj: any, pressed: boolean) {
        if (!obj || !obj.isUI) return true;
        const hit = this.handleMeshUIIntersection(obj, pressed);

        return hit;
    }

    private currentActiveMeshUIComponents: Object3D[] = [];

    private handleMeshUIIntersection(meshUiObject: Object3D, pressed: boolean): boolean {
        const res = MeshUIHelper.updateState(meshUiObject, pressed);
        if (res) {
            this.currentActiveMeshUIComponents.push(res);
        }
        return res !== null;
    }

    private resetMeshUIStates() {
        if (this.context.input.getPointerPressedCount() > 0) {
            MeshUIHelper.resetLastSelected();
        }

        if (!this.currentActiveMeshUIComponents || this.currentActiveMeshUIComponents.length <= 0) return;
        for (let i = 0; i < this.currentActiveMeshUIComponents.length; i++) {
            const comp = this.currentActiveMeshUIComponents[i];
            MeshUIHelper.resetState(comp);
        }
        this.currentActiveMeshUIComponents.length = 0;
    }

    private testIsVisible(obj: Object3D | null): boolean {
        if (!obj) return true;
        if (!GameObject.isActiveSelf(obj)) return false;
        return this.testIsVisible(obj.parent);
    }
}


class MeshUIHelper {

    private static lastSelected: Object3D | null = null;
    private static lastUpdateFrame: { context: Context, frame: number, nextUpdate: number }[] = [];
    private static needsUpdate: boolean = false;

    static markDirty() {
        this.needsUpdate = true;
    }

    static update(threeMeshUI: any, context: Context, force: boolean = false) {
        if (force) {
            threeMeshUI.update();
            return;
        }
        const currentFrame = context.time.frameCount;
        for (const lu of this.lastUpdateFrame) {
            if (lu.context === context) {
                if (currentFrame === lu.frame) return;
                lu.frame = currentFrame;
                let shouldUpdate = this.needsUpdate || currentFrame < 1;
                if (lu.nextUpdate <= currentFrame) shouldUpdate = true;
                // if (this.needsUpdate) lu.nextUpdate = currentFrame + 3;
                if (shouldUpdate) {
                    // console.warn(currentFrame, lu.nextUpdate, this.needsUpdate)
                    if (debug) console.log("Update threemeshui");
                    this.needsUpdate = false;
                    lu.nextUpdate = currentFrame + 60;
                    threeMeshUI.update();
                }
                return;
            }
        }
        this.lastUpdateFrame = [{ context, frame: currentFrame, nextUpdate: currentFrame + 60 }];
        threeMeshUI.update();
        this.needsUpdate = false;
    }

    static updateState(intersect: Object3D, _selectState: boolean): Object3D | null {
        let foundBlock: Object3D | null = null;

        if (intersect) {
            foundBlock = this.findBlockOrTextInParent(intersect);
            // console.log(intersect, "-- found block:", foundBlock)
            if (foundBlock && foundBlock !== this.lastSelected) {
                const interactable = foundBlock["interactable"];
                if (interactable === false) return null;
                this.needsUpdate = true;
            }
        }

        return foundBlock;
    }

    static resetLastSelected() {
        const last = this.lastSelected;
        if (!last) return;
        this.lastSelected = null;
        this.resetState(last);
    }

    static resetState(obj: any) {
        if (!obj) return;
        this.needsUpdate = true;
    }

    static findBlockOrTextInParent(elem: any): Object3D | null {
        if (!elem) return null;
        if (elem.isBlock || (elem.isText)) {
            // @TODO : Replace states managements
            // if (Object.keys(elem.states).length > 0)
            return elem;
        }
        return this.findBlockOrTextInParent(elem.parent);
    }
}