/** @internal NOTE: Internal APIs. Subject to change. Use of these APIs in production applications is not supported. */ /** */

import {
    TargetCamera,
    AbstractMesh,
    Mesh,
    Scene,
    Ray,
    Matrix,
    Node,
    Camera,
    EventState,
    Vector3,
    ISize,
    RenderTargetTexture
} from './babylonjs-import';
import { Intersection } from './bim-format-types';
import { saveAsImage } from './pixels';
import { Materials } from './materials';
import { BidirectionalMap } from './bidirectional-map';
import { ChangeRecorder } from './change-recorder';
import {
    PickOption,
    Selectables,
    VisibleSelectable,
    PickOptionType,
    PickOptionRay,
    CanvasPosition,
    PredefinedCanvasPosition,
    AttachOption,
    VisibleSelectableType,
    GetVisibleInSightOptions
} from './Selectables';

import { MutableArrayLike, Vertex2 } from '../math';
import { getCanvasCenterCoordinatesToRef } from '../camera/Inputs/input-utils';
import { rgba } from './rgba';
import { IconHandler } from '../tools/IconHandler';
import { Icon } from '../tools/Icon';
import { PickResult, PickResultEmpty, PickResultIcon, PickResultIfcProductMesh, PickResultMesh } from './PickResult';
import { GpuPickingTarget, GpuPickingTargetCache } from './GpuPickingTargetCache';
import { createNullFrustum } from '../math/Frustum';
import { telemetry } from '../Telemetry';

/** Debugging options for GPU picking. */
interface GpuPickDebugOptions {
    /** If `true` then the rendered gpu pick texture will be saved to disk. If `false` disables all other options.  */
    saveTexture: boolean;
    /** Camera FOV when rendering to gpu pick texture is usually very small. But that means that it will
     * be difficult to see rendered objects in texture. This value will override fov on pick camera during pick operation.
     * Only used if {@link saveTexture} is true*/
    pickCameraFovInRadians?: number;
    /** Default texture size if very small so it is difficult to make out bojects.
     * Setting this allows us to use bigger texture size when debuggin. Only used if {@link saveTexture} is true */
    textureSizePow?: number;

    /**  When texture is saved (after click) this indicates how big the marker drawn on the click point should be. */
    clickMarkerSize: number;

    /** If `true` meshes are culled during picking. Otherwise they are not */
    cullMeshes: boolean;
}

export class GpuSelectables implements Selectables {
    private static readonly _tmp = {
        ray: new Ray(Vector3.Zero(), Vector3.Zero(), 0)
    };

    private static readonly _tmpScreenCoordinate: Vertex2 = { x: 0, y: 0 };
    private static readonly _identity = Matrix.Identity();

    public static readonly defaultGpuPickTextureSize = 16;
    public debugOptions: GpuPickDebugOptions = {
        saveTexture: false,
        pickCameraFovInRadians: 0.8,
        textureSizePow: 2048,
        clickMarkerSize: 10,
        cullMeshes: false
    };

    // eslint-disable-next-line @typescript-eslint/ban-types
    private _renderTargetTextureBugWorkaroundMap = new WeakMap<{}, boolean>();

    // One pick camera per camera mode (orthographic and perspective)
    private _pickCameras: TargetCamera[] = [];

    /** BabylonJS meshes containing the merged IFC product meshes. */
    public readonly meshes = new Map<string, Mesh>();
    private readonly _textureObjectIdToGpuPickMap = new BidirectionalMap<number, Mesh | Icon>();
    private readonly _meshToGpuPickMeshMap = new BidirectionalMap<Mesh, Mesh>();
    private readonly _iconHandlersToGpuPick = new Map<IconHandler, Set<Icon>>();

    private readonly _boundOnDisposeMesh: (eD: Node, eS: EventState) => void;

    private readonly _perspectiveGpuPickCameraFov = 0.0174532925;

    private readonly _orthographicGpuPickCameraHeight = { height: 0.1 };

    private readonly _gpuPickCameraDefaultMinZ = 0.01;

    private readonly _gpuPickCameraDefaultMaxZ = 1000;

    private readonly _gpuPickingTargetCache: GpuPickingTargetCache;

    public constructor(
        private readonly scene: Scene,
        private readonly _materials: Materials
    ) {
        this._boundOnDisposeMesh = this.onDisposeMesh.bind(this);

        // Precreate the cameras that will be used for GPU picking.
        // NOTE Should we use a custom gpu picking scene instead? Then we could have a
        // separate render loop for that scene and simply read back pixels from that scene instead of
        // rendering directly to a texture!
        const perspectiveCamera = new TargetCamera('perspectiveGpuPick', Vector3.Zero(), this.scene, false);
        const ortographicCamera = new TargetCamera('orthographicGpuPick', Vector3.Zero(), this.scene, false);
        this._pickCameras.push(perspectiveCamera);
        this._pickCameras.push(ortographicCamera);

        perspectiveCamera.mode = Camera.PERSPECTIVE_CAMERA;
        ortographicCamera.mode = Camera.ORTHOGRAPHIC_CAMERA;
        perspectiveCamera.fov = this._perspectiveGpuPickCameraFov; // one degree
        ortographicCamera.twinfinity.setOrtho(this._orthographicGpuPickCameraHeight);
        for (const camera of this._pickCameras) {
            camera.minZ = this._gpuPickCameraDefaultMinZ;
            camera.maxZ = this._gpuPickCameraDefaultMaxZ;
        }
        this._gpuPickingTargetCache = new GpuPickingTargetCache(scene);
    }

    /**
     * Attach a babylonJs {@link Mesh} so it becomes available for gpu picking. That means that it may be returned in a {@link pick} operation.
     * @param meshOrIcon
     * @param o
     * @returns Number of remaning meshes it is possible to attach.
     */
    public attach(meshOrIcon: Mesh | Icon, o?: AttachOption): number {
        // TODO Support thinInstances and support HW instances better.
        // Handle Icons
        if (meshOrIcon instanceof Icon) {
            const icon = meshOrIcon;
            // If no icon handler or if icon has already been registered then return number of available pick slots.
            if (!icon._internal || (this._iconHandlersToGpuPick.get(icon._internal.iconHandler)?.has(icon) ?? false)) {
                return this._materials.maxObjectCount - this._materials.objectCount;
            }

            const gpuTextureObject = this._materials.createGpuTextureObject();
            if (gpuTextureObject === undefined) {
                return 0; // Not enough room in texure
            }

            this._textureObjectIdToGpuPickMap.set(gpuTextureObject.id, icon);
            this._iconHandlersToGpuPick.getOrAdd(icon._internal.iconHandler, (_key) => new Set<Icon>()).add(icon);
        } else if (meshOrIcon instanceof Mesh) {
            const mesh = meshOrIcon;
            // Cannot add same mesh twice. If already added, return number of available pick slots.
            if (this._meshToGpuPickMeshMap.has(mesh)) {
                return this._materials.maxObjectCount - this._materials.objectCount;
            }

            const gpuTextureObject = this._materials.createGpuTextureObject();
            if (gpuTextureObject === undefined) {
                return 0; // Not enough room in texure
            }

            // gpu pickable mesh must NOT have own picking because
            // then babylonjs will use it in its on picking operation.
            mesh.isPickable = false;

            // Create a disabled "pick mesh" to use when we render for gpu picking!
            // It is not not rendered if parent is not visible, disabled etc. It will use
            // same transformation as parent as well.
            const gpuPickMesh = mesh.clone('gpupick');
            // Must make geometry unique so we can change UV coordinates. It is a bit
            // wasteful and there are perhaps better ways of doing this (thinInstances or HW instances)
            // but it works for now.
            gpuPickMesh.makeGeometryUnique();
            // geometry intersection is enabled by default
            gpuPickMesh.isPickable = o?.isGeometryIntersectionEnabled ?? true;
            gpuPickMesh.name = gpuPickMesh.id;
            gpuPickMesh.setParent(mesh);
            gpuPickMesh.resetLocalMatrix();
            gpuPickMesh.computeWorldMatrix(true);
            gpuPickMesh.setEnabled(false);

            // gpu pick mesh gets new UV coordinates that points to a single pixel
            // in the gpu pick texture (the one with unique colors in every pixel which
            // maps 1:1 to objects that can be picked.). gpuPickMesh is also assigned the
            // gpu picking material.
            gpuTextureObject.buildGpuPickable(gpuPickMesh);
            this._meshToGpuPickMeshMap.set(mesh, gpuPickMesh);
            this._textureObjectIdToGpuPickMap.set(gpuTextureObject.id, gpuPickMesh);

            mesh.onDisposeObservable.removeCallback(this._boundOnDisposeMesh);
            mesh.onDisposeObservable.addOnce(this._boundOnDisposeMesh);
        } else {
            throw new Error('Not implemented');
        }

        return this._materials.maxObjectCount - this._materials.objectCount;
    }

    /** Detach a babylonJs {@link Mesh} or {@link Icon}, that was previously attached using {@link attach} from gpu picking.
     * @returns `true` if detach was succesful.
     */
    public detach(meshOrIcon: Mesh | Icon): boolean {
        // Handle Icons
        if (meshOrIcon instanceof Icon) {
            const icon = meshOrIcon;
            const wasIconDeleted = this._textureObjectIdToGpuPickMap.reverse.delete(meshOrIcon);

            if (icon._internal) {
                const pickableIconsInIconHandler = this._iconHandlersToGpuPick.get(icon._internal.iconHandler);
                if (pickableIconsInIconHandler) {
                    pickableIconsInIconHandler.delete(icon);
                    if (pickableIconsInIconHandler.size === 0) {
                        this._iconHandlersToGpuPick.delete(icon._internal.iconHandler);
                    }
                }
            }

            return wasIconDeleted;
        } else if (meshOrIcon instanceof Mesh) {
            const mesh = meshOrIcon;
            const gpuPickMesh = this._meshToGpuPickMeshMap.get(mesh);
            if (gpuPickMesh === undefined) {
                return false;
            }
            mesh.onDisposeObservable.removeCallback(this._boundOnDisposeMesh);
            this._meshToGpuPickMeshMap.reverse.delete(gpuPickMesh);

            const textureObjectId = this._textureObjectIdToGpuPickMap.reverse.get(gpuPickMesh);
            gpuPickMesh.setParent(null);
            if (textureObjectId !== undefined) {
                this._materials.deleteGpuTextureObject(textureObjectId);
                this._textureObjectIdToGpuPickMap.delete(textureObjectId);
            }

            if (!gpuPickMesh.isDisposed()) {
                gpuPickMesh.dispose();
            }

            return textureObjectId !== undefined;
        } else {
            throw new Error('Invalid operation');
        }
    }

    public idOf(meshOrIcon: Mesh | Icon): number {
        return this._textureObjectIdToGpuPickMap.reverse.get(meshOrIcon) ?? 0;
    }

    public getAndUpdatePickCamera(rayOrScreenCoordinate: Ray | CanvasPosition | Intersection): TargetCamera {
        let argumentIsRay = false;
        let ray: Ray;
        if (rayOrScreenCoordinate instanceof Ray) {
            argumentIsRay = true;
            ray = rayOrScreenCoordinate;
        } else if (this.isIntersection(rayOrScreenCoordinate)) {
            // We have a intersection which gives point of intersection and
            // the direction a ray would take if it continoued to bouncy off that point.
            // we do NOT have a length we can use here (intersection lenght is from origin of ray that caused
            // the intersection to the intersection point.)
            argumentIsRay = true;
            ray = GpuSelectables._tmp.ray;
            ray.origin.copyFrom(rayOrScreenCoordinate.position);
            ray.direction.copyFrom(rayOrScreenCoordinate.normal);
            ray.length = Number.MAX_VALUE;
        } else {
            const screenCoordinate = GpuSelectables._tmpScreenCoordinate;
            if (rayOrScreenCoordinate === PredefinedCanvasPosition.Center) {
                getCanvasCenterCoordinatesToRef(this.scene, screenCoordinate);
            } else if (rayOrScreenCoordinate === PredefinedCanvasPosition.Mouse) {
                screenCoordinate.x = this.scene.pointerX;
                screenCoordinate.y = this.scene.pointerY;
            } else if ('x' in rayOrScreenCoordinate) {
                screenCoordinate.x = rayOrScreenCoordinate.x;
                screenCoordinate.y = rayOrScreenCoordinate.y;
            }
            ray = GpuSelectables._tmp.ray;
            this.scene.createPickingRayToRef(
                screenCoordinate.x,
                screenCoordinate.y,
                GpuSelectables._identity,
                ray,
                this.scene.activeCamera
            );
        }

        let pickCamera: TargetCamera;

        if (argumentIsRay) {
            pickCamera = this._pickCameras[Camera.PERSPECTIVE_CAMERA];
            pickCamera.fov = this._perspectiveGpuPickCameraFov;
            pickCamera.minZ = this._gpuPickCameraDefaultMinZ;
            if (ray.length < Number.MAX_VALUE) {
                pickCamera.maxZ = ray.length;
            } else {
                pickCamera.maxZ = this.scene.activeCamera?.maxZ ?? this._gpuPickCameraDefaultMaxZ;
            }
        } else if (this.scene.activeCamera) {
            pickCamera = this._pickCameras[this.scene.activeCamera.mode];
            pickCamera.minZ = this.scene.activeCamera.minZ;
            pickCamera.maxZ = this.scene.activeCamera.maxZ;
            pickCamera.twinfinity.setOrtho(this._orthographicGpuPickCameraHeight);
        } else {
            pickCamera = this._pickCameras[Camera.PERSPECTIVE_CAMERA];
            pickCamera.fov = this._perspectiveGpuPickCameraFov;
            pickCamera.minZ = this._gpuPickCameraDefaultMinZ;
            pickCamera.maxZ = this._gpuPickCameraDefaultMaxZ;
        }

        const origin = ray.origin;
        const target = ray.origin.add(ray.direction);
        pickCamera.position.copyFrom(origin);
        pickCamera.setTarget(target);

        pickCamera.getViewMatrix(true);
        return pickCamera;
    }

    /**
     * Renders objects to a texture and checks which objects are in sight from given camera.
     * @param o Options.
     */
    public getVisiblesInSight(o?: GetVisibleInSightOptions): VisibleSelectable[];
    /**
     * Renders objects to a texture and checks which objects are in sight from given camera.
     * @param inSightCamera Camera to represent the view to check for visible objects. If not specified then current {@link Scene.activeCamera} is used
     * @param textureSizePow2 The resolution of texture to be used for in sight detection. (Smaller might miss object ending up smaller than a pixel.)
     * @param isDistanceCullingEnabled Whether to enable distance culling or not.
     */
    public getVisiblesInSight(
        inSightCamera?: Camera,
        textureSizePow2?: number,
        isDistanceCullingEnabled?: boolean
    ): VisibleSelectable[];
    public getVisiblesInSight(
        cameraOrOptions?: Camera | GetVisibleInSightOptions,
        textureSizePow2 = 512,
        isDistanceCullingEnabled = false
    ): VisibleSelectable[] {
        if (!this.scene.activeCamera) throw new Error('this.scene.activeCamera === null');
        let camera: Camera;
        let textureTargetSize: ISize;
        if (cameraOrOptions === undefined || cameraOrOptions instanceof Camera) {
            camera = cameraOrOptions ?? this.scene.activeCamera;
            const aspectRatio = this.scene.getEngine().getAspectRatio(camera);
            textureTargetSize = { width: Math.floor(textureSizePow2 * aspectRatio), height: textureSizePow2 };
        } else {
            camera = cameraOrOptions.camera ?? this.scene.activeCamera;
            const aspectRatio = this.scene.getEngine().getAspectRatio(camera);
            textureTargetSize = cameraOrOptions.textureSize || {
                width: Math.floor(textureSizePow2 * aspectRatio),
                height: textureSizePow2
            };
            isDistanceCullingEnabled = cameraOrOptions.isDistanceCullingEnabled ?? false;
        }

        // this.debugOptions.saveTexture = true;
        const hasIfcMeshesToRender = this.meshes.size > 0;
        const hasBabylonJsMeshesToRender = this._meshToGpuPickMeshMap.size > 0;
        const hasIconsToRender = [...this._iconHandlersToGpuPick.values()].every((icons) => icons.size === 0);
        const hasAnythingToRender = hasIfcMeshesToRender || hasBabylonJsMeshesToRender || hasIconsToRender;
        if (!hasAnythingToRender) {
            return [];
        }
        const changeRecorder = new ChangeRecorder();
        try {
            const renderList: AbstractMesh[] = [];

            const cameraFrustumPlanes = camera.twinfinity.getFrustumToRef(createNullFrustum());
            // Register IFC meshes for rendering

            for (const m of this.meshes.values()) {
                let isIfcMeshVisible =
                    m.isEnabled() && m.visibility > 0 && m.isVisible && m.isInFrustum(cameraFrustumPlanes);
                if (isIfcMeshVisible && isDistanceCullingEnabled && m.twinfinity.ifc) {
                    isIfcMeshVisible = m.twinfinity.ifc.isVisible(camera);
                }

                if (isIfcMeshVisible) {
                    renderList.push(m);
                }
            }

            // Register babylonjs gpu pick meshes to the render list (standard BJS meshes)
            // that we have explicitly said we want to enable in gpu picking.
            for (const gpuPickMesh of this._meshToGpuPickMeshMap.values()) {
                // Only render gpu pick mesh if the real mesh is enabled
                const parent = gpuPickMesh.parent as Mesh;
                if (
                    parent.isEnabled() &&
                    parent.isVisible &&
                    parent.visibility > 0 &&
                    parent.isInFrustum(cameraFrustumPlanes)
                ) {
                    changeRecorder.set(gpuPickMesh, 'isVisible', parent.isVisible);
                    changeRecorder.set(gpuPickMesh, 'visibility', parent.visibility);
                    gpuPickMesh.setEnabled(true);
                    changeRecorder.onRestore(gpuPickMesh, (m) => m.setEnabled(false));
                    renderList.push(gpuPickMesh);
                }
            }

            // Register icon meshes in the render list. Icons uses its own material for GPU picking
            // which is why we do not use the material above.
            for (const [iconHandler, iconsRegisteredForGpuPicking] of this._iconHandlersToGpuPick) {
                const shallRenderIconHandler =
                    iconHandler._mesh.isEnabled() &&
                    iconHandler._mesh.isVisible &&
                    iconsRegisteredForGpuPicking.size > 0 &&
                    iconHandler._mesh.isInFrustum(cameraFrustumPlanes);
                if (shallRenderIconHandler) {
                    const gpuPickingOperation = iconHandler._beginGpuPickingOperation(camera, textureTargetSize);
                    changeRecorder.onRestore(iconHandler, () => {
                        gpuPickingOperation.done();
                    });

                    renderList.push(iconHandler._mesh);
                }
            }

            if (renderList.length === 0) {
                return [];
            }

            const target = this._gpuPickingTargetCache.get('visiblesInSight', textureTargetSize);

            this.renderToTarget(renderList, camera, target);

            const visibleSelectables = this.getVisiblesFromTexture(target);

            this.debugSaveTexture(target);
            return visibleSelectables;
        } finally {
            changeRecorder.restoreAll();
        }
    }

    private getVisiblesFromTexture(target: GpuPickingTarget): VisibleSelectable[] {
        const visibleSelectables: VisibleSelectable[] = [];
        const pickedTextureObjectIds = new Set<number>();

        const appendVisible = (pixelOffset: number): void => {
            const picked = this.getObjectInGpuPickingTargetFromOffset(target, pixelOffset);
            if (picked && !pickedTextureObjectIds.has(picked.id)) {
                pickedTextureObjectIds.add(picked.id);
                visibleSelectables.push(picked);
            }
        };

        const len = target.uint32Pixels.length;
        for (let pixelOffset = 0; pixelOffset < len; ++pixelOffset) {
            appendVisible(pixelOffset);
        }

        return visibleSelectables;
    }

    public pick(o: PickOption): PickResult;
    public pick(
        picker: TargetCamera,
        textureSizePow2?: number,
        doIntersectionTestOnGeometry?: boolean,
        saveRenderedGpuPickingSceneTexture?: boolean
    ): PickResult;
    public pick(
        o: TargetCamera | PickOption,
        textureSizePow2 = GpuSelectables.defaultGpuPickTextureSize,
        doIntersectionTestOnGeometry = false,
        saveRenderedGpuPickingSceneTexture = false
    ): PickResult {
        if (this.meshes.size === 0 && this._meshToGpuPickMeshMap.size === 0) {
            return PickResultEmpty.instance;
        }

        const { pO, pickingCamera } = this.convertPickArgumentsToPickOptionRayAndCamera(
            o,
            doIntersectionTestOnGeometry,
            textureSizePow2
        );

        pO.textureSize = pO.textureSize ?? GpuSelectables.defaultGpuPickTextureSize;
        const changeRecorder = new ChangeRecorder();

        changeRecorder.set(
            this.debugOptions,
            'saveTexture',
            this.debugOptions.saveTexture || saveRenderedGpuPickingSceneTexture
        );

        // Only relevant when this.debugOptions.saveTexture = true
        // and is only used for internal debugging.
        this.assignEnabledDebugOptions(pickingCamera, pO, changeRecorder);
        // if you want to debug only whats actually get picked, uncomment the next row.
        // this.debugOptions.saveTexture = true;

        // TODO is aspect ration really correctly calculated here? what happens on phones that "stand"
        const aspectRatio = this.scene.getEngine().getAspectRatio(pickingCamera);
        const target = this._gpuPickingTargetCache.get('pick', {
            width: Math.floor(pO.textureSize * aspectRatio),
            height: pO.textureSize
        });

        try {
            const renderList: AbstractMesh[] = [];

            // TODO Also use a octree for even faster bbox selection
            // const rayIntersection = this.scene.selectionOctree.intersectsRay(ray);

            // Add gpu pick meshes  to the render list
            for (const gpuPickMesh of this._meshToGpuPickMeshMap.values()) {
                // Only render gpu pick mesh if the real mesh is enabled
                const parent = gpuPickMesh.parent as Mesh;
                gpuPickMesh.computeWorldMatrix(); // This has to be done to make sure picking works after moving the parent mesh.
                gpuPickMesh.setEnabled(parent.isEnabled());
                changeRecorder.onRestore(gpuPickMesh, (m) => {
                    m.setEnabled(false);
                });
                changeRecorder.set(gpuPickMesh, 'isVisible', parent.isVisible);
                changeRecorder.set(gpuPickMesh, 'visibility', parent.visibility);
                // TODO This is wrong. We need to check if we intersect the camera frustum. If so then the
                // mesh has to be rendered. Otherwise not. Only using ray risks missing renderings Meshes that would
                // have contributed to the pixels in the gpu pick texture
                this.appendToRenderListIfHitByRay(pO.ray, gpuPickMesh, renderList);
            }

            // Add normal ifcproduct meshes to the render list
            for (const m of this.meshes.values()) {
                this.appendToRenderListIfHitByRay(pO.ray, m, renderList);
            }

            for (const mesh of renderList) {
                changeRecorder.set(mesh, 'alwaysSelectAsActiveMesh', true);
            }

            // Add set of icons for gpu picking.
            for (const [iconHandler, iconsRegisteredForGpuPicking] of this._iconHandlersToGpuPick) {
                if (iconsRegisteredForGpuPicking.size > 0) {
                    if (this.appendToRenderListIfHitByRay(pO.ray, iconHandler._mesh, renderList)) {
                        const gpuPickingOperation = iconHandler._beginGpuPickingOperation(pickingCamera, target);
                        changeRecorder.onRestore(iconHandler, () => {
                            gpuPickingOperation.done();
                        });
                    }
                }
            }

            if (renderList.length === 0) {
                return PickResultEmpty.instance;
            }

            // Render all meshes in render list to texture, using picking camera and texture size.
            this.renderToTarget(renderList, pickingCamera, target);

            const textureHitPosition = {
                x: Math.floor(target.width * 0.5),
                y: Math.floor(target.height * 0.5)
            };

            let picked = this.getObjectInGpuPickingTargetFromPixelCoordinate(target, textureHitPosition);
            if (!picked && pO.isNearbyRenderedObjectsIntersectionFallbackEnabled) {
                // There was no object at the center pixel. Look for the object closest
                // to the center pixel
                picked = this.findObjectClosestToCenter(target);
            }

            this.debugSaveTexture(target, textureHitPosition);

            if (!picked) {
                return PickResultEmpty.instance;
            }

            if (picked.type === VisibleSelectableType.IfcProductAndMesh) {
                return new PickResultIfcProductMesh({
                    id: picked.id,
                    mesh: picked.mesh,
                    ifcProductAndMesh: picked.ifc.mesh,
                    pickOptions: pO
                });
            }

            if (picked.type === VisibleSelectableType.Icon) {
                return new PickResultIcon({
                    id: picked.id,
                    mesh: picked.mesh,
                    icon: picked.icon,
                    pickOptions: pO
                });
            }
            if (picked.type === VisibleSelectableType.Mesh) {
                const gpuPickMesh = this._meshToGpuPickMeshMap.get(picked.mesh);
                return gpuPickMesh === undefined
                    ? PickResultEmpty.instance
                    : new PickResultMesh({
                          id: picked.id,
                          mesh: picked.mesh,
                          gpuPickMesh: gpuPickMesh,
                          pickOptions: pO
                      });
            }
            throw new Error('Not implemeneted');
        } finally {
            changeRecorder.restoreAll();
        }
    }

    private renderToTarget(renderList: AbstractMesh[], camera: Camera, target: GpuPickingTarget): GpuPickingTarget {
        // To optimize performance we want to avoid switching materials on the meshes, previously done by calling mesh.material = material.
        // This causes caches to be invalidated and shaders have to be recompiled. Becomes noticable when we have low FPS
        // in the form of micro stutters.
        // Instead we use BJS 5.0+ render pass id system to set the material for the render pass. However we need to use the render pass id
        // of the target texture we are rendering to. Normally we would call target.texture.setMaterialForRendering(renderList, material). However that does
        // not work because meshes in the renderList may use different materials. Therefore we fallback to mesh.setMaterialForRenderPass(renderPassId, material)
        // Which is what target.texture.setRenderPassId(renderPassId) does internally. But for that to work we need use the renderPassId that the target texture
        // uses. We also check if the material is already set for the render pass. If so we do not need to set it again as that would invalidate the cache
        // and cause shaders to be recompiled. That in turn makes waitUntilRenderTargetTextureIsReady() take longer to complete leading micro stutters.
        const { renderPassId } = target.texture;
        for (const m of renderList) {
            // Materials that handle GPU picking natively (e.g. icon ShaderMaterial with u_useGpuColor)
            // must not be overridden with GpuPickingMaterial. They declare this via metadata.
            // BabylonJS falls back to mesh.material when no render pass material is set.
            if (m.material?.metadata?.handlesGpuPicking) {
                continue;
            }
            const material = this._materials.getGpuPickingMaterial(m);
            if (m.getMaterialForRenderPass(renderPassId) !== material) {
                m.setMaterialForRenderPass(renderPassId, material); // Have to recompile shader
            }
        }

        // NOTE: Investigate if we have to sort renderList by material to avoid switching materials
        // in the render pass. It seems to work without sorting but it might be a good idea to do so.
        target.texture.renderList = renderList;
        target.texture.clearColor = target.texture.clearColor ?? this._materials.reservedGpuPickClearColor.clone();
        target.texture.activeCamera = camera;

        try {
            this.waitUntilRenderTargetTextureIsReady(target.texture);

            target.texture.render(false, false);
            target.texture.readPixels(undefined, undefined, target.rgbaPixels);
        } finally {
            target.texture.renderList = [];
            target.texture.activeCamera = null;
        }
        return target;
    }

    private assignEnabledDebugOptions(
        pickingCamera: TargetCamera,
        pO: PickOptionRay,
        changeRecorder: ChangeRecorder
    ): void {
        if (this.debugOptions.saveTexture) {
            if (
                pickingCamera.mode === Camera.PERSPECTIVE_CAMERA &&
                this.debugOptions.pickCameraFovInRadians !== undefined
            ) {
                changeRecorder.set(pickingCamera, 'fov', this.debugOptions.pickCameraFovInRadians);
            } else if (pickingCamera.mode === Camera.ORTHOGRAPHIC_CAMERA && this.scene.activeCamera) {
                const c = this.scene.activeCamera;
                changeRecorder.set(pickingCamera, 'orthoLeft', c.orthoLeft);
                changeRecorder.set(pickingCamera, 'orthoRight', c.orthoRight);
                changeRecorder.set(pickingCamera, 'orthoTop', c.orthoTop);
                changeRecorder.set(pickingCamera, 'orthoBottom', c.orthoBottom);
            }
        }

        if (this.debugOptions.saveTexture && this.debugOptions.textureSizePow !== undefined) {
            pO.textureSize = this.debugOptions.textureSizePow;
        }
    }

    private findObjectClosestToCenter(
        target: GpuPickingTarget
    ): (VisibleSelectable & { readonly id: number }) | undefined {
        let lengthToCenterPixel = Number.MAX_VALUE;
        let objectClosestToPixel: (VisibleSelectable & { readonly id: number }) | undefined;
        const centerX = Math.floor(target.width * 0.5);
        const centerY = Math.floor(target.height * 0.5);
        const p = { x: 0, y: 0 };
        for (p.y = 0; p.y < target.height; ++p.y) {
            for (p.x = 0; p.x < target.width; ++p.x) {
                const tmp = this.getObjectInGpuPickingTargetFromPixelCoordinate(target, p);
                if (tmp) {
                    const approximateLenToTextureCenter = Math.max(Math.abs(p.y - centerX), Math.abs(p.x - centerY));
                    if (approximateLenToTextureCenter < lengthToCenterPixel) {
                        lengthToCenterPixel = approximateLenToTextureCenter;
                        objectClosestToPixel = tmp;
                    }
                }
            }
        }
        return objectClosestToPixel;
    }

    public clear(): void {
        for (const mesh of this._meshToGpuPickMeshMap.keys()) {
            this.detach(mesh);
        }

        this._gpuPickingTargetCache.clear();
        this.meshes.clear();
        this._iconHandlersToGpuPick.clear();

        // eslint-disable-next-line @typescript-eslint/ban-types
        this._renderTargetTextureBugWorkaroundMap = new WeakMap<{}, boolean>();
    }

    private appendToRenderListIfHitByRay(ray: Ray, mesh: Mesh, dst: AbstractMesh[]): boolean {
        if (mesh.isEnabled() && mesh.isVisible && mesh.visibility > 0) {
            const isCullingEnabled =
                !this.debugOptions.saveTexture || (this.debugOptions.saveTexture && this.debugOptions.cullMeshes);
            if (!isCullingEnabled) {
                dst.push(mesh);
                return true;
            }
            const aabb = mesh.getBoundingInfo().boundingBox;
            if (ray.intersectsBoxMinMax(aabb.minimumWorld, aabb.maximumWorld) || aabb.intersectsPoint(ray.origin)) {
                dst.push(mesh);
                return true;
            }
        }

        return false;
    }

    private convertPickArgumentsToPickOptionRayAndCamera(
        o: PickOption | TargetCamera,
        doIntersectionTestOnGeometry: boolean,
        textureSizePow2: number
    ): { readonly pO: PickOptionRay; readonly pickingCamera: TargetCamera } {
        let pickingCamera: TargetCamera;
        let pO: PickOptionRay;
        if (o instanceof Camera || o.type === PickOptionType.Camera) {
            pickingCamera = o instanceof Camera ? o : o.camera;
            pO = {
                type: PickOptionType.Ray,
                ray: pickingCamera.getForwardRay(pickingCamera.maxZ),
                isGeometryIntersectionEnabled: doIntersectionTestOnGeometry,
                textureSize: textureSizePow2
            };
        } else {
            if (o.type === PickOptionType.Ray) {
                pickingCamera = this.getAndUpdatePickCamera(o.ray);
            } else if (o.type === PickOptionType.Canvas) {
                pickingCamera = this.getAndUpdatePickCamera(o.position);
            } else if (o.type === PickOptionType.Intersection) {
                pickingCamera = this.getAndUpdatePickCamera(o.intersection);
            } else {
                throw new Error('Not implemented');
            }

            pO = {
                type: PickOptionType.Ray,
                ray: pickingCamera.getForwardRay(pickingCamera.maxZ),
                isGeometryIntersectionEnabled: o.isGeometryIntersectionEnabled ?? true, // default values is true
                isAABBIntersectionEnabled: o.isAABBIntersectionEnabled,
                isCenterIntersectionFallbackEnabled: o.isAABBIntersectionEnabled,
                isNearbyRenderedObjectsIntersectionFallbackEnabled: o.isAABBIntersectionEnabled,
                textureSize: o.textureSize ?? textureSizePow2
            };
        }
        return { pO, pickingCamera };
    }

    private getObjectInGpuPickingTargetFromOffset(
        target: GpuPickingTarget,
        pixelOffset: number
    ): (VisibleSelectable & { readonly id: number }) | undefined {
        // Mask away alpha as that is not part of the id. Need >>> 0 to make it a unsigned value (otherwise everything
        // above 2^31 will be incorrect)
        const gpuPickId = ((target.uint32Pixels[pixelOffset] ?? 0) & 0xffffff) >>> 0;
        if (gpuPickId <= 0) {
            // id 0 is reserved and will never map to any object so no use in checking further
            return undefined;
        }

        const pickedBimProductMesh = this._materials.getBimProductAndMeshById(gpuPickId);
        if (pickedBimProductMesh) {
            return {
                type: VisibleSelectableType.IfcProductAndMesh,
                ifc: { product: pickedBimProductMesh.ifcProduct, mesh: pickedBimProductMesh },
                mesh: this.meshes.get(pickedBimProductMesh.mergeId)!,
                id: pickedBimProductMesh.colorTexturePixelIndex
            };
        }

        const gpuPickMeshOrIcon = this._textureObjectIdToGpuPickMap.get(gpuPickId);
        if (gpuPickMeshOrIcon instanceof Mesh) {
            if (gpuPickMeshOrIcon?.parent instanceof Mesh) {
                return {
                    type: VisibleSelectableType.Mesh,
                    mesh: gpuPickMeshOrIcon.parent,
                    id: gpuPickId
                };
            }
            return undefined;
        }

        if (gpuPickMeshOrIcon instanceof Icon) {
            if (gpuPickMeshOrIcon._internal?.iconHandler._mesh instanceof Mesh) {
                return {
                    type: VisibleSelectableType.Icon,
                    mesh: gpuPickMeshOrIcon._internal.iconHandler._mesh,
                    icon: gpuPickMeshOrIcon,
                    id: gpuPickId
                };
            }
            return undefined;
        }
        return undefined;
    }

    private getObjectInGpuPickingTargetFromPixelCoordinate(
        target: GpuPickingTarget,
        coordinate: { x: number; y: number }
    ): (VisibleSelectable & { readonly id: number }) | undefined {
        const { height, width } = target;
        const flippedY = height - 1 - coordinate.y;
        const pixelOffset = flippedY * width + coordinate.x;
        return this.getObjectInGpuPickingTargetFromOffset(target, pixelOffset);
    }

    private debugSaveTexture(target: GpuPickingTarget, center?: { y: number; x: number }): void {
        if (this.debugOptions.saveTexture) {
            // To make it easier for a human to interpret the debug texture (more contrast between unique objects)
            // we randomize the colors in the rgbaPixels array.
            // Only background color is left alone..
            const { r, g, b, a } = this._materials.reservedGpuPickClearColor;
            const { height, width } = target;
            const backgroundColorAsInt32 = rgba.toInt32([r * 255, g * 255, b * 255, a * 255], 4);

            const excludeBackgroundColorPixel = (rgbaPixels: Uint8Array, rgbaOffset: number): boolean =>
                rgba.toInt32(rgbaPixels, 4, rgbaOffset) !== backgroundColorAsInt32;

            const rgbaPixels = rgba.randomizeRGBAColorsWithoutModifyingAlphaInPlace(
                target.rgbaPixels,
                target.rgbaPixels.slice(),
                excludeBackgroundColorPixel
            );

            if (center) {
                const flippedY = height - 1 - center.y;
                this.drawSquareCenteredOnClickedPixel(rgbaPixels, center.x, flippedY, width);
            }
            saveAsImage(rgbaPixels, width, height);
        }
    }

    private drawSquareCenteredOnClickedPixel(
        pixelsArray: MutableArrayLike<number>,
        x: number,
        y: number,
        width: number
    ): void {
        const clickSquareSize = this.debugOptions.clickMarkerSize;
        for (let _y = y - clickSquareSize; _y < y + clickSquareSize; ++_y) {
            for (let _x = x - clickSquareSize; _x < x + clickSquareSize; ++_x) {
                const pixelOffset = (_y * width + _x) * 4;
                const pixelEnd = pixelOffset + 3;
                for (let rgbaChannel = pixelOffset; rgbaChannel < pixelEnd; ++rgbaChannel) {
                    pixelsArray[rgbaChannel] = 255 - pixelsArray[rgbaChannel];
                }
                pixelsArray[pixelEnd] = pixelsArray[3]; //Alpha is not inverted
            }
        }
    }

    private onDisposeMesh(eD: Node, eS: EventState): void {
        this.detach(eD as Mesh);
    }

    private isIntersection(o: any): o is Intersection {
        const i = o as Intersection;
        return i.position instanceof Vector3 && typeof i.distance === 'number' && i.normal instanceof Vector3;
    }

    private waitUntilRenderTargetTextureIsReady(texture: RenderTargetTexture): void {
        if (texture.isReadyForRendering()) return;

        // NOTE: We keep this commented out console.log
        // because its useful to enable it when debugging GPU picking performance. (We want to end up here as little as possible)
        // console.log('Waiting for RTT texture to be ready for rendering');

        // This is a bit of a hack to make sure the texture is ready for rendering. When GPU picking was
        // first implemented it was not known that the RTT texture had to be ready for rendering before
        // rendering to it. The correct way would have been to make the methods, depending on it, async.
        // The reason we have to wait is that shaders etc are compiled async.
        // This is a workaround to avoid breaking the API.
        const maxFrameCount = 60;
        let frameCount = 0;
        for (frameCount = 0; frameCount < maxFrameCount && !texture.isReadyForRendering(); ++frameCount) {
            this.scene.render(false);
        }
        const success = frameCount < maxFrameCount;
        const severityLevel = success ? 0 : 3;
        if (!success) {
            telemetry.trackTrace(
                {
                    message: 'GPU RTT ready check completed.',
                    severityLevel
                },
                {
                    frameCount: frameCount,
                    success,
                    textureName: texture.name
                }
            );
        }
    }
}
