import { Intersection, Matrix4, Object3D, Ray, Vector2, Vector3 } from 'three';

import { showBalloonMessage, showBalloonWarning } from './debug/debug.js';
import { Context } from './engine_setup.js';
import { getTempVector, getWorldQuaternion } from './engine_three_utils.js';
import type { ButtonName, CursorTypeName, IGameObject, IInput, MouseButtonName, Vec2 } from './engine_types.js';
import { DeviceUtilities, type EnumToPrimitiveUnion, getParam } from './engine_utils.js';

const debug = getParam("debuginput");


/**
 * Types of pointer input devices supported by Needle Engine.
 */
export const enum PointerType {
    /** Mouse or trackpad input */
    Mouse = "mouse",
    /** Touch screen input */
    Touch = "touch",
    /** XR controller input (e.g., VR controllers) */
    Controller = "controller",
    /** XR hand tracking input */
    Hand = "hand"
}
export type PointerTypeNames = EnumToPrimitiveUnion<PointerType>;

const enum PointerEnumType {
    PointerDown = "pointerdown",
    PointerUp = "pointerup",
    PointerMove = "pointermove",
}
const enum KeyboardEnumType {
    KeyDown = "keydown",
    KeyUp = "keyup",
    KeyPressed = "keypress"
}

/**
 * Event types that can be listened to via {@link Input.addEventListener}.
 * @see {@link NEPointerEvent} for pointer event data
 * @see {@link NEKeyboardEvent} for keyboard event data
 */
export const enum InputEvents {
    /** Fired when a pointer button is pressed */
    PointerDown = "pointerdown",
    /** Fired when a pointer button is released */
    PointerUp = "pointerup",
    /** Fired when a pointer moves */
    PointerMove = "pointermove",
    /** Fired when a key is pressed down */
    KeyDown = "keydown",
    /** Fired when a key is released */
    KeyUp = "keyup",
    /** Fired when a key produces a character value */
    KeyPressed = "keypress"
}

/** e.g. `pointerdown` */
type PointerEventNames = EnumToPrimitiveUnion<PointerEnumType>;
type KeyboardEventNames = EnumToPrimitiveUnion<KeyboardEnumType>;
export type InputEventNames = PointerEventNames | KeyboardEventNames;

declare type PointerEventListener = (evt: NEPointerEvent) => void;
declare type KeyboardEventListener = (evt: NEKeyboardEvent) => void;
declare type InputEventListener = PointerEventListener | KeyboardEventListener;



export declare type NEPointerEventInit = PointerEventInit &
{
    clientZ?: number;
    origin: object;
    pointerId: number;
    /** the index of the device */
    deviceIndex: number;
    pointerType: PointerTypeNames;
    mode: XRTargetRayMode,
    ray?: Ray;
    /** The control object for this input. In the case of spatial devices the controller, 
     * otherwise a generated object in screen space. The object may not be in the scene. */
    device: IGameObject;
    buttonName: ButtonName | "none";
}

declare type OnPointerHitsEvent = (args: OnPointerHitEvent) => void;
declare type OnPointerHitEvent = {
    /** The object that raised the event */
    sender: object;
    /** The pointer event that invoked the event */
    event: NEPointerEvent;
    /** The intersections that were generated from this event (or are associated with this event in any way) */
    hits: Intersection[];
}
export interface IPointerHitEventReceiver {
    onPointerHits: OnPointerHitsEvent;
}

/** An intersection that is potentially associated with a pointer event */
export declare type NEPointerEventIntersection = Intersection & { event?: NEPointerEvent };

/**
 * Extended PointerEvent with Needle Engine-specific data.
 * Contains information about the input device, spatial data for XR, and world-space ray.
 *
 * @example Accessing event data in a component
 * ```ts
 * onPointerDown(args: PointerEventData) {
 *   const evt = args.event;
 *   console.log(`Pointer ${evt.pointerId} (${evt.pointerType})`);
 *   if (evt.isSpatial) {
 *     console.log("XR input, ray:", evt.ray);
 *   }
 * }
 * ```
 *
 * @see {@link Input} for the input management system
 * @see {@link PointerType} for available pointer types
 */
export class NEPointerEvent extends PointerEvent {

    /**
     * Spatial input data
     */
    clientZ?: number;

    /** the device index: mouse and touch are always 0, otherwise e.g. index of the connected Gamepad or XRController */
    readonly deviceIndex: number;

    /** The origin of the event contains a reference to the creator of this event.   
     * This can be the Needle Engine input system or e.g. a XR controller.  
     * Implement `onPointerHits` to receive the intersections of this event.
     */
    readonly origin: object & Partial<IPointerHitEventReceiver>;

    /** the browser event that triggered this event (if any) */
    readonly source: Event | null;

    /** Is the pointer event created via a touch on screen or a spatial device like a XR controller or hand tracking? */
    readonly mode: XRTargetRayMode | "transient-pointer";

    /** Returns true if the input was emitted in 3D space (and not by e.g. clicking on a 2D screen). You can use {@link mode} if you need more information about the input source */
    get isSpatial() {
        return this.mode != "screen";
    }

    /** A ray in worldspace for the event.    
     * If the ray is undefined you can also use `space.worldForward` and `space.worldPosition` */
    get ray(): Ray {
        if (!this._ray) {
            this._ray = new Ray(this.space.worldPosition.clone(), this.space.worldForward.clone());
        }
        return this._ray;
    }
    private set ray(value: Ray) { this._ray = value; }
    /**@returns true if this event has a ray. If you access the ray property a ray will automatically created */
    get hasRay() { return this._ray !== undefined; }
    private _ray: Ray | undefined;

    /** The device space (this object is not necessarily rendered in the scene but you can access or copy the matrix)   
     * E.g. you can access the input world space source position with `space.worldPosition` or world direction with `space.worldForward`
    */
    readonly space: IGameObject;

    /** true if this event is a click */
    isClick: boolean = false;
    /** true if this event is a double click */
    isDoubleClick: boolean = false;

    /** @returns `true` if the event is marked to be used (when `use()` has been called). Default: `false` */
    get used() { return this._used; }
    private _used: boolean = false;
    /** Call to mark an event to be used */
    use() {
        this._used = true;
    }

    /** Identifier for this pointer event.     
     * For mouse and touch this is always 0.  
     * For XR input: a combination of the deviceIndex + button to uniquely identify the exact input (e.g. LeftController:Button0 = 0, RightController:Button1 = 11) 
     */
    override get pointerId(): number { return this._pointerid; }
    private readonly _pointerid;

    // this is set via the init arguments (we override it here for intellisense to show the string options)
    /** What type of input created this event: touch, mouse, xr controller, xr hand tracking... */
    override get pointerType(): PointerTypeNames { return this._pointerType; }
    private readonly _pointerType: PointerTypeNames;

    /**
     * The button name that raised this event (e.g. for mouse events "left", "right", "middle" or for XRTrigger "xr-standard-trigger" or "xr-standard-thumbstick")    
     * Use {@link button} to get the numeric button index (e.g. 0, 1, 2...) on the controller or mouse.
     */
    readonly buttonName?: ButtonName | "none" = undefined;

    // this is set via the init arguments (we override it here for intellisense to show the string options)
    /** The input that raised this event like `pointerdown` */
    override get type(): InputEventNames { return this._type; }
    private readonly _type: InputEventNames;

    /** metadata can be used to associate additional information with the event */
    readonly metadata = {}

    /** intersections that were generated from this event (or are associated with this event in any way) */
    readonly intersections = new Array<NEPointerEventIntersection>();

    constructor(type: InputEvents | InputEventNames, source: Event | null, init: NEPointerEventInit) {
        super(type, init);
        this.clientZ = init.clientZ;
        // apply the init arguments. Otherwise the arguments will be undefined in the bundled / published version of needle engine
        // so we have to be careful if we override properties - we then also need to set them in the constructor
        this._pointerid = init.pointerId;
        this._pointerType = init.pointerType;
        this._type = type;

        this.deviceIndex = init.deviceIndex;
        this.origin = init.origin;
        this.source = source;
        this.mode = init.mode;
        this._ray = init.ray;
        this.space = init.device;
        this.buttonName = init.buttonName;
    }

    private _immediatePropagationStopped = false;
    get immediatePropagationStopped() {
        return this._immediatePropagationStopped;
    }
    private _propagationStopped = false;
    get propagationStopped() {
        return this._immediatePropagationStopped || this._propagationStopped;
    }

    stopImmediatePropagation(): void {
        this._immediatePropagationStopped = true;
        super.stopImmediatePropagation();
        this.source?.stopImmediatePropagation();
    }
    stopPropagation(): void {
        this._propagationStopped = true;
        super.stopPropagation();
        this.source?.stopPropagation();
        if (debug) console.warn("Stop propagation...", this.pointerId, this.pointerType)
    }
}
export class NEKeyboardEvent extends KeyboardEvent {
    source?: Event
    constructor(type: InputEvents, source: Event, init: KeyboardEventInit) {
        super(type, init)
        this.source = source;
    }
    stopImmediatePropagation(): void {
        super.stopImmediatePropagation();
        this.source?.stopImmediatePropagation();
    }
}

export class KeyEventArgs {
    key: string;
    keyType: string;
    source?: Event;
    constructor(evt: KeyboardEvent) {
        this.key = evt.key;
        this.keyType = evt.type;
        this.source = evt;
    }
}



export enum InputEventQueue {
    Early = -100,
    Default = 0,
    Late = 100,
}

declare type EventListenerOptions = {
    /** For addEventListener: The queue to add the listener to. Listeners in the same queue are called in the order they were added. Default is 0.   
     * For removeEventListener: The queue to remove the listener from. If no queue is specified the listener will be removed from all queues
     */
    queue?: InputEventQueue | number;
    /** If true, the listener will be removed after it is invoked once. */
    once?: boolean;
    /** The listener will be removed when the given AbortSignal object's `abort()` method is called. If not specified, no AbortSignal is associated with the listener. */
    signal?: AbortSignal;
}


type RegisteredEventListenerValue = Array<{ priority: number, listeners: Array<{ callback: InputEventListener, options: EventListenerOptions }> }>;

/**
 * Handles all input events including mouse, touch, keyboard, and XR controllers.  
 * Access via `this.context.input` from any component.
 *
 * @example Checking mouse/pointer state
 * ```ts
 * update() {
 *   if (this.context.input.mouseDown) {
 *     console.log("Mouse button pressed");
 *   }
 *   if (this.context.input.mouseClick) {
 *     console.log("Click detected");
 *   }
 *   const pos = this.context.input.mousePosition;
 *   console.log(`Mouse at: ${pos.x}, ${pos.y}`);
 * }
 * ```
 * @example Keyboard input
 * ```ts
 * update() {
 *   if (this.context.input.isKeyDown("Space")) {
 *     console.log("Space pressed this frame");
 *   }
 *   if (this.context.input.isKeyPressed("w")) {
 *     console.log("W key is held down");
 *   }
 * }
 * ```
 * @example Event-based input
 * ```ts
 * onEnable() {
 *   this.context.input.addEventListener("pointerdown", this.onPointerDown);
 * }
 * onDisable() {
 *   this.context.input.removeEventListener("pointerdown", this.onPointerDown);
 * }
 * onPointerDown = (evt: NEPointerEvent) => {
 *   console.log("Pointer down:", evt.pointerId);
 * }
 * ```
 *
 * @see {@link NEPointerEvent} for pointer event data
 * @see {@link InputEvents} for available event types
 * @see {@link PointerType} for pointer device types
 * @link https://engine.needle.tools/docs/scripting.html
 */
export class Input implements IInput {

    /** This is a list of event listeners per event type (e.g. pointerdown, pointerup, keydown...). Each entry contains a priority and list of listeners.  
     * That way users can control if they want to receive events before or after other listeners (e.g subscribe to pointer events before the EventSystem receives them) - this allows certain listeners to be always invoked first (or last) and stop propagation  
     * Listeners per event are sorted
     */
    private readonly _eventListeners: Record<string, RegisteredEventListenerValue> = {};

    /** Adds an event listener for the specified event type. The callback will be called when the event is triggered.
     * @param type The event type to listen for
     * @param callback The callback to call when the event is triggered
     * @param options The options for adding the event listener.
     * @example Basic usage
     * ```ts
     * input.addEventListener("pointerdown", (evt) => {
     *   console.log("Pointer down", evt.pointerId, evt.pointerType);
     * });
     * ```
     * @example Adding a listener that is called after all other listeners
     * By using a higher value for the queue the listener will be called after other listeners (default queue is 0).
     * ```ts
     * input.addEventListener("pointerdown", (evt) => {
     *  console.log("Pointer down", evt.pointerId, evt.pointerType);
     * }, { queue: 10 });
     * ```
     * @example Adding a listener that is only called once
     * ```ts
     * input.addEventListener("pointerdown", (evt) => {
     *   console.log("Pointer down", evt.pointerId, evt.pointerType);
     * }, { once: true });
     * ```
     */
    addEventListener(type: PointerEventNames, callback: PointerEventListener, options?: EventListenerOptions);
    addEventListener(type: KeyboardEventNames, callback: KeyboardEventListener, options?: EventListenerOptions);
    addEventListener(type: InputEvents | InputEventNames, callback: InputEventListener, options?: EventListenerOptions): void {
        if (!this._eventListeners[type]) this._eventListeners[type] = [];

        if (!callback || typeof callback !== "function") {
            console.error("Invalid call to addEventListener: callback is required and must be a function!");
            return;
        }

        if (!options) options = {};
        // create a copy of the options object to avoid the original object being modified
        else options = { ...options };

        let queue = 0;
        if (options?.queue != undefined) queue = options.queue;

        const listeners = this._eventListeners[type];
        const queueListeners = listeners.find(l => l.priority === queue);
        if (!queueListeners) {
            listeners.push({ priority: queue, listeners: [{ callback, options }] });
            // ensure we sort the listeners by priority
            listeners.sort((a, b) => a.priority - b.priority);
        } else {
            queueListeners.listeners.push({ callback, options });
        }
    }
    /** Removes the event listener from the specified event type. If no queue is specified the listener will be removed from all queues.
     * @param type The event type to remove the listener from
     * @param callback The callback to remove
     * @param options The options for removing the event listener 
     */
    removeEventListener(type: PointerEventNames, callback: PointerEventListener, options?: EventListenerOptions);
    removeEventListener(type: KeyboardEventNames, callback: KeyboardEventListener, options?: EventListenerOptions);
    removeEventListener(type: InputEvents | InputEventNames, callback: InputEventListener, options?: EventListenerOptions): void {
        if (!this._eventListeners[type]) return;
        if (!callback) return;
        const listeners = this._eventListeners[type];
        // if a specific queue is requested the callback should only be removed from that queue
        if (options?.queue != undefined) {
            const queueListeners = listeners.find(l => l.priority === options.queue);
            if (!queueListeners) return;
            const index = queueListeners.listeners.findIndex(l => l.callback === callback);
            if (index >= 0) queueListeners.listeners.splice(index, 1);
        }
        // if no queue is requested the callback will be removed from all queues 
        else {
            for (const l of listeners) {
                const index = l.listeners.findIndex(l => l.callback === callback);
                if (index >= 0) l.listeners.splice(index, 1);
            }
        }
    }
    private dispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) {
        /** True when the next event queue should not be invoked */
        let preventNextEventQueue = false;

        // Handle keyboard event
        if (evt instanceof NEKeyboardEvent) {
            const listeners = this._eventListeners[evt.type];
            if (listeners) {
                for (const queue of listeners) {
                    for (let i = 0; i < queue.listeners.length; i++) {
                        const entry = queue.listeners[i];
                        // if the abort signal is aborted we remove the listener and will not invoke it
                        if (entry.options?.signal?.aborted) {
                            queue.listeners.splice(i, 1);
                            i--;
                            continue;
                        }
                        // if the event should only be invoked once then we remove the listener before invoking it
                        if (entry.options.once) {
                            queue.listeners.splice(i, 1);
                            i--;
                        }
                        (entry.callback as KeyboardEventListener)(evt);
                    }
                }
            }
        }

        // Hnadle pointer event
        if (evt instanceof NEPointerEvent) {
            const listeners = this._eventListeners[evt.type];
            if (listeners) {
                for (const queue of listeners) {
                    if (preventNextEventQueue) break;
                    for (let i = 0; i < queue.listeners.length; i++) {
                        const entry = queue.listeners[i];

                        // if the abort signal is aborted we remove the listener and will not invoke it
                        if (entry.options?.signal?.aborted) {
                            queue.listeners.splice(i, 1);
                            i--;
                            continue;
                        }
                        // if immediatePropagationStopped is true we stop propagation altogether
                        if (evt.immediatePropagationStopped) {
                            preventNextEventQueue = true;
                            if (debug) console.log("immediatePropagationStopped", evt.type);
                            break;
                        }
                        // if propagationStopped is true we continue invoking the current queue but then not invoke the next queue
                        else if (evt.propagationStopped) {
                            preventNextEventQueue = true;
                            if (debug) console.log("propagationStopped", evt.type);
                            // we do not break here but continue invoking the listeners in the queue
                        }

                        // if the event should only be invoked once then we remove the listener before invoking it
                        if (entry.options.once) {
                            queue.listeners.splice(i, 1);
                            i--;
                        }
                        (entry.callback as PointerEventListener)(evt);
                    }
                }
            }
        }
    }


    _doubleClickTimeThreshold = .2;
    _longPressTimeThreshold = 1;

    get mousePosition(): Vector2 { return this._pointerPositions[0]; };
    get mousePositionRC(): Vector2 { return this._pointerPositionsRC[0]; }
    get mouseDown(): boolean { return this._pointerDown[0]; }
    get mouseUp(): boolean { return this._pointerUp[0]; }
    /** Is the primary pointer clicked (usually the left button). This is equivalent to `input.click` */
    get mouseClick(): boolean { return this._pointerClick[0]; }
    /** Was a double click detected for the primary pointer? This is equivalent to `input.doubleClick` */
    get mouseDoubleClick(): boolean { return this._pointerDoubleClick[0]; }
    get mousePressed(): boolean { return this._pointerPressed[0]; }
    get mouseWheelChanged(): boolean { return this.getMouseWheelChanged(0); }

    /** Is the primary pointer double clicked (usually the left button). This is equivalent to `input.mouseDoubleClick` */
    get click(): boolean { return this._pointerClick[0]; }
    /** Was a double click detected for the primary pointer? */
    get doubleClick(): boolean { return this._pointerDoubleClick[0]; }

    /**
     * Get a connected Gamepad    
     * Note: For a gamepad to be available to the browser it must have received input before while the page was focused.  
     * @link https://developer.mozilla.org/en-US/docs/Web/API/Gamepad_API/Using_the_Gamepad_API
     * @returns The gamepad or null if no gamepad is connected
     */
    getGamepad(index: number = 0): Gamepad | null {
        if (typeof navigator !== "undefined" && "getGamepads" in navigator) {
            return navigator.getGamepads()[index] || null;
        }
        return null;
    }

    private readonly _setCursorTypes: CursorTypeName[] = [];

    /** @deprecated use setCursor("pointer") */
    setCursorPointer() {
        this.setCursor("pointer");
    }
    /** @deprecated use unsetCursor() */
    setCursorNormal() {
        this.unsetCursor("pointer");
    }
    /**
     * Set a custom cursor. This will set the cursor type until unsetCursor is called
     */
    setCursor(type: CursorTypeName) {
        this._setCursorTypes.push(type);
        if (this._setCursorTypes.length > 10) {
            this._setCursorTypes.shift();
        }
        this.updateCursor();
    }
    /**
     * Unset a custom cursor. This will set the cursor type to the previous type or default
     */
    unsetCursor(type: CursorTypeName) {
        for (let i = this._setCursorTypes.length - 1; i >= 0; i--) {
            if (this._setCursorTypes[i] === type) {
                this._setCursorTypes.splice(i, 1);
                this.updateCursor();
                break;
            }
        }
    }
    private updateCursor() {
        if (this._setCursorTypes?.length == 0)
            this.context.domElement.style.cursor = "default";
        else this.context.domElement.style.cursor = this._setCursorTypes[this._setCursorTypes.length - 1];
    }

    /**
     * Check if a pointer id is currently used.
     */
    getIsPointerIdInUse(pointerId: number) {
        for (const evt of this._pointerEventsPressed) {
            if (evt.pointerId === pointerId) {
                if (evt.used) return true;
            }
        }
        return false;
    }
    /** how many pointers are currently pressed */
    getPointerPressedCount(): number {
        let count = 0;
        for (let i = 0; i < this._pointerPressed.length; i++) {
            if (this._pointerPressed[i]) {
                count++;
            }
        }
        return count;
    }

    /**
     * Gets the position of the given pointer index in pixel
     * @param i The pointer index
     * @returns The position of the pointer in pixel
     */
    getPointerPosition(i: number): Vector2 | null {
        if (i >= this._pointerPositions.length) return null;
        return this._pointerPositions[i];
    }
    getPointerPositionLastFrame(i: number): Vector2 | null {
        if (i >= this._pointerPositionsLastFrame.length) return null;
        return this._pointerPositionsLastFrame[i];
    }
    getPointerPositionDelta(i: number): Vector2 | null {
        if (i >= this._pointerPositionsDelta.length) return null;
        return this._pointerPositionsDelta[i];
    }
    /**
     * The pointer position in screenspace coordinates (-1 to 1) where 0 is the center of the screen.  
     * This can be useful for e.g. raycasting (see https://threejs.org/docs/#api/en/core/Raycaster.setFromCamera)
     */
    getPointerPositionRC(i: number): Vector2 | null {
        if (i >= this._pointerPositionsRC.length) return null;
        return this._pointerPositionsRC[i];
    }
    getPointerDown(i: number): boolean {
        if (i >= this._pointerDown.length) return false;
        return this._pointerDown[i];
    }
    getPointerUp(i: number): boolean {
        if (i >= this._pointerUp.length) return false;
        return this._pointerUp[i];
    }
    getPointerPressed(i: number): boolean {
        if (i >= this._pointerPressed.length) return false;
        const res = this._pointerPressed[i];
        // if (i === 0) console.log(...this._pointerIds);
        return res;
    }
    getPointerClicked(i: number): boolean {
        if (i >= this._pointerClick.length) return false;
        return this._pointerClick[i];
    }
    getPointerDoubleClicked(i: number): boolean {
        if (i >= this._pointerDoubleClick.length) return false;
        return this._pointerDoubleClick[i];
    }
    getPointerDownTime(i: number): number {
        if (i >= this._pointerDownTime.length) return -1;
        return this._pointerDownTime[i];
    }
    getPointerUpTime(i: number): number {
        if (i >= this._pointerUpTime.length) return -1;
        return this._pointerUpTime[i];
    }
    getPointerLongPress(i: number): boolean {
        if (i >= this._pointerDownTime.length) return false;
        return this.getPointerPressed(i) && this.context.time.time - this._pointerDownTime[i] > this._longPressTimeThreshold;
    }
    getIsMouse(i: number): boolean {
        if (i < 0 || i >= this._pointerTypes.length) return false;
        return this._pointerTypes[i] === PointerType.Mouse;
    }
    getIsTouch(i: number): boolean {
        if (i < 0 || i >= this._pointerTypes.length) return false;
        return this._pointerTypes[i] === PointerType.Touch;
    }
    getTouchesPressedCount(): number {
        let count = 0;
        for (let i = 0; i < this._pointerPressed.length; i++) {
            if (this._pointerPressed[i] && this.getIsTouch(i)) {
                count++;
            }
        }
        return count;
    }
    getMouseWheelChanged(i: number = 0): boolean {
        if (i >= this._mouseWheelChanged.length) return false;
        return this._mouseWheelChanged[i];
    }
    getMouseWheelDeltaY(i: number = 0): number {
        if (i >= this._mouseWheelDeltaY.length) return 0;
        return this._mouseWheelDeltaY[i];
    }
    getPointerEvent(i: number): Event | undefined {
        if (i >= this._pointerEvent.length) return undefined;
        return this._pointerEvent[i] ?? undefined;
    }
    *foreachPointerId(pointerType?: string | PointerType | string[] | PointerType[]): Generator<number> {
        for (let i = 0; i < this._pointerTypes.length; i++) {
            // check if the pointer is active
            if (this._pointerIsActive(i)) {
                // if specific pointer types are requested
                if (pointerType !== undefined) {
                    const type = this._pointerTypes[i];
                    if (Array.isArray(pointerType)) {
                        let isInArray = false;
                        for (const t of pointerType) {
                            if (type === t) {
                                isInArray = true;
                                break;
                            }
                        }
                        if (!isInArray) continue;
                    } else {
                        if (pointerType !== type) continue;
                    }
                }
                yield i;
            }
        }
    }
    *foreachTouchId(): Generator<number> {
        for (let i = 0; i < this._pointerTypes.length; i++) {
            const type = this._pointerTypes[i];
            if (type !== PointerType.Touch) continue;
            if (this._pointerIsActive[i])
                yield i;
        }
    }

    private _pointerIsActive(index: number) {
        if (index < 0) return false;
        return this._pointerPressed[index] || this._pointerDown[index] || this._pointerUp[index];
    }

    private context: Context;

    private _pointerDown: boolean[] = [false];
    private _pointerUp: boolean[] = [false];
    private _pointerClick: boolean[] = [false];
    private _pointerDoubleClick: boolean[] = [false];
    private _pointerPressed: boolean[] = [false];
    private _pointerPositions: Vector2[] = [new Vector2()];
    private _pointerPositionsLastFrame: Vector2[] = [new Vector2()];
    private _pointerPositionsDelta: Vector2[] = [new Vector2()];
    private _pointerPositionsRC: Vector2[] = [new Vector2()];
    private _pointerPositionDown: Vector3[] = [new Vector3()];
    private _pointerDownTime: number[] = [];
    private _pointerUpTime: number[] = [];
    private _pointerUpTimestamp: number[] = [];
    private _pointerIds: number[] = [];
    private _pointerTypes: string[] = [""];
    private _mouseWheelChanged: boolean[] = [false];
    private _mouseWheelDeltaY: number[] = [0];
    private _pointerEvent: Event[] = [];
    /** current pressed pointer events. Used to check if any of those events was used  */
    private _pointerEventsPressed: NEPointerEvent[] = [];
    /** This is added/updated for pointers. screenspace pointers set this to the camera near plane  */
    private _pointerSpace: IGameObject[] = [];


    private readonly _pressedStack = new Map<number, number[]>();
    private onDownButton(pointerId: number, button: number) {
        let stack = this._pressedStack.get(pointerId);
        if (!stack) {
            stack = [];
            this._pressedStack.set(pointerId, stack);
        }
        stack.push(button);
    }
    private onReleaseButton(pointerId: number, button: number) {
        const stack = this._pressedStack.get(pointerId);
        if (!stack) return;
        const index = stack.indexOf(button);
        if (index >= 0) stack.splice(index, 1);
    }
    /** the first button that was down and is currently pressed */
    getFirstPressedButtonForPointer(pointerId: number): number | undefined {
        const stack = this._pressedStack.get(pointerId);
        if (!stack) return undefined;
        return stack[0];
    }
    /** the last (most recent) button that was down and is currently pressed */
    getLatestPressedButtonForPointer(pointerId: number): number | undefined {
        const stack = this._pressedStack.get(pointerId);
        if (!stack) return undefined;
        return stack[stack.length - 1];
    }


    /** Get a key (if any) that was just pressed this frame (this is only true for the frame it was pressed down) */
    getKeyDown(): string | null;
    /** Get true or false if the given key was pressed this frame */
    getKeyDown(key: KeyCode | ({} & string)): boolean;
    getKeyDown(key?: KeyCode | ({} & string)): boolean | string | null {
        // If a key is provided check if it was pressed this frame
        if (key !== undefined) {
            return this.isKeyDown(key);
        }
        // If no key was provided get the first key that was pressed this frame
        for (const key in this.keysPressed) {
            const k = this.keysPressed[key];
            if (k.startFrame === this.context.time.frameCount) return k.key;
        }
        return null;
    }
    /** Get a key (if any) that is currently being pressed (held down) */
    getKeyPressed(): string | null;
    /** Get true or false if the given key is pressed */
    getKeyPressed(key: KeyCode | ({} & string)): boolean
    getKeyPressed(key?: KeyCode | ({} & string)): boolean | string | null {
        if (key !== undefined) {
            return this.isKeyPressed(key);
        }
        for (const key in this.keysPressed) {
            const k = this.keysPressed[key];
            if (k.pressed)
                return k.key;
        }
        return null;
    }

    /** Get a key (if any) that was released in this frame */
    getKeyUp(): string | null;
    /** Get true or false if the given key was released this frame */
    getKeyUp(key: KeyCode | ({} & string)): boolean;
    getKeyUp(key?: KeyCode | ({} & string)): boolean | string | null {

        if (key !== undefined) {
            return this.isKeyUp(key);
        }
        for (const key in this.keysPressed) {
            const k = this.keysPressed[key];
            if (k.pressed === false && k.frame === this.context.time.frameCount) return true;
            return false;
        }

        return null;
    }

    isKeyDown(keyCode: KeyCode | ({} & string)) {
        if (!this.context.application.isVisible || !this.context.application.hasFocus) return false;
        const codes = this.getCodeForCommonKeyName(keyCode);
        if (codes !== null) {
            for (const code of codes) if (this.isKeyDown(code)) return true;
            return false;
        }
        const k = this.keysPressed[keyCode];
        if (!k) return false;
        return k.startFrame === this.context.time.frameCount && k.pressed;
    }
    isKeyUp(keyCode: KeyCode | ({} & string)) {
        if (!this.context.application.isVisible || !this.context.application.hasFocus) return false;
        const codes = this.getCodeForCommonKeyName(keyCode);
        if (codes !== null) {
            for (const code of codes) if (this.isKeyUp(code)) return true;
            return false;
        }
        const k = this.keysPressed[keyCode];
        if (!k) return false;
        return k.frame === this.context.time.frameCount && k.pressed === false;
    }
    isKeyPressed(keyCode: KeyCode | ({} & string)): boolean {
        if (!this.context.application.isVisible || !this.context.application.hasFocus) return false;
        const codes = this.getCodeForCommonKeyName(keyCode);
        if (codes !== null) {
            for (const code of codes) if (this.isKeyPressed(code)) return true;
            return false;
        }
        const k = this.keysPressed[keyCode];
        if (!k) return false;
        return k.pressed || false;
    }

    // utility helper for mapping common names to actual codes; e.g. "Shift" -> "ShiftLeft" and "ShiftRight" or "a" -> "KeyA"
    private getCodeForCommonKeyName(keyName: string): string[] | null {
        if (keyName.length === 1) {
            // check if this is a digit
            if (keyName >= "0" && keyName <= "9")
                return ["Digit" + keyName];
            // check if this is a letter
            if (keyName >= "a" && keyName <= "z")
                return ["Key" + keyName.toUpperCase()];
            if (keyName == " ")
                return ["Space"];
        }
        switch (keyName) {
            case "shift":
            case "Shift":
                return ["ShiftLeft", "ShiftRight"];
            case "control":
            case "Control":
                return ["ControlLeft", "ControlRight"];
            case "alt":
            case "Alt":
                return ["AltLeft", "AltRight"];
        }
        return null;
    }

    createInputEvent(args: NEPointerEvent) {
        // TODO: technically we would need to check for circular invocations here!
        switch (args.type) {
            case InputEvents.PointerDown:
                if (debug) showBalloonMessage("Create Pointer down");
                this.onDownButton(args.deviceIndex, args.button);
                this.onDown(args);
                break;
            case InputEvents.PointerMove:
                if (debug) showBalloonMessage("Create Pointer move");
                this.onMove(args);
                break;
            case InputEvents.PointerUp:
                if (debug) showBalloonMessage("Create Pointer up");
                this.onUp(args);
                this.onReleaseButton(args.deviceIndex, args.button);
                break;
        }
    }

    convertScreenspaceToRaycastSpace<T extends Vec2 | Vector2>(vec2: T): T {
        vec2.x = (vec2.x - this.context.domX) / this.context.domWidth * 2 - 1;
        vec2.y = -((vec2.y - this.context.domY) / this.context.domHeight) * 2 + 1;
        return vec2;
    }

    /** @internal */
    constructor(context: Context) {
        this.context = context;
        this.context.post_render_callbacks.push(this.onEndOfFrame);
    }

    /** this is the html element we subscribed to for events */
    private _htmlEventSource!: HTMLElement;

    bindEvents() {
        this.unbindEvents();

        // we subscribe to the canvas element because we don't want to receive events when the user is interacting with the UI
        // e.g. if we have slotted HTML elements in the needle engine DOM elements we don't want to receive input events for those
        this._htmlEventSource = this.context.renderer.domElement;

        window.addEventListener('contextmenu', this.onContextMenu);

        this._htmlEventSource.addEventListener('pointerdown', this.onPointerDown, { passive: true });
        window.addEventListener('pointermove', this.onPointerMove, { passive: true, capture: true, });
        window.addEventListener('pointerup', this.onPointerUp, { passive: true });
        window.addEventListener('pointercancel', this.onPointerCancel, { passive: true })

        window.addEventListener("touchstart", this.onTouchStart, { passive: true });
        window.addEventListener("touchmove", this.onTouchMove, { passive: true });
        window.addEventListener("touchend", this.onTouchEnd, { passive: true });

        this._htmlEventSource.addEventListener('wheel', this.onMouseWheel, { passive: true });
        window.addEventListener("wheel", this.onWheelWindow, { passive: true })

        window.addEventListener("keydown", this.onKeyDown, false);
        window.addEventListener("keypress", this.onKeyPressed, false);
        window.addEventListener("keyup", this.onKeyUp, false);

        // e.g. when using sharex to capture we loose focus thus dont get e.g. key up events
        window.addEventListener('blur', this.onLostFocus);
    }

    unbindEvents() {
        for (const key in this._eventListeners) {
            this._eventListeners[key].length = 0; // clear all listeners for this event
        }

        window.removeEventListener('contextmenu', this.onContextMenu);

        this._htmlEventSource?.removeEventListener('pointerdown', this.onPointerDown);
        window.removeEventListener('pointermove', this.onPointerMove);
        window.removeEventListener('pointerup', this.onPointerUp);
        window.removeEventListener('pointercancel', this.onPointerCancel);

        window.removeEventListener("touchstart", this.onTouchStart);
        window.removeEventListener("touchmove", this.onTouchMove);
        window.removeEventListener("touchend", this.onTouchEnd);

        this._htmlEventSource?.removeEventListener('wheel', this.onMouseWheel, false);
        window.removeEventListener("wheel", this.onWheelWindow, false);

        window.removeEventListener("keydown", this.onKeyDown, false);
        window.removeEventListener("keypress", this.onKeyPressed, false);
        window.removeEventListener("keyup", this.onKeyUp, false);

        window.removeEventListener('blur', this.onLostFocus);
    }

    dispose() {
        const index = this.context.post_render_callbacks.indexOf(this.onEndOfFrame);
        if (index >= 0) this.context.post_render_callbacks.splice(index, 1);
        this.unbindEvents();
    }

    private onLostFocus = () => {
        for (const kp in this.keysPressed) {
            this.keysPressed[kp].pressed = false;
        }
    }

    private readonly _receivedPointerMoveEventsThisFrame = new Array<number>;

    private onEndOfFrame = () => {
        this._receivedPointerMoveEventsThisFrame.length = 0;
        for (let i = 0; i < this._pointerUp.length; i++)
            this._pointerUp[i] = false;
        for (let i = 0; i < this._pointerDown.length; i++)
            this._pointerDown[i] = false;
        for (let i = 0; i < this._pointerClick.length; i++)
            this._pointerClick[i] = false;
        for (let i = 0; i < this._pointerDoubleClick.length; i++)
            this._pointerDoubleClick[i] = false;
        for (const pt of this._pointerPositionsDelta)
            pt.set(0, 0);
        for (let i = 0; i < this._mouseWheelChanged.length; i++)
            this._mouseWheelChanged[i] = false;
        for (let i = 0; i < this._mouseWheelDeltaY.length; i++)
            this._mouseWheelDeltaY[i] = 0;
    }

    private canReceiveInput(evt: Event) {
        // If the user has HTML objects ontop of the canvas
        // if(evt.target === this.context.renderer.domElement) return true;
        // const css = window.getComputedStyle(evt.target as HTMLElement);
        // if(css.pointerEvents === "all") return false;
        // We only check the target elements here since the canvas may be overlapped by other elements
        // in which case we do not want to use the input (e.g. if a HTML element is being triggered)
        if (evt.target === this.context.renderer?.domElement) return true;
        if (evt.target === this.context.domElement) return true;

        // if we are in AR we always want to receive touches because the canvas is the whole screen. 
        // See https://linear.app/needle/issue/NE-4345
        if (this.context.isInAR) {
            return true;
        }

        // looks like in Mozilla WebXR viewer the target element is the body
        if (this.context.isInAR && evt.target === document.body && DeviceUtilities.isMozillaXR()) return true;

        if (debug) console.warn("CanReceiveInput:False for", evt.target);
        return false;
    }

    private onContextMenu = (evt: Event) => {
        if (this.canReceiveInput(evt) === false)
            return;
        // if (evt instanceof PointerEvent) {
        //     // for longpress on touch there might open a context menu
        //     // in which case we set the pointer pressed back to false (resetting the pressed pointer)
        //     // we need to emit a pointer up event here as well
        //     if (evt.pointerType === "touch") {
        //         // for (const index in this._pointerPressed) {
        //         //     if (this._pointerTypes[index] === PointerType.Touch) {
        //         //         // this._pointerPressed[index] = false;
        //         //         // this throws orbit controls?
        //         //         // const ne = this.createPointerEventFromTouch("pointerup", parseInt(index), this._pointerPositions[index].x, this._pointerPositions[index].y, 0, evt);
        //         //         // this.onUp(ne);
        //         //     }
        //         // }
        //     }
        // }
    }

    private keysPressed: { [key: KeyCode | string]: { pressed: boolean, frame: number, startFrame: number, key: string, code: KeyCode | string } } = {};

    private onKeyDown = (evt: KeyboardEvent) => {
        if (debug) console.log(`key down ${evt.code}, ${this.context.application.hasFocus}`, evt);
        if (!this.context.application.hasFocus)
            return;
        const ex = this.keysPressed[evt.code];
        if (ex && ex.pressed) return;
        this.keysPressed[evt.code] = { pressed: true, frame: this.context.time.frameCount + 1, startFrame: this.context.time.frameCount + 1, key: evt.key, code: evt.code };
        const ne = new NEKeyboardEvent(InputEvents.KeyDown, evt, evt);
        this.onDispatchEvent(ne);
    }
    private onKeyPressed = (evt: KeyboardEvent) => {
        if (!this.context.application.hasFocus)
            return;
        const p = this.keysPressed[evt.code];
        if (!p) return;
        p.pressed = true;
        p.frame = this.context.time.frameCount + 1;
        const ne = new NEKeyboardEvent(InputEvents.KeyPressed, evt, evt);
        this.onDispatchEvent(ne);

    }
    private onKeyUp = (evt: KeyboardEvent) => {
        if (!this.context.application.hasFocus)
            return;
        const p = this.keysPressed[evt.code];
        if (!p) return;
        p.pressed = false;
        p.frame = this.context.time.frameCount + 1;
        const ne = new NEKeyboardEvent(InputEvents.KeyUp, evt, evt);
        this.onDispatchEvent(ne);
    }

    private onWheelWindow = (evt: WheelEvent) => {
        // check if we are in pointer lock mode
        if (document.pointerLockElement) {
            // only if yes we want to use the mouse wheel as a pointer event
            this.onMouseWheel(evt);
        }
    };
    private onMouseWheel = (evt: WheelEvent) => {
        if (this.canReceiveInput(evt) === false) return;
        if (this._mouseWheelDeltaY.length <= 0) this._mouseWheelDeltaY.push(0);
        if (this._mouseWheelChanged.length <= 0) this._mouseWheelChanged.push(false);
        this._mouseWheelChanged[0] = true;
        const current = this._mouseWheelDeltaY[0];
        this._mouseWheelDeltaY[0] = current + evt.deltaY;
    }
    private onPointerDown = (evt: PointerEvent) => {
        if (this.context.isInAR) return;
        if (this.canReceiveInput(evt) === false) return;
        if (evt.target instanceof HTMLElement) {
            evt.target.setPointerCapture(evt.pointerId);
        }
        const id = this.getPointerId(evt);
        if (debug) showBalloonMessage(`pointer down #${id}, identifier:${evt.pointerId}`);
        const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY);
        const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: evt.button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType as PointerTypeNames, buttonName: this.getButtonName(evt), device: space, pressure: evt.pressure });
        this.onDown(ne);
    }
    private onPointerMove = (evt: PointerEvent) => {
        if (this.context.isInAR) return;

        // Prevent multiple pointerMove events per frame
        if (this._receivedPointerMoveEventsThisFrame.includes(evt.pointerId)) return;
        this._receivedPointerMoveEventsThisFrame.push(evt.pointerId);

        // We want to keep receiving move events until pointerUp and not stop handling events just because we're hovering over *some* HTML element
        // if (this.canReceiveInput(evt) === false) return;

        let button = evt.button;
        if (evt.pointerType === "mouse") {
            const pressedButton = this.getFirstPressedButtonForPointer(0);
            button = pressedButton ?? 0;
        }
        const id = this.getPointerId(evt, button);
        if (button === -1) {
            button = id;
        }
        const space = this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY);
        const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType as PointerTypeNames, buttonName: this.getButtonName(evt), device: space, pressure: evt.pressure });
        this.onMove(ne);
    }
    private onPointerCancel = (evt: PointerEvent) => {
        if (this.context.isInAR) return;
        if (debug) console.log("Pointer cancel", evt);
        // we treat this as an up event for now to make sure we don't have any pointers stuck in a pressed state etc. Technically we dont want to invoke a up event for cancels...
        this.onPointerUp(evt);
    }
    private onPointerUp = (evt: PointerEvent) => {
        if (this.context.isInAR) return;
        if (evt.target instanceof HTMLElement) {
            evt.target.releasePointerCapture(evt.pointerId);
        }
        // the pointer up event should always be handled
        // if (this.canReceiveInput(evt) === false) return;
        const id = this.getPointerId(evt);
        // if (!this.isNewEvent(evt.timeStamp, id, this._pointerUpTimestamp)) return;
        const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: evt.button, clientX: evt.clientX, clientY: evt.clientY, pointerType: evt.pointerType as PointerTypeNames, buttonName: this.getButtonName(evt), device: this.getAndUpdateSpatialObjectForScreenPosition(id, evt.clientX, evt.clientY), pressure: evt.pressure });
        this.onUp(ne);
        this._pointerIds[id] = -1;
        if (debug) console.log("ID=" + id, "PointerId=" + evt.pointerId, "ALL:", [...this._pointerIds]);
    }

    private getPointerId(evt: PointerEvent, button?: number): number {
        if (evt.pointerType === "mouse") return 0 + (button ?? evt.button);
        return this.getPointerIndex(evt.pointerId);
    }
    private getButtonName(evt: PointerEvent): MouseButtonName | "unknown" {
        const button = evt.button;
        if (evt.pointerType === "mouse") {
            switch (button) {
                case 0: return "left";
                case 1: return "middle";
                case 2: return "right";
            }
        }
        return "unknown";
    }

    // the touch events are currently only used for AR support on android
    private onTouchStart = (evt: TouchEvent) => {
        if (!this.context.isInAR) return;
        for (let i = 0; i < evt.changedTouches.length; i++) {
            const touch = evt.changedTouches[i];
            const id = this.getPointerIndex(touch.identifier);
            const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY);
            const ne = new NEPointerEvent(InputEvents.PointerDown, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: space, pressure: touch.force });
            this.onDown(ne);
        };
    };
    private onTouchMove = (evt: TouchEvent) => {
        if (!this.context.isInAR) return;
        for (let i = 0; i < evt.changedTouches.length; i++) {
            const touch = evt.changedTouches[i];
            const id = this.getPointerIndex(touch.identifier);
            const space = this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY);
            const ne = new NEPointerEvent(InputEvents.PointerMove, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: space, pressure: touch.force });
            this.onMove(ne);
        };
    }
    private onTouchEnd = (evt: TouchEvent) => {
        if (!this.context.isInAR) return;
        for (let i = 0; i < evt.changedTouches.length; i++) {
            const touch = evt.changedTouches[i];
            const id = this.getPointerIndex(touch.identifier);
            const ne = new NEPointerEvent(InputEvents.PointerUp, evt, { origin: this, mode: "screen", deviceIndex: 0, pointerId: id, button: 0, clientX: touch.clientX, clientY: touch.clientY, pointerType: "touch", buttonName: "unknown", device: this.getAndUpdateSpatialObjectForScreenPosition(id, touch.clientX, touch.clientY), pressure: touch.force });
            this.onUp(ne);
            this._pointerIds[id] = -1;
        };
    }

    private readonly tempNearPlaneVector = new Vector3();
    private readonly tempFarPlaneVector = new Vector3();
    private readonly tempLookMatrix = new Matrix4();
    private getAndUpdateSpatialObjectForScreenPosition(id: number, screenX: number, screenY: number): IGameObject {
        let space = this._pointerSpace[id]
        if (!space) {
            space = new Object3D() as unknown as IGameObject;
            this._pointerSpace[id] = space;
        }
        this._pointerSpace[id] = space;
        const camera = this.context.mainCamera;
        if (camera) {
            const pointOnNearPlane = this.tempNearPlaneVector.set(screenX, screenY, -1);
            this.convertScreenspaceToRaycastSpace(pointOnNearPlane);
            const pointOnFarPlane = this.tempFarPlaneVector.set(pointOnNearPlane.x, pointOnNearPlane.y, 1);
            pointOnNearPlane.unproject(camera);
            pointOnFarPlane.unproject(camera);
            const worldUp = (camera as any as IGameObject).worldUp || getTempVector(0, 1, 0).applyQuaternion(getWorldQuaternion(camera))
            this.tempLookMatrix.lookAt(pointOnFarPlane, pointOnNearPlane, worldUp);
            space.position.set(pointOnNearPlane.x, pointOnNearPlane.y, pointOnNearPlane.z);
            space.quaternion.setFromRotationMatrix(this.tempLookMatrix);
        }
        return space;
    }

    // Prevent the same event being handled twice (e.g. on touch we get a mouseUp and touchUp evt with the same timestamp)
    // private isNewEvent(timestamp: number, index: number, arr: number[]): boolean {
    //     while (arr.length <= index) arr.push(-1);
    //     if (timestamp === arr[index]) return false;
    //     arr[index] = timestamp;
    //     return true;
    // }

    private isInRect(e: { clientX: number, clientY: number }): boolean {
        if (this.context.isInXR) return true;
        const rect = this.context.domElement.getBoundingClientRect();
        const px = e.clientX;
        const py = e.clientY;
        const isInRect = px >= rect.x && px <= rect.right && py >= rect.y && py <= rect.bottom;
        if (debug && !isInRect) console.log("Not in rect", rect, px, py);
        return isInRect;

    }

    private onDown(evt: NEPointerEvent) {
        const index = evt.pointerId;

        if (this.getPointerPressed(index)) {
            // see https://linear.app/needle/issue/NE-6855#comment-3b0e3365
            if (debug)
                console.warn(`Received pointerDown event for pointerId that is already pressed: ${index}/${evt.button}`, debug ? evt : '');
            return;
        }
        if (debug) console.log(evt.pointerType, "DOWN", index, evt.button);

        if (!this.isInRect(evt)) return;
        // if (this.isMouseEventFromTouch(evt)) return;

        this.setPointerState(index, this._pointerPressed, true);
        this.setPointerState(index, this._pointerDown, true);
        this.setPointerStateT(index, this._pointerEvent, evt.source);

        while (index >= this._pointerTypes.length) this._pointerTypes.push(evt.pointerType);
        this._pointerTypes[index] = evt.pointerType;

        while (index >= this._pointerPositionDown.length) this._pointerPositionDown.push(new Vector3());
        this._pointerPositionDown[index].set(evt.clientX, evt.clientY, evt.clientZ ?? 0);
        while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2());
        this._pointerPositions[index].set(evt.clientX, evt.clientY);

        if (index >= this._pointerDownTime.length) this._pointerDownTime.push(0);
        this._pointerDownTime[index] = this.context.time.realtimeSinceStartup;

        this.updatePointerPosition(evt);

        this._pointerEventsPressed.push(evt);
        this.onDispatchEvent(evt);
    }
    // moveEvent?: Event;
    private onMove(evt: NEPointerEvent) {
        const index = evt.pointerId;

        const isDown = this.getPointerPressed(index);
        if (isDown === false && !this.isInRect(evt)) return;
        if (evt.pointerType === PointerType.Touch && !isDown) return;
        // if (this.isMouseEventFromTouch(evt)) return;
        // if (debug) console.log(evt.pointerType, "MOVE", index, "hasSpace=" + evt.space != null);

        this.updatePointerPosition(evt);
        this.setPointerStateT(index, this._pointerEvent, evt.source);
        this.onDispatchEvent(evt);
    }
    private onUp(evt: NEPointerEvent) {
        const index = evt.pointerId;
        const wasDown = this.getPointerPressed(index);
        if (!wasDown) {
            if (debug)
                console.warn(`Received pointerUp for pointerId that is not pressed: ${index}/${evt.button}`, debug ? evt : '');
            return;
        }
        // if (this.isMouseEventFromTouch(evt)) return;
        if (debug) console.log(evt.pointerType, "UP", index);

        this.setPointerState(index, this._pointerPressed, false);
        this.setPointerStateT(index, this._pointerEvent, evt.source);
        this.setPointerState(index, this._pointerUp, true);

        this.updatePointerPosition(evt);

        for (let i = this._pointerEventsPressed.length - 1; i >= 0; i--) {
            const ptr = this._pointerEventsPressed[i];
            if (ptr.pointerId === index) {
                this._pointerEventsPressed.splice(i, 1);
                break;
            }
        }

        if (!this._pointerPositionDown[index]) {
            if (debug) showBalloonWarning("[Received pointer up event without matching down event for button: " + index);
            console.warn("Received pointer up event without matching down event for button: " + index)
            return;
        }


        const lastUpTime = this._pointerUpTime[index];
        const downTime = this._pointerDownTime[index];
        const upTime = this.context.time.realtimeSinceStartup;
        const dt = upTime - downTime;
        if (index >= this._pointerUpTime.length) this._pointerUpTime.push(-99);
        this._pointerUpTime[index] = upTime;

        // If the time from down to up is less than 1 second check if it was a click
        if (dt < 1) {
            let dx = evt.clientX - this._pointerPositionDown[index].x;
            let dy = evt.clientY - this._pointerPositionDown[index].y;
            let dz = 0;
            if (evt.isSpatial && evt.clientZ != undefined) {
                dz = evt.clientZ - this._pointerPositionDown[index].z;
                // spatial clicks remapping
                dx *= 200;
                dy *= 200;
                dz *= 200;
            }

            if (Math.abs(dx) < 5 && Math.abs(dy) < 5 && Math.abs(dz) < 5) {
                this.setPointerState(index, this._pointerClick, true);
                evt.isClick = true;

                // handle double click
                const dt = upTime - lastUpTime;
                if (debug) console.log("CLICK", index, dx, dy, dz, dt)
                if (dt < this._doubleClickTimeThreshold && dt > 0) {
                    this.setPointerState(index, this._pointerDoubleClick, true);
                    evt.isDoubleClick = true;
                }
            }
        }


        this.onDispatchEvent(evt);
    }

    private updatePointerPosition(evt: NEPointerEvent) {
        const index = evt.pointerId;

        while (index >= this._pointerPositions.length) this._pointerPositions.push(new Vector2());
        while (index >= this._pointerPositionsLastFrame.length) this._pointerPositionsLastFrame.push(new Vector2());
        while (index >= this._pointerPositionsDelta.length) this._pointerPositionsDelta.push(new Vector2());

        const lf = this._pointerPositionsLastFrame[index];
        lf.copy(this._pointerPositions[index]);
        // accumulate delta (it's reset in end of frame), if we just write it here it's not correct when the browser console is open
        const delta = this._pointerPositionsDelta[index];
        let dx = evt.clientX - lf.x;
        let dy = evt.clientY - lf.y;
        // if pointer is locked, clientX and Y are not changed, but Movement is.
        if (evt.source instanceof MouseEvent || evt.source instanceof TouchEvent) {
            const source = evt.source as PointerEvent;
            if (dx === 0 && source.movementX !== 0)
                dx = source.movementX || 0;
            if (dy === 0 && source.movementY !== 0)
                dy = source.movementY || 0;
        }
        delta.x += dx;
        delta.y += dy;

        this._pointerPositions[index].x = evt.clientX;
        this._pointerPositions[index].y = evt.clientY;

        // we want to have the position 01 on the canvas for raycasting
        const px = evt.clientX;
        const py = evt.clientY;
        while (index >= this._pointerPositionsRC.length) this._pointerPositionsRC.push(new Vector2());
        const rc = this._pointerPositionsRC[index];
        rc.set(px, py);
        this.convertScreenspaceToRaycastSpace(rc);
        // console.log(this.context.alias, rc);
        // this._pointerPositionsRC[evt.button].x = (px - this.context.domX) / this.context.domWidth * 2 - 1;
        // this._pointerPositionsRC[evt.button].y = -((py - this.context.domY) / this.context.domHeight) * 2 + 1;
        // console.log(evt.button)
    }

    /** get the next free id */
    private getPointerIndex(pointerId: number) {
        // test if theres a pointer with the id
        let firstFreeIndex = -1;
        for (let i = 0; i < this._pointerIds.length; i++) {
            if (this._pointerIds[i] === pointerId) return i;
            else if (firstFreeIndex === -1 && this._pointerIds[i] === -1) {
                firstFreeIndex = i;
            }
        }
        // if not take the free slot if any
        if (firstFreeIndex !== -1) {
            this._pointerIds[firstFreeIndex] = pointerId;
            return firstFreeIndex;
        }
        if (debug) console.log("PUSH pointerId:", pointerId)
        this._pointerIds.push(pointerId);
        return this._pointerIds.length - 1;
    }

    private setPointerState(index: number, arr: boolean[], value: boolean) {
        arr[index] = value;
    }

    private setPointerStateT<T>(index: number, arr: T[], value: T) {
        // while (arr.length <= index) arr.push(null as any);
        arr[index] = value;
        return value;
    }

    private onDispatchEvent(evt: NEPointerEvent | NEKeyboardEvent) {
        const prevContext = Context.Current;
        try {
            Context.Current = this.context;
            this.dispatchEvent(evt);
        }
        finally {
            Context.Current = prevContext;
        }
    }
}

export declare type KeyCode =
    | "Tab"
    | "Enter"
    | "ShiftLeft"
    | "ShiftRight"
    | "ControlLeft"
    | "ControlRight"
    | "AltLeft"
    | "AltRight"
    | "Pause"
    | "CapsLock"
    | "Escape"
    | "Space"
    | "PageUp"
    | "PageDown"
    | "End"
    | "Home"
    | "ArrowLeft"
    | "ArrowUp"
    | "ArrowRight"
    | "ArrowDown"
    | "Insert"
    | "Delete"
    | "Digit0"
    | "Digit1"
    | "Digit2"
    | "Digit3"
    | "Digit4"
    | "Digit5"
    | "Digit6"
    | "Digit7"
    | "Digit8"
    | "Digit9"
    | "KeyA"
    | "KeyB"
    | "KeyC"
    | "KeyD"
    | "KeyE"
    | "KeyF"
    | "KeyG"
    | "KeyH"
    | "KeyI"
    | "KeyJ"
    | "KeyK"
    | "KeyL"
    | "KeyM"
    | "KeyN"
    | "KeyO"
    | "KeyP"
    | "KeyQ"
    | "KeyR"
    | "KeyS"
    | "KeyT"
    | "KeyU"
    | "KeyV"
    | "KeyW"
    | "KeyX"
    | "KeyY"
    | "KeyZ"
    | "Select"
    | "Numpad0"
    | "Numpad1"
    | "Numpad2"
    | "Numpad3"
    | "Numpad4"
    | "Numpad5"
    | "Numpad6"
    | "Numpad7"
    | "Numpad8"
    | "Numpad9"
    | "Multiply"
    | "Add"
    | "Subtract"
    | "Decimal"
    | "Divide"
    | "F1"
    | "F2"
    | "F3"
    | "F4"
    | "F5"
    | "F6"
    | "F7"
    | "F8"
    | "F9"
    | "F10"
    | "F11"
    | "F12";

// KEY_1 = 49,
// KEY_2 = 50,
// KEY_3 = 51,
// KEY_4 = 52,
// KEY_5 = 53,
// KEY_6 = 54,
// KEY_7 = 55,
// KEY_8 = 56,
// KEY_9 = 57,
// KEY_A = 65,
// KEY_B = 66,
// KEY_C = 67,
// KEY_D = "d",
// KEY_E = 69,
// KEY_F = 70,
// KEY_G = 71,
// KEY_H = 72,
// KEY_I = 73,
// KEY_J = 74,
// KEY_K = 75,
// KEY_L = 76,
// KEY_M = 77,
// KEY_N = 78,
// KEY_O = 79,
// KEY_P = 80,
// KEY_Q = 81,
// KEY_R = 82,
// KEY_S = 83,
// KEY_T = 84,
// KEY_U = 85,
// KEY_V = 86,
// KEY_W = 87,
// KEY_X = 88,
// KEY_Y = 89,
// KEY_Z = 90,
// LEFT_META = 91,
// RIGHT_META = 92,
// SELECT = 93,
// NUMPAD_0 = 96,
// NUMPAD_1 = 97,
// NUMPAD_2 = 98,
// NUMPAD_3 = 99,
// NUMPAD_4 = 100,
// NUMPAD_5 = 101,
// NUMPAD_6 = 102,
// NUMPAD_7 = 103,
// NUMPAD_8 = 104,
// NUMPAD_9 = 105,
// MULTIPLY = 106,
// ADD = 107,
// SUBTRACT = 109,
// DECIMAL = 110,
// DIVIDE = 111,
// F1 = 112,
// F2 = 113,
// F3 = 114,
// F4 = 115,
// F5 = 116,
// F6 = 117,
// F7 = 118,
// F8 = 119,
// F9 = 120,
// F10 = 121,
// F11 = 122,
// F12 = 123,
// NUM_LOCK = 144,
// SCROLL_LOCK = 145,
// SEMICOLON = 186,
// EQUALS = 187,
// COMMA = 188,
// DASH = 189,
// PERIOD = 190,
// FORWARD_SLASH = 191,
// GRAVE_ACCENT = 192,
// OPEN_BRACKET = 219,
// BACK_SLASH = 220,
// CLOSE_BRACKET = 221,
// SINGLE_QUOTE = 222