import { Color, CubeUVReflectionMapping, EquirectangularReflectionMapping, Euler, Frustum, Matrix4, OrthographicCamera, PerspectiveCamera, Ray, Vector3 } from "three";
import { Texture } from "three";

import { showBalloonMessage } from "../engine/debug/index.js";
import { Gizmos } from "../engine/engine_gizmos.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { Context } from "../engine/engine_setup.js";
import { RenderTexture } from "../engine/engine_texture.js";
import { getTempColor, getWorldPosition } from "../engine/engine_three_utils.js";
import type { ICamera } from "../engine/engine_types.js"
import { DeviceUtilities, getParam } from "../engine/engine_utils.js";
import { NeedleXREventArgs } from "../engine/engine_xr.js";
import { RGBAColor } from "../engine/js-extensions/index.js";
import { Behaviour, GameObject } from "./Component.js";
import { OrbitControls } from "./OrbitControls.js";

/**  
 * The ClearFlags enum is used to determine how the camera clears the background 
 */
export enum ClearFlags {
    /** Don't clear the background */
    None = 0,
    /** Clear the background with a skybox */
    Skybox = 1,
    /** Clear the background with a solid color. The alpha channel of the color determines the transparency */
    SolidColor = 2,
    /** Clear the background with a transparent color */
    Uninitialized = 4,
}

const debug = getParam("debugcam");
const debugscreenpointtoray = getParam("debugscreenpointtoray");

/**
 * [Camera](https://engine.needle.tools/docs/api/Camera) handles rendering from a specific viewpoint in the scene.  
 * Supports both perspective and orthographic cameras with various rendering options.  
 * Internally uses three.js {@link PerspectiveCamera} or {@link OrthographicCamera}.    
 * 
 * ![](https://cloud.needle.tools/-/media/UU96_SJNXdVjaAvPNW3kZA.webp)
 *
 * **Background clearing:**  
 * Control how the camera clears the background using `clearFlags`:  
 * - `Skybox` - Use scene skybox/environment
 * - `SolidColor` - Clear with `backgroundColor`
 * - `None` - Don't clear (for layered rendering)
 *
 * **Render targets:**  
 * Set `targetTexture` to a {@link RenderTexture} to render to a texture  
 * instead of the screen (useful for mirrors, portals, minimaps).  
 * 
 * [![](https://cloud.needle.tools/-/media/W4tYZuJVVJFVp7NTaHPOnA.gif)](https://engine.needle.tools/samples/movie-set)
 *
 * @example Configure camera settings
 * ```ts
 * const cam = this.context.mainCameraComponent;
 * cam.fieldOfView = 60;
 * cam.nearClipPlane = 0.1;
 * cam.farClipPlane = 1000;
 * cam.clearFlags = ClearFlags.SolidColor;
 * cam.backgroundColor = new RGBAColor(0.1, 0.1, 0.2, 1);
 * ```
 *
 * - Example: https://engine.needle.tools/samples/multiple-cameras
 *
 * @summary Rendering scenes from a specific viewpoint
 * @category Camera and Controls
 * @group Components
 * @see {@link OrbitControls} for camera interaction
 * @see {@link RenderTexture} for off-screen rendering
 * @see {@link ClearFlags} for background clearing options
 * @link https://engine.needle.tools/samples/movie-set/
 */
export class Camera extends Behaviour implements ICamera {

    /**
     * Returns whether this component is a camera
     * @returns {boolean} Always returns true
     */
    get isCamera() {
        return true;
    }

    /** 
     * Gets or sets the camera's aspect ratio (width divided by height).
     * For perspective cameras, this directly affects the camera's projection matrix.
     * When set, automatically updates the projection matrix.
     */
    get aspect(): number {
        if (this._cam instanceof PerspectiveCamera) return this._cam.aspect;
        return (this.context.domWidth / this.context.domHeight);
    }
    @serializable()
    set aspect(value: number) {
        if (this._cam instanceof PerspectiveCamera) {
            if (this._cam.aspect !== value) {
                this._cam.aspect = value;
                this._cam.updateProjectionMatrix();
            }
        }
    }

    /**
     * Gets or sets the camera's field of view in degrees for perspective cameras.
     * When set, automatically updates the projection matrix.
     */
    get fieldOfView(): number | undefined {
        if (this._cam instanceof PerspectiveCamera) {
            return this._cam.fov;
        }
        return this._fov;
    }
    @serializable()
    set fieldOfView(val: number | undefined) {
        const changed = this.fieldOfView != val;
        this._fov = val;
        if (changed && this._cam) {
            if (this._cam instanceof PerspectiveCamera) {
                if (this._fov === undefined) {
                    console.warn("Can not set undefined fov on PerspectiveCamera");
                    return;
                }
                this._cam.fov = this._fov;
                this._cam.updateProjectionMatrix();
            }
        }
    }

    /**
     * Gets or sets the camera's near clipping plane distance.
     * Objects closer than this distance won't be rendered.
     * When set, automatically updates the projection matrix.
     */
    get nearClipPlane(): number { return this._nearClipPlane; }
    @serializable()
    set nearClipPlane(val: number) {
        const changed = this._nearClipPlane != val;
        this._nearClipPlane = val;
        if (this._cam && (changed || this._cam.near != val)) {
            this._cam.near = val;
            this._cam.updateProjectionMatrix();
        }
    }
    private _nearClipPlane: number = 0.1;

    /**
     * Gets or sets the camera's far clipping plane distance.
     * Objects farther than this distance won't be rendered.
     * When set, automatically updates the projection matrix.
     */
    get farClipPlane(): number { return this._farClipPlane; }
    @serializable()
    set farClipPlane(val: number) {
        const changed = this._farClipPlane != val;
        this._farClipPlane = val;
        if (this._cam && (changed || this._cam.far != val)) {
            this._cam.far = val;
            this._cam.updateProjectionMatrix();
        }
    }
    private _farClipPlane: number = 1000;

    /**
     * Applies both the camera's near and far clipping planes and updates the projection matrix.
     * This ensures rendering occurs only within the specified distance range.
     */
    applyClippingPlane() {
        if (this._cam) {
            this._cam.near = this._nearClipPlane;
            this._cam.far = this._farClipPlane;
            this._cam.updateProjectionMatrix();
        }
    }

    /**
     * Gets or sets the camera's clear flags that determine how the background is rendered.
     * Options include skybox, solid color, or transparent background.
     */
    @serializable()
    public get clearFlags(): ClearFlags {
        return this._clearFlags;
    }
    public set clearFlags(val: ClearFlags | "skybox" | "solidcolor") {

        if (typeof val === "string") {
            switch (val) {
                case "skybox":
                    val = ClearFlags.Skybox;
                    break;
                case "solidcolor":
                    val = ClearFlags.SolidColor;
                    break;
                default:
                    val = ClearFlags.None;
                    break;
            }
        }

        if (val === this._clearFlags) return;
        this._clearFlags = val;
        this.applyClearFlagsIfIsActiveCamera();
    }

    /**
     * Determines if the camera should use orthographic projection instead of perspective.
     */
    @serializable()
    public orthographic: boolean = false;

    /**
     * The size of the orthographic camera's view volume when in orthographic mode.
     * Larger values show more of the scene.
     */
    @serializable()
    public orthographicSize: number = 5;

    /**
     * Controls the transparency level of the camera background in AR mode on supported devices.
     * Value from 0 (fully transparent) to 1 (fully opaque).
     */
    @serializable()
    public ARBackgroundAlpha: number = 0;

    /** 
     * Gets or sets the layers mask that determines which objects this camera will render.
     * Uses the {@link https://threejs.org/docs/#api/en/core/Layers.mask|three.js layers mask} convention.
     */
    @serializable()
    public set cullingMask(val: number) {
        this._cullingMask = val;
        if (this._cam) {
            this._cam.layers.mask = val;
        }
    }
    public get cullingMask(): number {
        if (this._cam) return this._cam.layers.mask;
        return this._cullingMask;
    }
    private _cullingMask: number = 0xffffffff;

    /**
     * Sets only a specific layer to be active for rendering by this camera.
     * This is equivalent to calling `layers.set(val)` on the three.js camera object.
     * @param val The layer index to set active
     */
    public set cullingLayer(val: number) {
        this.cullingMask = (1 << val | 0) >>> 0;
    }

    /**
     * Gets or sets the blurriness of the skybox background.
     * Values range from 0 (sharp) to 1 (maximum blur).
     */
    @serializable()
    public set backgroundBlurriness(val: number | undefined) {
        if (val === this._backgroundBlurriness) return;
        if (val === undefined)
            this._backgroundBlurriness = undefined;
        else
            this._backgroundBlurriness = Math.min(Math.max(val, 0), 1);
        this.applyClearFlagsIfIsActiveCamera();
    }
    public get backgroundBlurriness(): number | undefined {
        return this._backgroundBlurriness;
    }
    private _backgroundBlurriness?: number = undefined;

    /**
     * Gets or sets the intensity of the skybox background.
     * Values range from 0 (dark) to 10 (very bright).
     */
    @serializable()
    public set backgroundIntensity(val: number | undefined) {
        if (val === this._backgroundIntensity) return;
        if (val === undefined)
            this._backgroundIntensity = undefined;
        else
            this._backgroundIntensity = Math.min(Math.max(val, 0), 10);
        this.applyClearFlagsIfIsActiveCamera();
    }
    public get backgroundIntensity(): number | undefined {
        return this._backgroundIntensity;
    }
    private _backgroundIntensity?: number = undefined;

    /**
     * Gets or sets the rotation of the skybox background.
     * Controls the orientation of the environment map.
     */
    @serializable(Euler)
    public set backgroundRotation(val: Euler | undefined) {
        if (val === this._backgroundRotation) return;
        if (val === undefined)
            this._backgroundRotation = undefined;
        else
            this._backgroundRotation = val;
        this.applyClearFlagsIfIsActiveCamera();
    }
    public get backgroundRotation(): Euler | undefined {
        return this._backgroundRotation;
    }
    private _backgroundRotation?: Euler = undefined;

    /**
     * Gets or sets the intensity of the environment lighting.
     * Controls how strongly the environment map affects scene lighting.
     */
    @serializable()
    public set environmentIntensity(val: number | undefined) {
        this._environmentIntensity = val;
    }
    public get environmentIntensity(): number | undefined {
        return this._environmentIntensity;
    }
    private _environmentIntensity?: number = undefined;

    /**
     * Gets or sets the background color of the camera when {@link ClearFlags} is set to {@link ClearFlags.SolidColor}.
     * The alpha component controls transparency.
     */
    @serializable(RGBAColor)
    public get backgroundColor(): RGBAColor | null {
        return this._backgroundColor ?? null;
    }
    public set backgroundColor(val: RGBAColor | Color | null) {
        if (!val) return;
        if (!this._backgroundColor) {
            this._backgroundColor = new RGBAColor(1, 1, 1, 1);
        }

        this._backgroundColor.copy(val);

        // set background color to solid if provided color doesnt have any alpha channel
        if ((!("alpha" in val) || val.alpha === undefined)) {
            this._backgroundColor.alpha = 1;
        }
        this.applyClearFlagsIfIsActiveCamera();
    }

    /**
     * Gets or sets the texture that the camera should render to instead of the screen.
     * Useful for creating effects like mirrors, portals or custom post processing.
     */
    @serializable(RenderTexture)
    public set targetTexture(rt: RenderTexture | null) {
        this._targetTexture = rt;
    }
    public get targetTexture(): RenderTexture | null {
        return this._targetTexture;
    }
    private _targetTexture: RenderTexture | null = null;

    private _backgroundColor?: RGBAColor;
    private _fov?: number;
    private _cam: PerspectiveCamera | OrthographicCamera | null = null;
    private _clearFlags: ClearFlags = ClearFlags.SolidColor;
    private _skybox?: CameraSkybox;

    /**
     * Gets the three.js camera object. Creates one if it doesn't exist yet.
     * @returns {PerspectiveCamera | OrthographicCamera} The three.js camera object
     * @deprecated Use {@link threeCamera} instead
     */
    public get cam(): PerspectiveCamera | OrthographicCamera {
        return this.threeCamera;
    }

    /**
     * Gets the three.js camera object. Creates one if it doesn't exist yet.
     * @returns {PerspectiveCamera | OrthographicCamera} The three.js camera object
     */
    public get threeCamera(): PerspectiveCamera | OrthographicCamera {
        if (this.activeAndEnabled)
            this.buildCamera();
        return this._cam!;
    }

    private static _origin: Vector3 = new Vector3();
    private static _direction: Vector3 = new Vector3();

    /**
     * Converts screen coordinates to a ray in world space.
     * Useful for implementing picking or raycasting from screen to world.
     * 
     * @param x The x screen coordinate
     * @param y The y screen coordinate
     * @param ray Optional ray object to reuse instead of creating a new one
     * @returns {Ray} A ray originating from the camera position pointing through the screen point
     */
    public screenPointToRay(x: number, y: number, ray?: Ray): Ray {
        const cam = this.threeCamera;
        const origin = Camera._origin;
        origin.set(x, y, -1);
        this.context.input.convertScreenspaceToRaycastSpace(origin);
        if (debugscreenpointtoray) console.log("screenPointToRay", x.toFixed(2), y.toFixed(2), "now:", origin.x.toFixed(2), origin.y.toFixed(2), "isInXR:" + this.context.isInXR);
        origin.z = -1;
        origin.unproject(cam);
        const dir = Camera._direction.set(origin.x, origin.y, origin.z);
        const camPosition = getWorldPosition(cam);
        dir.sub(camPosition);
        dir.normalize();
        if (ray) {
            ray.set(camPosition, dir);
            return ray;
        }
        else {
            return new Ray(camPosition.clone(), dir.clone());
        }
    }

    private _frustum?: Frustum;

    /**
     * Gets the camera's view frustum for culling and visibility checks.
     * Creates the frustum if it doesn't exist and returns it.
     * 
     * @returns {Frustum} The camera's view frustum
     */
    public getFrustum(): Frustum {
        if (!this._frustum) {
            this._frustum = new Frustum();
            this.updateFrustum();
        }
        return this._frustum;
    }

    /**
     * Forces an update of the camera's frustum.
     * This is automatically called every frame in onBeforeRender.
     */
    public updateFrustum() {
        if (!this._frustum) this._frustum = new Frustum();
        this._frustum.setFromProjectionMatrix(this.getProjectionScreenMatrix(this._projScreenMatrix, true), this.context.renderer.coordinateSystem);
    }

    /**
     * Gets this camera's projection-screen matrix.
     * 
     * @param target Matrix4 object to store the result in
     * @param forceUpdate Whether to force recalculation of the matrix
     * @returns {Matrix4} The requested projection screen matrix
     */
    public getProjectionScreenMatrix(target: Matrix4, forceUpdate?: boolean) {
        if (forceUpdate) {
            this._projScreenMatrix.multiplyMatrices(this.threeCamera.projectionMatrix, this.threeCamera.matrixWorldInverse);
        }
        if (target === this._projScreenMatrix) return target;
        return target.copy(this._projScreenMatrix);
    }
    private readonly _projScreenMatrix = new Matrix4();

    /** @internal */
    awake() {
        if (debugscreenpointtoray) {
            window.addEventListener("pointerdown", evt => {
                const px = evt.clientX;
                const py = evt.clientY;
                console.log("touch", px.toFixed(2), py.toFixed(2))
                const ray = this.screenPointToRay(px, py);
                const randomHex = "#" + Math.floor(Math.random() * 16777215).toString(16);
                Gizmos.DrawRay(ray.origin, ray.direction, randomHex, 10);
            });
        }
    }

    /** @internal */
    onEnable(): void {
        if (debug) console.log(`Camera enabled: \"${this.name}\". ClearFlags=${ClearFlags[this._clearFlags]}`, this);
        this.buildCamera();
        if (this.tag == "MainCamera" || !this.context.mainCameraComponent) {
            this.context.setCurrentCamera(this);
            handleFreeCam(this);
        }
        this.applyClearFlagsIfIsActiveCamera({ applySkybox: true });
    }

    /** @internal */
    onDisable() {
        this.context.removeCamera(this);
    }

    onLeaveXR(_args: NeedleXREventArgs): void {
        // Restore previous FOV
        this.fieldOfView = this._fov;
    }


    /** @internal */
    onBeforeRender() {
        if (this._cam) {

            if (this._frustum) {
                this.updateFrustum();
            }

            // because the background color may be animated!
            if (this._clearFlags === ClearFlags.SolidColor)
                this.applyClearFlagsIfIsActiveCamera();

            if (this._targetTexture) {
                if (this.context.isManagedExternally) {
                    // TODO: rendering with r3f renderer does throw an shader error for some reason?
                    if (!this["_warnedAboutExternalRenderer"]) {
                        this["_warnedAboutExternalRenderer"] = true;
                        console.warn("Rendering with external renderer is not supported yet. This may not work or throw errors. Please remove the the target texture from your camera: " + this.name, this.targetTexture)
                    }
                }

                // TODO: optimize to not render twice if this is already the main camera. In that case we just want to blit
                const composer = this.context.composer;
                const useNormalRenderer = true;// this.context.isInXR || !composer;
                const renderer = useNormalRenderer ? this.context.renderer : composer;
                if (renderer) {
                    // TODO: we should do this in onBeforeRender for the main camera only
                    const mainCam = this.context.mainCameraComponent;
                    this.applyClearFlags();
                    this._targetTexture.render(this.context.scene, this._cam, renderer);
                    mainCam?.applyClearFlags();
                }
            }
        }
    }

    /** 
     * Creates a three.js camera object if it doesn't exist yet and sets its properties.
     * This is called internally when accessing the {@link threeCamera} property.
     */
    buildCamera() {
        if (this._cam) return;

        const cameraAlreadyCreated = this.gameObject["isCamera"];

        // TODO: when exporting from blender we already have a camera in the children
        let cam: PerspectiveCamera | OrthographicCamera | null = null;
        if (cameraAlreadyCreated) {
            cam = this.gameObject as any;
            cam?.layers.enableAll();
            if (cam instanceof PerspectiveCamera)
                this._fov = cam.fov;
        }
        else
            cam = this.gameObject.children[0] as PerspectiveCamera | OrthographicCamera | null;
        if (cam && cam.isCamera) {
            if (cam instanceof PerspectiveCamera) {
                if (this._fov)
                    cam.fov = this._fov;
                cam.near = this._nearClipPlane;
                cam.far = this._farClipPlane;
                cam.updateProjectionMatrix();
            }
        }
        else if (!this.orthographic) {
            cam = new PerspectiveCamera(this.fieldOfView, window.innerWidth / window.innerHeight, this._nearClipPlane, this._farClipPlane);
            if (this.fieldOfView)
                cam.fov = this.fieldOfView;
            this.gameObject.add(cam);
        }
        else {
            const factor = this.orthographicSize * 100;
            cam = new OrthographicCamera(window.innerWidth / -factor, window.innerWidth / factor, window.innerHeight / factor, window.innerHeight / -factor, this._nearClipPlane, this._farClipPlane);
            this.gameObject.add(cam);
        }
        this._cam = cam;

        this._cam.layers.mask = this._cullingMask;

        if (this.tag == "MainCamera") {
            this.context.setCurrentCamera(this);
        }
    }

    /**
     * Applies clear flags if this is the active main camera.
     * @param opts Options for applying clear flags
     */
    applyClearFlagsIfIsActiveCamera(opts?: { applySkybox: boolean }) {
        if (this.context.mainCameraComponent === this) {
            this.applyClearFlags(opts);
        }
    }

    /**
     * Applies this camera's clear flags and related settings to the renderer.
     * This controls how the background is rendered (skybox, solid color, transparent).
     * @param opts Options for applying clear flags
     */
    applyClearFlags(opts?: { applySkybox: boolean }) {
        if (!this._cam) {
            if (debug) console.log("Camera does not exist (apply clear flags)")
            return;
        }

        // restore previous fov (e.g. when user was in VR or AR and the camera's fov has changed)
        this.fieldOfView = this.fieldOfView;

        if (debug) {
            const msg = `[Camera] Apply ClearFlags: ${ClearFlags[this._clearFlags]} - \"${this.name}\"`;
            console.debug(msg);
        }

        const hasBackgroundImageOrColorAttribute = this.context.domElement.getAttribute("background-image") || this.context.domElement.getAttribute("background-color");

        switch (this._clearFlags) {
            case ClearFlags.None:
                return;

            case ClearFlags.Skybox:
                if (Camera.backgroundShouldBeTransparent(this.context)) {
                    if (!this.ARBackgroundAlpha || this.ARBackgroundAlpha < 0.001) {
                        this.context.scene.background = null;
                        this.context.renderer.setClearColor(0x000000, 0);
                        return;
                    }
                }

                // apply the skybox only if it is not already set or if it's the first time (e.g. if the _skybox is not set yet)
                if (!this.scene.background || !this._skybox || opts?.applySkybox === true)
                    this.applySceneSkybox();

                // set background blurriness and intensity
                if (this._backgroundBlurriness !== undefined && !this.context.domElement.getAttribute("background-blurriness"))
                    this.context.scene.backgroundBlurriness = this._backgroundBlurriness;
                else if (debug) console.warn(`Camera \"${this.name}\" has no background blurriness`)

                if (this._backgroundIntensity !== undefined && !this.context.domElement.getAttribute("background-intensity"))
                    this.context.scene.backgroundIntensity = this._backgroundIntensity;

                if (this._backgroundRotation !== undefined && !this.context.domElement.getAttribute("background-rotation"))
                    this.context.scene.backgroundRotation = this._backgroundRotation;

                else if (debug) console.warn(`Camera \"${this.name}\" has no background intensity`)

                break;
            case ClearFlags.SolidColor:
                if (this._backgroundColor && !hasBackgroundImageOrColorAttribute) {
                    let alpha = this._backgroundColor.alpha;
                    // when in WebXR use ar background alpha override or set to 0
                    if (Camera.backgroundShouldBeTransparent(this.context)) {
                        alpha = this.ARBackgroundAlpha ?? 0;
                    }
                    this.context.scene.background = null;
                    // In WebXR VR the background colorspace is wrong
                    if (this.context.xr?.isVR) {
                        this.context.renderer.setClearColor(getTempColor(this._backgroundColor).convertLinearToSRGB());
                    } else {
                        this.context.renderer.setClearColor(this._backgroundColor, alpha);
                    }
                }
                else if (!this._backgroundColor) {
                    if (debug) console.warn(`[Camera] has no background color \"${this.name}\" `)
                }
                break;
            case ClearFlags.Uninitialized:
                if (!hasBackgroundImageOrColorAttribute) {
                    this.context.scene.background = null
                    this.context.renderer.setClearColor(0x000000, 0);
                }
                break;
        }
    }

    /**
     * Applies the skybox texture to the scene background.
     */
    applySceneSkybox() {
        if (!this._skybox)
            this._skybox = new CameraSkybox(this);
        this._skybox.apply();
    }

    /**
     * Determines if the background should be transparent when in passthrough AR mode.
     * 
     * @param context The current rendering context
     * @returns {boolean} True when in XR on a pass through device where the background should be invisible
     */
    static backgroundShouldBeTransparent(context: Context) {
        const session = context.renderer.xr?.getSession();
        if (!session) return false;
        if (typeof session["_transparent"] === "boolean") {
            return session["_transparent"];
        }
        const environmentBlendMode = session.environmentBlendMode;
        if (debug)
            showBalloonMessage("Environment blend mode: " + environmentBlendMode + " on " + navigator.userAgent);
        let transparent = environmentBlendMode === 'additive' || environmentBlendMode === 'alpha-blend';
        if (context.isInAR) {
            if (environmentBlendMode === "opaque") {
                // workaround for Quest 2 returning opaque when it should be alpha-blend
                // check user agent if this is the Quest browser and return true if so
                if (navigator.userAgent?.includes("OculusBrowser")) {
                    transparent = true;
                }
                // Mozilla WebXR Viewer
                else if (navigator.userAgent?.includes("Mozilla") && navigator.userAgent?.includes("Mobile WebXRViewer/v2")) {
                    transparent = true;
                }
                else if(DeviceUtilities.isNeedleAppClip()) {
                    return true;
                }
            }
        }

        session["_transparent"] = transparent;
        return transparent;
    }
}

/**
 * Helper class for managing skybox textures for cameras.
 * Handles retrieving and applying skybox textures to the scene.
 */
class CameraSkybox {

    private _camera: Camera;
    private _skybox?: Texture;

    get context() { return this._camera?.context; }

    constructor(camera: Camera) {
        this._camera = camera;
    }

    /**
     * Applies the skybox texture to the scene background.
     * Retrieves the texture based on the camera's source ID.
     */
    apply() {
        this._skybox = this.context.lightmaps.tryGetSkybox(this._camera.sourceId) as Texture;
        if (!this._skybox) {
            if (!this["_did_log_failed_to_find_skybox"]) {
                this["_did_log_failed_to_find_skybox"] = true;
                console.warn(`Camera \"${this._camera.name}\" has no skybox texture. ${this._camera.sourceId}`);
            }
        }
        else if (this.context.scene.background !== this._skybox) {

            const hasBackgroundAttribute = this.context.domElement.getAttribute("background-image") || this.context.domElement.getAttribute("background-color");

            if (debug) console.debug(`[Camera] Apply Skybox ${this._skybox?.name} ${hasBackgroundAttribute} - \"${this._camera.name}\"`);
            if (!hasBackgroundAttribute?.length) {
                if (this._skybox.mapping !== CubeUVReflectionMapping) {
                    this._skybox.mapping = EquirectangularReflectionMapping;
                }
                this.context.scene.background = this._skybox;
            }
        }
    }
}

/**
 * Adds orbit controls to the camera if the freecam URL parameter is enabled.
 * 
 * @param cam The camera to potentially add orbit controls to
 */
function handleFreeCam(cam: Camera) {
    const isFreecam = getParam("freecam");
    if (isFreecam) {
        if (cam.context.mainCameraComponent === cam) {
            GameObject.getOrAddComponent(cam.gameObject, OrbitControls);
        }
    }
}