import { Camera as Camera3, Object3D, PerspectiveCamera, Ray, Vector2, Vector3, Vector3Like } from "three";
import { OrbitControls as ThreeOrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

import { isDevEnvironment } from "../engine/debug/index.js";
import { fitCamera, FitCameraOptions } from "../engine/engine_camera.fit.js";
import { setCameraController } from "../engine/engine_camera.js";
import { Gizmos } from "../engine/engine_gizmos.js";
import { InputEventQueue, NEPointerEvent } from "../engine/engine_input.js";
import { Mathf } from "../engine/engine_math.js";
import { IRaycastOptions, RaycastOptions } from "../engine/engine_physics.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { getTempVector, getWorldPosition } from "../engine/engine_three_utils.js";
import type { ICameraController } from "../engine/engine_types.js";
import { DeviceUtilities, getParam } from "../engine/engine_utils.js";
import { NeedleEngineWebComponent } from "../engine/webcomponents/needle-engine.js";
import { Camera } from "./Camera.js";
import { Behaviour, GameObject } from "./Component.js";
import { SyncedTransform } from "./SyncedTransform.js";
import { type AfterHandleInputEvent, EventSystem, EventSystemEvents } from "./ui/EventSystem.js";
import { tryGetUIComponent } from "./ui/Utils.js";
import { LookAtConstraint } from "./LookAtConstraint.js";


const debug = getParam("debugorbit");
const freeCam = getParam("freecam");
const debugCameraFit = getParam("debugcamerafit");
const smoothcam = getParam("smoothcam");

const disabledKeys = { LEFT: "", UP: "", RIGHT: "", BOTTOM: "" };
let defaultKeys: any = undefined;

export enum OrbitControlsEventsType {
    /** Invoked with a CameraTargetReachedEvent */
    CameraTargetReached = "target-reached",
}
export class CameraTargetReachedEvent extends CustomEvent<{ controls: OrbitControls, type: "camera" | "lookat" }> {
    constructor(ctrls: OrbitControls, type: "camera" | "lookat") {
        super(OrbitControlsEventsType.CameraTargetReached, {
            detail: {
                controls: ctrls,
                type: type,
            }
        });
    }
}


declare module 'three/examples/jsm/controls/OrbitControls.js' {
    export interface OrbitControls {
        _sphericalDelta: import("three").Spherical,
        _rotateLeft: (angleInRadians: number) => void;
        _rotateUp: (angleInRadians: number) => void;
        _pan: (dx: number, dy: number) => void;
        _dollyIn: (dollyScale: number) => void;
        _dollyOut: (dollyScale: number) => void;
    }

    export interface OrbitControlsEventMap {
        endMovement: Event;
    }
}

/**
 * [OrbitControls](https://engine.needle.tools/docs/api/OrbitControls) provides interactive camera control using three.js OrbitControls.  
 * Users can rotate, pan, and zoom the camera to explore 3D scenes.  
 *
 * **Features:**  
 * - Rotation around a target point (orbit)
 * - Panning to move the view
 * - Zooming via scroll or pinch
 * - Auto-rotation for showcases
 * - Configurable angle and distance limits
 * - Smooth damping for natural feel  
 * 
 * ![](https://cloud.needle.tools/-/media/ylC34hrC3srwyzGNhFRbEQ.gif)  
 *
 * **Access underlying controls:**  
 * - `controls` - The three.js OrbitControls instance
 * - `controllerObject` - The object being controlled (usually the camera)
 *
 * **Debug options:**  
 * - `?debugorbit` - Log orbit control events
 * - `?freecam` - Enable unrestricted camera movement
 *
 * @example Basic setup
 * ```ts
 * const orbitControls = camera.getComponent(OrbitControls);
 * orbitControls.autoRotate = true;
 * orbitControls.autoRotateSpeed = 2;
 * ```
 *
 * @example Set look-at target
 * ```ts
 * orbitControls.setLookTargetPosition(new Vector3(0, 1, 0), true);
 * // Or move both camera and target
 * orbitControls.setCameraTargetPosition(new Vector3(5, 2, 5), new Vector3(0, 0, 0));
 * ```
 *
 * @summary Camera controller using three.js OrbitControls
 * @category Camera and Controls
 * @group Components
 * @see {@link SmoothFollow} for smooth camera following
 * @see {@link Camera} for camera configuration
 * @link https://threejs.org/docs/#examples/en/controls/OrbitControls
 * @link https://engine.needle.tools/samples/panorama-controls alternative controls in samples
 */
export class OrbitControls extends Behaviour implements ICameraController {

    /**
     * @inheritdoc
     */
    get isCameraController(): boolean {
        return true;
    }

    /** The underlying three.js OrbitControls.   
     * See {@link https://threejs.org/docs/#examples/en/controls/OrbitControls}
     * @returns {@type ThreeOrbitControls | null}
    */
    public get controls() {
        return this._controls;
    }

    /** The object being controlled by the OrbitControls (usually the camera)  
     * See {@link https://threejs.org/docs/#examples/en/controls/OrbitControls.object}
     * @returns {@type Object3D | null}
    */
    public get controllerObject(): Object3D | null {
        return this._cameraObject;
    }

    /** Register callback when user starts interacting with the orbit controls */
    public onStartInteraction(callback: Function) {
        this.controls?.addEventListener("start", callback as any);
    }

    /** When enabled OrbitControls will automatically raycast find a look at target in start 
     * @default true
    */
    @serializable()
    autoTarget: boolean = true;
    /** When enabled the scene will be automatically fitted into the camera view in onEnable 
     * @default false
    */
    @serializable()
    autoFit: boolean = false;

    /** When enabled the camera can be rotated 
     * @default true
    */
    @serializable()
    enableRotate: boolean = true;
    /** When enabled the camera will rotate automatically 
     * @default false
    */
    @serializable()
    autoRotate: boolean = false;
    /** The speed at which the camera will rotate automatically. Will only be used when `autoRotate` is enabled 
     * @default 1.0
    */
    @serializable()
    autoRotateSpeed: number = 1.0;
    /** The minimum azimuth angle in radians */
    @serializable()
    minAzimuthAngle: number = Infinity;
    /** The maximum azimuth angle in radians */
    @serializable()
    maxAzimuthAngle: number = Infinity;
    /** The minimum polar angle in radians
     * @default 0
     */
    @serializable()
    minPolarAngle: number = 0;
    /** The maximum polar angle in radians
     * @default Math.PI
     */
    @serializable()
    maxPolarAngle: number = Math.PI;

    /** When enabled the camera can be moved using keyboard keys. The keys are defined in the `controls.keys` property
     * @default false
     */
    @serializable()
    enableKeys: boolean = false;
    /** When enabled the camera movement will be damped 
     * @default true
    */
    @serializable()
    enableDamping: boolean = true;
    /** The damping factor for the camera movement. For more information see the [three.js documentation](https://threejs.org/docs/#examples/en/controls/OrbitControls.dampingFactor)
     * @default 0.1
     */
    @serializable()
    dampingFactor: number = 0.1;

    /** When enabled the camera can be zoomed
     * @default true
    */
    @serializable()
    enableZoom: boolean = true;
    /** The minimum zoom level
     * @default 0
     */
    @serializable()
    minZoom: number = 0;
    /** The maximum zoom level
     * @default Infinity
     */
    @serializable()
    maxZoom: number = Infinity;

    /**
     * Sets the zoom speed of the OrbitControls
     * @default 1
     */
    @serializable()
    zoomSpeed: number = 1;

    /**
     * Set to true to enable zooming to the cursor position.  
     * @default false
     */
    zoomToCursor: boolean = false;

    /** When enabled the camera can be panned
     * @default true
     */
    @serializable()
    enablePan: boolean = true;
    /** Assigning an Object3D will make the camera look at this target's position.
     * The camera will orbit around this target.
     * @default null
    */
    @serializable(Object3D)
    lookAtTarget: Object3D | null = null;
    /** When enabled the camera will continuously follow the lookAtTarget's position every frame.
     * When disabled the target is only used for the initial look direction.
     * @default true
    */
    @serializable()
    lockLookAtTarget: boolean = true;

    /** The weight for the lookAtTarget interpolation
     * @default 1
    */
    @serializable()
    lookAtConstraint01: number = 1;

    // LookAtConstraint for backwards compat with old glTF files
    @serializable(LookAtConstraint)
    private lookAtConstraint?: LookAtConstraint;

    /** If true user input interrupts the camera from animating to a target 
     * @default true
    */
    @serializable()
    allowInterrupt: boolean = true;
    /** If true the camera will focus on the target when the middle mouse button is clicked */
    @serializable()
    middleClickToFocus: boolean = true;
    /** If true the camera will focus on the target when the left mouse button is double clicked
     * @default true
     */
    @serializable()
    doubleClickToFocus: boolean = true;
    /**
     * When enabled the camera will fit the scene to the camera view when the background is clicked the specified number of times within a short time
     * @default 2
     */
    @serializable()
    clickBackgroundToFitScene: number = 2;

    /**
     * This is the DOM element that the OrbitControls will listen to for input events. By default this is the renderer's canvas element.  
     * Set this to a different element to make the OrbitControls listen to that element instead.  
     */
    get targetElement(): HTMLElement | null {
        return this._controls?.domElement ?? this._targetElement;
    }
    set targetElement(value: HTMLElement | null) {
        this._targetElement = value;
        if (this._controls && this._controls.domElement !== value) {
            this._controls.disconnect();
            this._controls.domElement = value;
            this._controls.connect();
        }
    }
    private _targetElement: HTMLElement | null = null;


    /** 
     * @internal If true debug information will be logged to the console
     * @default false
     */
    debugLog: boolean = false;

    /** 
     * @deprecated use `targetLerpDuration` instead  
     * ~~The speed at which the camera target and the camera will be lerping to their destinations (if set via script or user input)~~ 
     * */
    get targetLerpSpeed() { return 5 }
    set targetLerpSpeed(v) { this.targetLerpDuration = 1 / v; }

    /** The duration in seconds it takes for the camera look ad and position lerp to reach their destination (when set via `setCameraTargetPosition` and `setLookTargetPosition`)
     * @default 1
     */
    @serializable()
    get targetLerpDuration() { return this._lookTargetLerpDuration; }
    set targetLerpDuration(v) { this._lookTargetLerpDuration = v; }
    private _lookTargetLerpDuration: number = 1;

    @serializable(Object3D)
    targetBounds: Object3D | null = null;

    /**
     * Rotate the camera left (or right) by the specified angle in radians.   
     * For positive angles the camera will rotate to the left, for negative angles it will rotate to the right.  
     * Tip: Use Mathf to convert between degrees and radians.
     * @param angleInRadians The angle in radians to rotate the camera left
     * @example
     * ```typescript
     * // Rotate the camera left by 0.1 radians
     * orbitControls.rotateLeft(0.1);
     * ```
     */
    rotateLeft(angleInRadians: number) {
        this._controls?._rotateLeft(angleInRadians);
    }

    /**
     * Rotate the camera up (or down) by the specified angle in radians.  
     * For positive angles the camera will rotate up, for negative angles it will rotate down.
     * Tip: Use Mathf to convert between degrees and radians.
     * @param angleInRadians The angle in radians to rotate the camera up
     * @example
     * ```typescript
     * // Rotate the camera up by 0.1 radians
     * orbitControls.rotateUp(0.1);
     * ```
     */
    rotateUp(angleInRadians: number) {
        this._controls?._rotateUp(angleInRadians);
    }

    /**
     * Pan the camera by the specified amount in the x and y direction in pixels.
     * @param dx The amount to pan the camera in the x direction in pixels.
     * @param dy The amount to pan the camera in the y direction in pixels.
     */
    pan(dx: number, dy: number) {
        this._controls?._pan(dx, dy);
    }

    /**
     * Zoom the camera in or out by the specified scale factor. The factor is applied to the current zoom radius / distance.
     * If the scale is greater than 0 then the camera will zoom in, if it is less than 0 then the camera will zoom out. 
     * @param scale The scale factor to zoom the camera in or out. Expected range is between -1 and 1, where 0 means no zoom.
     * @example
     * ```typescript
     * // Zoom in by 0.1
     * orbitControls.zoomIn(0.1);
     * // Zoom out by 0.1
     * orbitControls.zoomIn(-0.1);
     * ```
     */
    zoomIn(scale: number) {
        if (scale > 0) {
            this._controls?._dollyIn(1 - scale);
        }
        else if (scale < 0) {
            this._controls?._dollyOut(1 + scale);
        }
    }



    private _controls: ThreeOrbitControls | null = null;
    private _cameraObject: Object3D | null = null;

    private _lookTargetLerpActive: boolean = false;
    private _lookTargetStartPosition: Vector3 = new Vector3();
    private _lookTargetEndPosition: Vector3 = new Vector3();
    private _lookTargetLerp01: number = 0;

    private _cameraLerpActive: boolean = false;
    private _cameraStartPosition: Vector3 = new Vector3();
    private _cameraEndPosition: Vector3 = new Vector3();
    private _cameraLerp01: number = 0;
    private _cameraLerpDuration: number = 0;

    private _fovLerpActive: boolean = false;
    private _fovLerpStartValue: number = 0;
    private _fovLerpEndValue: number = 0;
    private _fovLerp01: number = 0;
    private _fovLerpDuration: number = 0;

    private _inputs: number = 0;
    private _enableTime: number = 0; // use to disable double click when double clicking on UI
    private _startedListeningToKeyEvents: boolean = false;

    private _eventSystem?: EventSystem;
    private _afterHandleInputFn?: any;
    private _camera: Camera | null = null;
    private _syncedTransform?: SyncedTransform;
    private _didSetTarget = 0;
    private _didApplyLookAtTarget = false;

    /** @internal */
    awake(): void {
        if (debug) console.debug("OrbitControls", this);

        if (this.lookAtConstraint) {
            console.warn("[OrbitControls] lookAtConstraint is deprecated, use lookTarget and lockLookAtTarget instead. This will be removed in a future version.");
            if (!this.lookAtTarget && this.lookAtConstraint.sources?.[0]) {
                this.lookAtTarget = this.lookAtConstraint.sources[0];
                this.lockLookAtTarget = this.lookAtConstraint.locked;
            }
        }

        this._didSetTarget = 0;
        this._didApplyLookAtTarget = false;
        this._startedListeningToKeyEvents = false;
        if ((this.context.domElement as NeedleEngineWebComponent).cameraControls === false) {
            this.enabled = false;
        }
    }

    /** @internal */
    start() {
        this._eventSystem = EventSystem.get(this.context) ?? undefined;
        if (this._eventSystem) {
            this._afterHandleInputFn = this.afterHandleInput.bind(this);
            this._eventSystem.addEventListener(EventSystemEvents.AfterHandleInput, this._afterHandleInputFn!);
        }
    }

    /** @internal */
    onDestroy() {
        this._controls?.dispose();
        this._eventSystem?.removeEventListener(EventSystemEvents.AfterHandleInput, this._afterHandleInputFn!);
    }

    /** @internal */
    onEnable() {
        this._didSetTarget = 0;
        this._didApplyLookAtTarget = false;
        this._enableTime = this.context.time.time;
        const cameraComponent = GameObject.getComponent(this.gameObject, Camera);
        this._camera = cameraComponent;
        let cam = cameraComponent?.threeCamera;
        if (!cam && this.gameObject instanceof PerspectiveCamera) {
            cam = this.gameObject;
        }
        if (cam) setCameraController(cam, this, true);
        if (!this._controls && cam instanceof Object3D) {
            this._cameraObject = cam;
            // Using the parent if possible to make it possible to disable input on the canvas
            // for having HTML content behind it and still receive input
            const element = this.targetElement ?? this.context.renderer.domElement;
            // HACK: workaround for three orbit controls forcing an update when being created....
            const mat = cam?.quaternion.clone();
            this._controls = new ThreeOrbitControls(cam!, element);
            cam?.quaternion.copy(mat!)
            if (defaultKeys === undefined) defaultKeys = { ...this._controls.keys };
            // set controls look point in front of the current camera by default
            // it may be overriden by the autoTarget feature
            // but if we don't do this and autoTarget is OFF then the camera will turn to look at 0 0 0 of the scene 
            const worldPosition = getWorldPosition(cam);
            const forward = this.gameObject.worldForward;
            const dist = 2.5;
            const lookAt = worldPosition.clone().sub(forward.multiplyScalar(dist));
            this._controls.target.copy(lookAt);
        }

        if (this._controls) {
            if (freeCam) {
                this.enablePan = true;
                this.enableZoom = true;
                this.middleClickToFocus = true;
                if (DeviceUtilities.isMobileDevice()) this.doubleClickToFocus = true;
            }
            this._controls.addEventListener("start", this.onControlsChangeStarted);
            this._controls.addEventListener("endMovement", this.onControlsChangeEnded);

            if (!this._startedListeningToKeyEvents && this.enableKeys) {
                this._startedListeningToKeyEvents = true;
                this._controls.listenToKeyEvents(this.context.domElement);
            }
            else {
                try {
                    this._controls.stopListenToKeyEvents();
                } catch { /** this fails if we never listened to key events... */ }
            }
        }
        this._syncedTransform = GameObject.getComponent(this.gameObject, SyncedTransform) ?? undefined;
        this.context.pre_render_callbacks.push(this.__onPreRender);

        this._activePointerEvents = [];
        this.context.input.addEventListener("pointerdown", this._onPointerDown, { queue: InputEventQueue.Early });
        this.context.input.addEventListener("pointerdown", this._onPointerDownLate, { queue: InputEventQueue.Late });
        this.context.input.addEventListener("pointerup", this._onPointerUp, { queue: InputEventQueue.Early });
        this.context.input.addEventListener("pointerup", this._onPointerUpLate, { queue: InputEventQueue.Late });
    }

    /** @internal */
    onDisable() {
        if (this._camera?.threeCamera) {
            setCameraController(this._camera.threeCamera, this, false);
        }
        if (this._controls) {
            this._controls.enabled = false;
            this._controls.autoRotate = false;
            this._controls.removeEventListener("start", this.onControlsChangeStarted);
            this._controls.removeEventListener("endMovement", this.onControlsChangeEnded);
            try {
                this._controls.stopListenToKeyEvents();
            } catch { /** this fails if we never listened to key events... */ }
            this._startedListeningToKeyEvents = false;
        }
        this._activePointerEvents.length = 0;
        this.context.input.removeEventListener("pointerdown", this._onPointerDown);
        this.context.input.removeEventListener("pointerdown", this._onPointerDownLate);
        this.context.input.removeEventListener("pointerup", this._onPointerUp);
        this.context.input.removeEventListener("pointerup", this._onPointerUpLate);
    }

    private _activePointerEvents!: NEPointerEvent[];
    private _lastTimeClickOnBackground: number = -1;
    private _clickOnBackgroundCount: number = 0;

    private _onPointerDown = (_evt: NEPointerEvent) => {
        this._activePointerEvents.push(_evt);
    }
    private _onPointerDownLate = (evt: NEPointerEvent) => {
        if (evt.used && this._controls) {
            // Disabling orbit controls here because otherwise we get a slight movement when e.g. using DragControls
            this._controls.enabled = false;
        }
    }

    private _onPointerUp = (evt: NEPointerEvent) => {
        // make sure we cleanup the active pointer events
        for (let i = this._activePointerEvents.length - 1; i >= 0; i--) {
            const registered = this._activePointerEvents[i];
            if (registered.pointerId === evt.pointerId && registered.button === evt.button) {
                this._activePointerEvents.splice(i, 1);
                break;
            }
        }

        if (this.clickBackgroundToFitScene > 0 && evt.isClick && evt.button === 0) {

            // it's possible that we didnt raycast in this frame
            if (!evt.hasRay) {
                evt.intersections.push(...this.context.physics.raycast());
            }

            if (evt.intersections.length <= 0) {
                const dt = this.context.time.time - this._lastTimeClickOnBackground;
                this._lastTimeClickOnBackground = this.context.time.time;
                if (this.clickBackgroundToFitScene <= 1 || dt < this.clickBackgroundToFitScene * .15) {
                    this._clickOnBackgroundCount += 1;
                    if (this._clickOnBackgroundCount >= this.clickBackgroundToFitScene - 1) {
                        this.autoRotate = false;
                        this.fitCamera({
                            objects: this.context.scene,
                            immediate: false,
                        });
                    }
                }
                else {
                    this._clickOnBackgroundCount = 0;
                }
            }

            if (debug) console.log(this.clickBackgroundToFitScene, evt.intersections.length, this._clickOnBackgroundCount)
        }
    };

    private _onPointerUpLate = (evt: NEPointerEvent) => {
        if (this.doubleClickToFocus && evt.isDoubleClick && !evt.used) {
            this.setTargetFromRaycast();
        }
        // Automatically update the camera focus
        // else if (!evt.used && this.autoTarget) {
        //     this.updateTargetNow();
        // }
    };

    private updateTargetNow(options?: IRaycastOptions) {
        if (debug) console.warn("OrbitControls: updateTargetNow is using raycasting to update the target immediately. This can be expensive and should be used with caution.", options);
        const ray = new Ray(this._cameraObject?.worldPosition, this._cameraObject?.worldForward.multiplyScalar(-1));
        const hits = this.context.physics.raycastFromRay(ray, options);
        const hit = hits.length > 0 ? hits[0] : undefined;
        if (hit && hit.distance > this.minZoom && hit.distance < this.maxZoom) {
            if (debug) Gizmos.DrawWireSphere(hit.point, 0.1, 0xff0000, 2);
            this._controls?.target.copy(hits[0].point);
        }
        else {
            if (debug) console.log("OrbitControls: No hit found when updating target", { hits: [...hits] });
        }
    }

    private _orbitStartAngle: number = 0;
    private _zoomStartDistance: number = 0;
    private onControlsChangeStarted = () => {
        if (debug) console.debug("OrbitControls: Change started");
        if (this._controls) {
            this._orbitStartAngle = this._controls.getAzimuthalAngle() + this._controls.getPolarAngle();
            this._zoomStartDistance = this._controls.getDistance();
        }
        if (this._syncedTransform) {
            this._syncedTransform.requestOwnership();
        }
    }
    private onControlsChangeEnded = () => {
        if (debug) console.debug("OrbitControls: Change ended", { autoTarget: this.autoTarget });
        if (this._controls) {
            if (this.autoTarget) {
                const newAngle = this._controls.getAzimuthalAngle() + this._controls.getPolarAngle();
                const deltaAngle = newAngle - this._orbitStartAngle;
                // TODO: "just zoom" probably shouldnt update the target either, unless zoomToCursor is enabled
                if (Math.abs(deltaAngle) < .01) {
                    if (debug) console.debug("OrbitControls: Update target", { deltaAngle });
                    this.updateTargetNow({ allowSlowRaycastFallback: false });
                }
                else if (debug) console.debug("OrbitControls: No target update", { deltaAngle });
            }
        }

    }

    private _shouldDisable: boolean = false;
    private afterHandleInput(evt: CustomEvent<AfterHandleInputEvent>) {
        if (evt.detail.args.pointerId === 0) {
            if (evt.detail.args.isDown) {
                if (this._controls && this._eventSystem) {
                    this._shouldDisable = this._eventSystem.hasActiveUI;
                }
            }
            else if (!evt.detail.args.isPressed || evt.detail.args.isUp) {
                this._shouldDisable = false;
            }
        }
    }

    onPausedChanged(isPaused: boolean): void {
        if (!this._controls) return;
        if (isPaused) this._controls.enabled = false;
    }


    /** @internal */
    onBeforeRender() {
        if (!this._controls) return;
        if (this._cameraObject !== this.context.mainCamera) {
            this._controls.enabled = false;
            return;
        }
        this._controls.enabled = true;

        if (this.context.input.getPointerDown(1) || this.context.input.getPointerDown(2) || this.context.input.mouseWheelChanged || (this.context.input.getPointerPressed(0) && this.context.input.getPointerPositionDelta(0)?.length() || 0 > .1)) {
            this._inputs += 1;
        }
        if (this._inputs > 0 && this.allowInterrupt) {
            // if a user has disabled rotation but enabled auto rotate we don't want to change it when we receive input
            if (this.enableRotate) {
                this.autoRotate = false;
            }
            this._cameraLerpActive = false;
            this._lookTargetLerpActive = false;
        }
        this._inputs = 0;


        if (this.autoTarget) {
            // we want to wait one frame so all matrixWorlds are updated
            // otherwise raycasting will not work correctly
            if (this._didSetTarget++ === 0) {
                const camGo = GameObject.getComponent(this.gameObject, Camera);
                if (camGo && !this.setLookTargetFromConstraint()) {
                    if (this.debugLog)
                        console.log("NO TARGET");
                    const worldPosition = getWorldPosition(camGo.threeCamera);
                    // Handle case where the camera is in 0 0 0 of the scene
                    // if the look at target is set to the camera position we can't move at all anymore
                    const distanceToCenter = Math.max(.01, worldPosition.length());
                    const forward = new Vector3(0, 0, -distanceToCenter).applyMatrix4(camGo.threeCamera.matrixWorld);
                    if (debug) Gizmos.DrawLine(worldPosition, forward, 0x5555ff, 10)
                    this.setLookTargetPosition(forward, true);
                }
                if (!this.setLookTargetFromConstraint()) {
                    const opts = new RaycastOptions();
                    // center of the screen:
                    opts.screenPoint = new Vector2(0, 0);
                    opts.lineThreshold = 0.1;
                    const hits = this.context.physics.raycast(opts);
                    if (hits.length > 0) {
                        this.setLookTargetPosition(hits[0].point, true);
                    }
                    if (debugCameraFit)
                        console.log("OrbitControls hits", ...hits);
                }
            }
        }

        const focusAtPointer = (this.middleClickToFocus && this.context.input.getPointerClicked(1));
        if (focusAtPointer) {
            this.setTargetFromRaycast();
        }

        if (this._lookTargetLerpActive || this._cameraLerpActive || this._fovLerpActive) {
            // lerp the camera
            if (this._cameraLerpActive && this._cameraObject) {
                this._cameraLerp01 += this.context.time.deltaTime / this._cameraLerpDuration;
                if (this._cameraLerp01 >= 1) {
                    this._cameraObject.position.copy(this._cameraEndPosition);
                    this._cameraLerpActive = false;
                    this.dispatchEvent(new CameraTargetReachedEvent(this, "camera"));
                }
                else {
                    const t = Mathf.easeInOutCubic(this._cameraLerp01);
                    this._cameraObject.position.lerpVectors(this._cameraStartPosition, this._cameraEndPosition, t);
                }
            }

            // lerp the look target
            if (this._lookTargetLerpActive) {
                this._lookTargetLerp01 += this.context.time.deltaTime / this._lookTargetLerpDuration;
                if (this._lookTargetLerp01 >= 1) {
                    this.lerpLookTarget(this._lookTargetEndPosition, this._lookTargetEndPosition, 1);
                    this._lookTargetLerpActive = false;
                    this.dispatchEvent(new CameraTargetReachedEvent(this, "lookat"));
                } else {
                    const t = Mathf.easeInOutCubic(this._lookTargetLerp01);
                    this.lerpLookTarget(this._lookTargetStartPosition, this._lookTargetEndPosition, t);
                }
            }

            // lerp the fov
            if (this._fovLerpActive && this._cameraObject) {
                const cam = this._cameraObject as PerspectiveCamera;
                this._fovLerp01 += this.context.time.deltaTime / this._fovLerpDuration;
                if (this._fovLerp01 >= 1) {
                    cam.fov = this._fovLerpEndValue;
                    this._fovLerpActive = false;
                } else {
                    const t = Mathf.easeInOutCubic(this._fovLerp01);
                    cam.fov = Mathf.lerp(this._fovLerpStartValue, this._fovLerpEndValue, t);
                }
                cam.updateProjectionMatrix();
            }
        }

        if (this.targetBounds) {
            // #region target bounds
            const targetVector = this._controls.target;
            const boundsCenter = this.targetBounds.worldPosition;
            const boundsHalfSize = getTempVector(this.targetBounds.worldScale).multiplyScalar(0.5);
            const min = getTempVector(boundsCenter).sub(boundsHalfSize);
            const max = getTempVector(boundsCenter).add(boundsHalfSize);
            const newTarget = getTempVector(this._controls.target).clamp(min, max);
            const duration = .1;
            if (duration <= 0) targetVector.copy(newTarget);
            else targetVector.lerp(newTarget, this.context.time.deltaTime / duration);
            if (this._lookTargetLerpActive) {
                if (duration <= 0) this._lookTargetEndPosition.copy(newTarget);
                else this._lookTargetEndPosition.lerp(newTarget, this.context.time.deltaTime / (duration * 5));
            }
            if (debug) {
                Gizmos.DrawWireBox(boundsCenter, boundsHalfSize.multiplyScalar(2), 0xffaa00);
            }
        }


        if (this._controls) {
            if (this.debugLog) this._controls.domElement = this.context.renderer.domElement;

            const viewZoomFactor = 1 / (this.context.focusRectSettings?.zoom || 1);

            this._controls.enabled = !this._shouldDisable && this._camera === this.context.mainCameraComponent && !this.context.isInXR && !this._activePointerEvents.some(e => e.used);
            this._controls.keys = this.enableKeys ? defaultKeys : disabledKeys;
            this._controls.autoRotate = this.autoRotate;
            this._controls.autoRotateSpeed = this.autoRotateSpeed;
            this._controls.enableZoom = this.enableZoom;
            this._controls.zoomSpeed = this.zoomSpeed;
            this._controls.zoomToCursor = this.zoomToCursor;
            this._controls.enableDamping = this.enableDamping;
            this._controls.dampingFactor = this.dampingFactor;
            this._controls.enablePan = this.enablePan;
            this._controls.panSpeed = viewZoomFactor;
            this._controls.enableRotate = this.enableRotate;
            this._controls.minAzimuthAngle = this.minAzimuthAngle;
            this._controls.maxAzimuthAngle = this.maxAzimuthAngle;
            this._controls.minPolarAngle = this.minPolarAngle;
            this._controls.maxPolarAngle = this.maxPolarAngle;
            // set the min/max zoom if it's not a free cam
            if (!freeCam) {
                if (this._camera?.threeCamera?.type === "PerspectiveCamera") {
                    this._controls.minDistance = this.minZoom;
                    this._controls.maxDistance = this.maxZoom;
                    this._controls.minZoom = 0;
                    this._controls.maxZoom = Infinity;
                }
                else {
                    this._controls.minDistance = 0;
                    this._controls.maxDistance = Infinity;
                    this._controls.minZoom = this.minZoom;
                    this._controls.maxZoom = this.maxZoom;
                }
            }

            if (typeof smoothcam === "number" || smoothcam === true) {
                this._controls.enableDamping = true;
                const factor = typeof smoothcam === "number" ? smoothcam : .99;
                this._controls.dampingFactor = Math.max(.001, 1 - Math.min(1, factor));
            }

            if (!this.allowInterrupt) {
                if (this._lookTargetLerpActive) {
                    this._controls.enablePan = false;
                }
                if (this._cameraLerpActive) {
                    this._controls.enableRotate = false;
                    this._controls.autoRotate = false;
                }
                if (this._lookTargetLerpActive || this._cameraLerpActive) {
                    this._controls.enableZoom = false;
                }
            }

            // this._controls.zoomToCursor = this.zoomToCursor;
            if (!this.context.isInXR) {
                if (!freeCam && this.lookAtTarget && !this._lookTargetLerpActive) {
                    if (this.lockLookAtTarget) {
                        this.setLookTargetFromConstraint(this.lookAtConstraint01);
                    }
                    else if (!this._didApplyLookAtTarget) {
                        this._didApplyLookAtTarget = true;
                        this.setLookTargetFromConstraint(1);
                    }
                }
                this._controls.update(this.context.time.deltaTime);

                if (debug) {
                    Gizmos.DrawWireSphere(this._controls.target, 0.1, 0x00ff00);
                }
            }
        }
    }

    private __onPreRender = () => {

        // We call this only once when the camera becomes active and use the engine pre_render_callbacks because they are run 
        // after all scripts have been executed
        const index = this.context.pre_render_callbacks.indexOf(this.__onPreRender);
        if (index >= 0) {
            this.context.pre_render_callbacks.splice(index, 1);
        }

        if (this.autoFit) {
            // we don't want to autofit again if the component is disabled and re-enabled
            this.autoFit = false;
            this.fitCamera({
                centerCamera: "y",
                immediate: true,
                objects: this.scene.children,
            })
        }
    }


    /** 
     * Sets camera target position and look direction using a raycast in forward direction of the object.  
     * 
     * @param source The object to raycast from. If a camera is passed in the camera position will be used as the source.
     * @param immediateOrDuration If true the camera target will move immediately to the new position, otherwise it will lerp. If a number is passed in it will be used as the duration of the lerp.
     * 
     * This is useful for example if you want to align your camera with an object in your scene (or another camera). Simply pass in this other camera object
     * @returns true if the target was set successfully
     */
    public setCameraAndLookTarget(source: Object3D | Camera, immediateOrDuration: number | boolean = false): boolean {
        if (!source) {
            if (isDevEnvironment() || debug) console.warn("[OrbitControls] setCameraAndLookTarget target is null");
            return false;
        }
        if (!(source instanceof Object3D) && !(source instanceof Camera)) {
            if (isDevEnvironment() || debug) console.warn("[OrbitControls] setCameraAndLookTarget target is not an Object3D or Camera");
            return false;
        }

        if (source instanceof Camera) {
            source = source.gameObject;
        }
        const worldPosition = source.worldPosition;
        const forward = source.worldForward;

        // The camera render direction is -Z. When a camera is passed in then we'll take the view direction OR the object Z forward direction.
        if (source instanceof Camera3) {
            if (debug) console.debug("[OrbitControls] setCameraAndLookTarget flip forward direction for camera");
            forward.multiplyScalar(-1);
        }

        const ray = new Ray(worldPosition, forward);

        if (debug) Gizmos.DrawRay(ray.origin, ray.direction, 0xff0000, 10);

        if (!this.setTargetFromRaycast(ray, immediateOrDuration)) {
            this.setLookTargetPosition(ray.at(2, getTempVector()), immediateOrDuration);
        }

        this.setCameraTargetPosition(worldPosition, immediateOrDuration);
        return true;
    }

    /** Moves the camera to position smoothly. 
     * @param position The position in local space of the controllerObject to move the camera to. If null the camera will stop lerping to the target.
     * @param immediateOrDuration If true the camera will move immediately to the new position, otherwise it will lerp. If a number is passed in it will be used as the duration of the lerp.
    */
    public setCameraTargetPosition(position?: Object3D | Vector3Like | null, immediateOrDuration: boolean | number = false) {
        if (!position) return;
        if (position instanceof Object3D) {
            position = getWorldPosition(position) as Vector3;
        }
        if (!this._cameraEndPosition) this._cameraEndPosition = new Vector3();
        this._cameraEndPosition.copy(position);
        if (immediateOrDuration === true) {
            this._cameraLerpActive = false;
            if (this._cameraObject) {
                this._cameraObject.position.copy(this._cameraEndPosition);
            }
        }
        else if (this._cameraObject) {
            this._cameraLerpActive = true;
            this._cameraLerp01 = 0;
            this._cameraStartPosition.copy(this._cameraObject?.position);
            if (typeof immediateOrDuration === "number") {
                this._cameraLerpDuration = immediateOrDuration;
            }
            else this._cameraLerpDuration = this.targetLerpDuration;
        }
    }
    // public setCameraTargetRotation(rotation: Vector3 | Euler | Quaternion, immediateOrDuration: boolean | number = false): void {
    //     if (!this._cameraObject) return;

    //     if (typeof immediateOrDuration === "boolean") immediateOrDuration = immediateOrDuration ? 0 : this.targetLerpDuration;

    //     const ray = new Ray(this._cameraObject.worldPosition, getTempVector(0, 0, 1));

    //     // if the camera is in the middle of lerping we use the end position for the raycast
    //     if (immediateOrDuration > 0 && this._cameraEndPosition && this._cameraLerpActive) {
    //         ray.origin = getTempVector(this._cameraEndPosition)
    //     }

    //     if (rotation instanceof Vector3) {
    //         rotation = new Euler().setFromVector3(rotation);
    //     }
    //     if (rotation instanceof Euler) {
    //         rotation = new Quaternion().setFromEuler(rotation);
    //     }

    //     ray.direction.applyQuaternion(rotation);
    //     ray.direction.multiplyScalar(-1);

    //     const hits = this.context.physics.raycastFromRay(ray);

    //     if (hits.length > 0) {
    //         this.setCameraTargetPosition(hits[0].point, immediateOrDuration);
    //     }
    //     else {
    //         this.setLookTargetPosition(ray.at(2, getTempVector()));
    //     }
    // }

    /** True while the camera position is being lerped */
    get cameraLerpActive() { return this._cameraLerpActive; }
    /** Call to stop camera position lerping */
    public stopCameraLerp() {
        this._cameraLerpActive = false;
    }

    public setFieldOfView(fov: number | undefined, immediateOrDuration: boolean | number = false) {
        if (!this._controls) return;
        if (typeof fov !== "number") return;
        const cam = this._camera?.threeCamera as PerspectiveCamera;
        if (!cam) return;
        if (immediateOrDuration === true) {
            cam.fov = fov;
        }
        else {
            this._fovLerpActive = true;
            this._fovLerp01 = 0;
            this._fovLerpStartValue = cam.fov;
            this._fovLerpEndValue = fov;
            if (typeof immediateOrDuration === "number") {
                this._fovLerpDuration = immediateOrDuration;
            }
            else this._fovLerpDuration = this.targetLerpDuration;
        }

        // if (this.context.mainCameraComponent) this.context.mainCameraComponent.fieldOfView = fov;
    }

    /** Moves the camera look-at target to a position smoothly. 
     * @param position The position in world space to move the camera target to. If null the camera will stop lerping to the target.
     * @param immediateOrDuration If true the camera target will move immediately to the new position, otherwise it will lerp. If a number is passed in it will be used as the duration of the lerp.
    */
    public setLookTargetPosition(position: Object3D | Vector3Like | null = null, immediateOrDuration: boolean | number = false) {
        if (!this._controls) return;
        if (!position) return
        if (position instanceof Object3D) {
            position = getWorldPosition(position) as Vector3;
        }
        this._lookTargetEndPosition.copy(position);

        // if a user calls setLookTargetPosition we don't want to perform autoTarget in onBeforeRender (and override whatever the user set here)
        this._didSetTarget++;

        if (debug) {
            console.warn("OrbitControls: setLookTargetPosition", position, immediateOrDuration);
            Gizmos.DrawWireSphere(this._lookTargetEndPosition, .2, 0xff0000, 2);
        }

        if (immediateOrDuration === true) {
            this.lerpLookTarget(this._lookTargetEndPosition, this._lookTargetEndPosition, 1);
        }
        else {
            this._lookTargetLerpActive = true;
            this._lookTargetLerp01 = 0;
            this._lookTargetStartPosition.copy(this._controls.target);
            if (typeof immediateOrDuration === "number") {
                this._lookTargetLerpDuration = immediateOrDuration;
            }
            else this._lookTargetLerpDuration = this.targetLerpDuration;
        }
    }
    /** True while the camera look target is being lerped */
    get lookTargetLerpActive() { return this._lookTargetLerpActive; }
    /** Call to stop camera look target lerping */
    public stopLookTargetLerp() {
        this._lookTargetLerpActive = false;
    }

    /** Sets the look at target from the assigned lookAtTarget Object3D
     * @param t The interpolation factor between the current look at target and the new target
    */
    private setLookTargetFromConstraint(t: number = 1): boolean {
        if (!this._controls) return false;
        if (!this.lookAtTarget) return false;
        this.lookAtTarget.getWorldPosition(this._lookTargetEndPosition);
        this.lerpLookTarget(this._controls.target, this._lookTargetEndPosition, t);
        return true;
    }

    private lerpLookTarget(start: Vector3, position: Vector3, t: number) {
        if (!this._controls) return;
        if (t >= 1) this._controls.target.copy(position);
        else this._controls.target.lerpVectors(start, position, t);
        if (this.lookAtTarget && this.lockLookAtTarget) this.lookAtTarget.worldPosition = this._controls.target;
    }

    private setTargetFromRaycast(ray?: Ray, immediateOrDuration: number | boolean = false): boolean {
        if (!this.controls) return false;
        const rc = ray ? this.context.physics.raycastFromRay(ray) : this.context.physics.raycast();
        for (const hit of rc) {
            if (hit.distance > 0 && GameObject.isActiveInHierarchy(hit.object)) {

                const uiComponent = tryGetUIComponent(hit.object);
                if (uiComponent) {
                    const canvas = uiComponent.canvas;
                    if (canvas?.screenspace) {
                        break;
                    }
                }
                this.setLookTargetPosition(hit.point, immediateOrDuration);
                return true;
            }
        }
        return false;
    }

    // Adapted from https://discourse.threejs.org/t/camera-zoom-to-fit-object/936/24
    // Slower but better implementation that takes bones and exact vertex positions into account: https://github.com/google/model-viewer/blob/04e900c5027de8c5306fe1fe9627707f42811b05/packages/model-viewer/src/three-components/ModelScene.ts#L321

    /** 
     * Fits the camera to show the objects provided (defaults to the scene if no objects are passed in) 
     * @param options The options for fitting the camera. Use to provide objects to fit to, fit direction and size and other settings.
    */
    fitCamera(options?: OrbitFitCameraOptions);
    /** @deprecated Use fitCamera(options) */
    fitCamera(objects?: Object3D | Array<Object3D>, options?: Omit<OrbitFitCameraOptions, "objects">);
    fitCamera(objectsOrOptions?: Object3D | Array<Object3D> | OrbitFitCameraOptions, options?: OrbitFitCameraOptions): void {


        let objects: Object3D | Array<Object3D> | undefined = undefined;
        // If the user passed in an array as first argument
        if (Array.isArray(objectsOrOptions)) {
            objects = objectsOrOptions;
        }
        // If the user passed in an object as first argument
        else if (objectsOrOptions && "type" in objectsOrOptions) {
            objects = objectsOrOptions;
        }
        // If the user passed in an object as first argument and options as second argument
        else if (objectsOrOptions && typeof objectsOrOptions === "object") {
            if (!(objectsOrOptions instanceof Object3D) && !Array.isArray(objectsOrOptions)) {
                options = objectsOrOptions;
                objects = options.objects;
            }
        }
        // Ensure objects are setup correctly
        if (objects && !Array.isArray(objects)) {
            objects = [objects];
        }
        if (!Array.isArray(objects) || objects && objects.length <= 0) {
            objects = this.context.scene.children;
        }

        // Make sure there's anything to fit to
        if (!Array.isArray(objects) || objects.length <= 0) {
            console.warn("No objects to fit camera to...");
            return;
        }


        const res = fitCamera({
            objects: [...objects],
            ...options,
            autoApply: false,
            context: this.context,
            camera: this._cameraObject as Camera3,
            currentZoom: this._controls?.getDistance() || undefined,
            minZoom: this.minZoom,
            maxZoom: this.maxZoom,
        });
        if (!res) return;
        this.setLookTargetPosition(res.lookAt, options?.immediate || false);
        this.setCameraTargetPosition(res.position, options?.immediate || false);
        this.setFieldOfView(options?.fov, options?.immediate || false);
        this.onBeforeRender();
    }

    private _haveAttachedKeyboardEvents: boolean = false;
}

type OrbitFitCameraOptions = FitCameraOptions & {
    immediate?: boolean,
}
