/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

import type { OrthographicCamera, PerspectiveCamera } from 'three';
import type { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

import {
    Clock,
    EventDispatcher,
    Group,
    Object3D,
    Scene,
    Vector2,
    Vector3,
    type ColorRepresentation,
    type WebGLRenderer,
    type WebGLRendererParameters,
} from 'three';

import type Entity from '../entities/Entity';
import type Entity3D from '../entities/Entity3D';
import type RenderingOptions from '../renderer/RenderingOptions';
import type CoordinateSystem from './geographic/CoordinateSystem';
import type PickOptions from './picking/PickOptions';
import type PickResult from './picking/PickResult';
import type Progress from './Progress';

import { isEntity } from '../entities/Entity';
import { isEntity3D } from '../entities/Entity3D';
import C3DEngine from '../renderer/c3DEngine';
import { GlobalRenderTargetPool } from '../renderer/RenderTargetPool';
import View from '../renderer/View';
import { GlobalCache } from './Cache';
import { isDisposable } from './Disposable';
import MainLoop from './MainLoop';
import {
    aggregateMemoryUsage,
    getObject3DMemoryUsage,
    type GetMemoryUsageContext,
    type MemoryUsageReport,
} from './MemoryUsage';
import { isPickable } from './picking/Pickable';
import { isPickableFeatures } from './picking/PickableFeatures';
import pickObjectsAt from './picking/PickObjectsAt';

const vectors = {
    pos: new Vector3(),
    size: new Vector3(),
    evtToCanvas: new Vector2(),
    pickVec2: new Vector2(),
};

/** Frame event payload */
export interface FrameEventPayload {
    /** The frame number. */
    frame: number;
    /** Time elapsed since previous update loop, in milliseconds */
    dt: number;
    /** `true` if the update loop restarted */
    updateLoopRestarted: boolean;
}

/** Entity event payload */
export interface EntityEventPayload {
    /** Entity */
    entity: Entity;
}

/**
 * Events supported by
 * [`Instance.addEventListener()`](https://threejs.org/docs/#api/en/core/EventDispatcher.addEventListener)
 * and
 * [`Instance.removeEventListener()`](https://threejs.org/docs/#api/en/core/EventDispatcher.removeEventListener)
 */
export interface InstanceEvents {
    /**
     * Fires when an entity is added to the instance.
     */
    'entity-added': unknown;
    /**
     * Fires when an entity is removed from the instance.
     */
    'entity-removed': unknown;
    /**
     * Fires at the start of the update
     */
    'update-start': FrameEventPayload;
    /**
     * Fires before the camera update
     */
    'before-camera-update': { camera: View } & FrameEventPayload;
    /**
     * Fires after the camera update
     */
    'after-camera-update': { camera: View } & FrameEventPayload;
    /**
     * Fires before the entity update
     */
    'before-entity-update': EntityEventPayload & FrameEventPayload;
    /**
     * Fires after the entity update
     */
    'after-entity-update': EntityEventPayload & FrameEventPayload;
    /**
     * Fires before the render
     */
    'before-render': FrameEventPayload;
    /**
     * Fires after the render
     */
    'after-render': FrameEventPayload;
    /**
     * Fires at the end of the update
     */
    'update-end': FrameEventPayload;
    'picking-start': unknown;
    'picking-end': {
        /**
         * The duration of the picking, in seconds.
         */
        elapsed: number;
        /**
         * The picking results.
         */
        results?: PickResult<unknown>[];
    };
    /**
     * Fires when the instance is disposed.
     */
    dispose: unknown;
}

/** Options for creating Instance */
export interface InstanceOptions {
    /**
     * The container for the instance. May be either the `id` of an existing `<div>` element,
     * or the element itself.
     */
    target: string | HTMLDivElement;
    /**
     * The coordinate reference system of the scene.
     * Must be a cartesian system.
     * Must first be registered via {@link CoordinateSystem.register}
     */
    crs: CoordinateSystem;
    /**
     * The [Three.js Scene](https://threejs.org/docs/#api/en/scenes/Scene) instance to use,
     * otherwise a default one will be constructed
     */
    scene3D?: Scene;
    /**
     * The background color of the canvas. If `null`, the canvas is transparent.
     * If `undefined`, the default color is used.
     * @defaultValue `'#030508'`
     */
    backgroundColor?: ColorRepresentation | null;
    /**
     * The renderer to use. Might be either an instance of an existing {@link WebGLRenderer},
     * or options to create one. If `undefined`, a new one will be created with default parameters.
     */
    renderer?: WebGLRenderer | WebGLRendererParameters;
    /**
     * The THREE camera to use
     */
    camera?: PerspectiveCamera | OrthographicCamera;
}

/**
 * Options for picking objects from the Giro3D {@link Instance}.
 */
export interface PickObjectsAtOptions extends PickOptions {
    /**
     * List of entities to pick from.
     * If not provided, will pick from all the objects in the scene.
     * Strings consist in the IDs of the object.
     */
    where?: (string | Object3D | Entity)[];
    /**
     * Indicates if the results should be sorted by distance, as Three.js raycasting does.
     * This prevents the `limit` option to be fully used as it is applied after sorting,
     * thus it may be slow and is disabled by default.
     *
     * @defaultValue false
     */
    sortByDistance?: boolean;
    /**
     * Indicates if features information are also retrieved from the picked object.
     * On complex objects, this may be slow, and therefore is disabled by default.
     *
     * @defaultValue false
     */
    pickFeatures?: boolean;
}

function isObject3D(o: unknown): o is Object3D {
    return (o as Object3D).isObject3D;
}

/**
 * The instance is the core component of Giro3D. It encapsulates the 3D scene,
 * the current camera and one or more {@link Entity | entities}. The instance displays
 * the 3D scene in the DOM element specified by the `target` constructor property.
 *
 * ```js
 * // Create a Giro3D instance in the EPSG:3857 coordinate system:
 * const instance = new Instance({
 *     target: 'view',
 *     crs: CoordinateSystem.epsg3857,
 * });
 *
 * const map = new Map(...);
 *
 * // Add an entity to the instance
 * instance.add(map);
 *
 * // Bind an event listener on double click
 * instance.domElement.addEventListener('dblclick', () => console.log('double click!'));
 *
 * // Get the camera position
 * const position = instance.view.camera.position;
 *
 * // Set the camera position to be located 10,000 meters above the center of the coordinate system.
 * instance.view.camera.position.set(0, 0, 10000);
 * instance.view.camera.lookAt(0, 0, 0);
 * ```
 */
class Instance extends EventDispatcher<InstanceEvents> implements Progress {
    private readonly _referenceCrs: CoordinateSystem;
    private readonly _viewport: HTMLDivElement;
    private readonly _mainLoop: MainLoop;
    private readonly _engine: C3DEngine;
    private readonly _scene: Scene;
    private readonly _threeObjects: Group;
    private readonly _view: View;
    private readonly _entities: Set<Entity>;
    private readonly _resizeObserver?: ResizeObserver;
    private readonly _pickingClock: Clock;
    private readonly _onContextRestored: () => void;
    private readonly _onContextLost: () => void;
    private _resizeTimeout?: string | number | NodeJS.Timeout;
    private _disposed = false;

    /**
     * Constructs a Giro3D Instance
     *
     * @param options - Options
     *
     * ```js
     * const instance = new Instance({
     *   target: 'parentElement', // The id of the <div> to attach the instance
     *   crs: CoordinateSystem.epsg3857,
     * });
     * ```
     */
    public constructor(options: InstanceOptions) {
        super();
        Object3D.DEFAULT_UP.set(0, 0, 1);

        const target = options.target;
        let viewerDiv: HTMLElement | null = null;

        if (typeof target === 'string') {
            viewerDiv = document.getElementById(target);
        } else if (options.target instanceof HTMLElement) {
            viewerDiv = options.target;
        }

        if (!viewerDiv || !(viewerDiv instanceof HTMLDivElement)) {
            throw new Error('Invalid target parameter (must be a valid <div>)');
        }
        if (viewerDiv.childElementCount > 0) {
            console.warn(
                'Target element has children; Giro3D expects an empty element - this can lead to unexpected behaviors',
            );
        }

        this._referenceCrs = options.crs;
        this._viewport = viewerDiv;

        // viewerDiv may have padding/borders, which is annoying when retrieving its size
        // Wrap our canvas in a new div so we make sure the display
        // is correct whatever the page layout is
        // (especially when skrinking so there is no scrollbar/bleading)
        this._viewport = document.createElement('div');
        this._viewport.style.position = 'relative';
        this._viewport.style.overflow = 'hidden'; // Hide overflow during resizing
        this._viewport.style.width = '100%'; // Make sure it fills the space
        this._viewport.style.height = '100%';
        viewerDiv.appendChild(this._viewport);

        this._engine = new C3DEngine(this._viewport, {
            clearColor: options.backgroundColor,
            renderer: options.renderer,
        });

        this._mainLoop = new MainLoop();

        this._scene = options.scene3D || new Scene();
        // will contain simple three objects that need to be taken into
        // account, for example camera near / far calculation maybe it'll be
        // better to do the contrary: having a group where *all* the Giro3D
        // object will be added, and traverse all other objects for near far
        // calculation but actually I'm not even sure near far calculation is
        // worthy of this.
        this._threeObjects = new Group();
        this._threeObjects.name = 'threeObjects';

        this._scene.add(this._threeObjects);
        if (!options.scene3D) {
            this._scene.matrixWorldAutoUpdate = false;
        }

        const windowSize = this._engine.getWindowSize();

        this._view = new View({
            crs: this._referenceCrs,
            camera: options.camera,
            width: windowSize.width,
            height: windowSize.height,
        });

        this._view.addEventListener('change', () => this.notifyChange(this._view.camera));

        this._entities = new Set();

        if (window.ResizeObserver != null) {
            this._resizeObserver = new ResizeObserver(() => {
                this._updateRendererSize(this.viewport);
            });
            this._resizeObserver.observe(viewerDiv);
        }

        this._pickingClock = new Clock(false);

        this._onContextRestored = this.onContextRestored.bind(this);
        this._onContextLost = this.onContextLost.bind(this);
        this.domElement.addEventListener('webglcontextlost', this._onContextLost);
        this.domElement.addEventListener('webglcontextrestored', this._onContextRestored);
    }

    private onContextLost(): void {
        this.getEntities().forEach(entity => {
            if (isEntity3D(entity)) {
                entity.onRenderingContextLost({ canvas: this.domElement });
            }
        });
    }

    private onContextRestored(): void {
        this.getEntities().forEach(entity => {
            if (isEntity3D(entity)) {
                entity.onRenderingContextRestored({ canvas: this.domElement });
            }
        });
        this.notifyChange();
    }

    /** Gets the canvas that this instance renders into. */
    public get domElement(): HTMLCanvasElement {
        return this._engine.renderer.domElement;
    }

    /** Gets the DOM element that contains the Giro3D viewport. */
    public get viewport(): HTMLDivElement {
        return this._viewport;
    }

    /** Gets the CRS used in this instance. */
    public get coordinateSystem(): CoordinateSystem {
        return this._referenceCrs;
    }

    /** Gets whether at least one entity is currently loading data. */
    public get loading(): boolean {
        const entities = this.getEntities();
        return entities.some(e => e.loading);
    }

    /**
     * Gets the progress (between 0 and 1) of the processing of the entire instance.
     * This is the average of the progress values of all entities.
     * Note: This value is only meaningful is {@link loading} is `true`.
     * Note: if no entity is present in the instance, this will always return 1.
     */
    public get progress(): number {
        const entities = this.getEntities();
        if (entities.length === 0) {
            return 1;
        }
        const sum = entities.reduce((accum, entity) => accum + entity.progress, 0);
        return sum / entities.length;
    }

    /** Gets the main loop */
    public get mainLoop(): MainLoop {
        return this._mainLoop;
    }

    /** Gets the rendering engine */
    public get engine(): C3DEngine {
        return this._engine;
    }

    /**
     * Gets the rendering options.
     *
     * Note: you must call {@link notifyChange | notifyChange()} to take
     * the changes into account.
     */
    public get renderingOptions(): RenderingOptions {
        return this._engine.renderingOptions;
    }

    /**
     * Gets the underlying WebGL renderer.
     */
    public get renderer(): WebGLRenderer {
        return this._engine.renderer;
    }

    /**
     * Gets the underlying CSS2DRenderer.
     */
    public get css2DRenderer(): CSS2DRenderer {
        return this._engine.labelRenderer;
    }

    /** Gets the [3D Scene](https://threejs.org/docs/#api/en/scenes/Scene). */
    public get scene(): Scene {
        return this._scene;
    }

    /** Gets the group containing native Three.js objects. */
    public get threeObjects(): Group {
        return this._threeObjects;
    }

    /** Gets the view. */
    public get view(): View {
        return this._view;
    }

    private _doUpdateRendererSize(div: HTMLDivElement): void {
        this._engine.onWindowResize(div.clientWidth, div.clientHeight);
        this.notifyChange(this._view.camera);
    }

    private _updateRendererSize(div: HTMLDivElement): void {
        // Each time a canvas is resized, its content is erased and must be re-rendered.
        // Since we are only interested in the last size, we must discard intermediate
        // resizes to avoid the flickering effect due to the canvas going blank.

        if (this._resizeTimeout != null) {
            // If there's already a timeout in progress, discard it
            clearTimeout(this._resizeTimeout);
        }

        // And add another one
        this._resizeTimeout = setTimeout(() => this._doUpdateRendererSize(div), 50);
    }

    /**
     * Dispose of this instance object. Free all memory used.
     *
     * Note: this *will not* dispose the following reusable objects:
     * - controls (because they can be attached and detached). For THREE.js controls, use
     * `controls.dispose()`
     * - Inspectors, use `inspector.detach()`
     * - any openlayers objects, please see their individual documentation
     *
     */
    public dispose(): void {
        if (this._disposed) {
            return;
        }
        this._disposed = true;

        this.domElement.removeEventListener('webglcontextlost', this._onContextLost);
        this.domElement.removeEventListener('webglcontextrestored', this._onContextRestored);

        this._resizeObserver?.disconnect();
        for (const obj of this.getObjects()) {
            this.remove(obj);
        }
        this._scene.remove(this._threeObjects);

        this._engine.dispose();
        this._view.dispose();
        this.viewport.remove();

        this.dispatchEvent({ type: 'dispose' });
    }

    /**
     * Add THREE object or Entity to the instance.
     *
     * If the object or entity has no parent, it will be added to the default tree (i.e under
     * `.scene` for entities and under `.threeObjects` for regular Object3Ds.).
     *
     * If the object or entity already has a parent, then it will not be changed. Check that this
     * parent is present in the scene graph (i.e has the `.scene` object as ancestor), otherwise it
     * will **never be displayed**.
     *
     * @example
     * // Add Map to instance
     * instance.add(new Map(...);
     *
     * // Add Map to instance then wait for the map to be ready.
     * instance.add(new Map(...).then(...);
     * @param object - the object to add
     * @returns a promise resolved with the new layer object when it is fully initialized
     * or rejected if any error occurred.
     */
    public async add<T extends Object3D | Entity>(object: T): Promise<T> {
        if (object == null) {
            throw new Error('object is undefined');
        }

        if (!isObject3D(object) && !isEntity(object)) {
            throw new Error('object is not an instance of THREE.Object3D or Giro3D.Entity');
        }

        if (isObject3D(object)) {
            // case of a simple THREE.js object3D
            const object3d = object as Object3D;
            if (object.parent == null) {
                // Add to default scene graph
                this._threeObjects.add(object3d);
            }
            this.notifyChange(object3d);
            return object3d as T;
        }

        // We know it's an Entity
        const entity = object as Entity;

        const duplicate = this.getObjects(l => l.id === object.id);
        if (duplicate.length > 0) {
            throw new Error(`Invalid id '${object.id}': id already used`);
        }

        this._entities.add(entity);

        await entity.initialize({
            instance: this,
        });

        if (
            isEntity3D(entity) &&
            entity.object3d != null &&
            entity.object3d.parent == null &&
            entity.object3d !== this._scene
        ) {
            // Add to default scene graph
            this._scene.add(entity.object3d);
        }

        this.notifyChange(object, { needsRedraw: false });
        this.dispatchEvent({ type: 'entity-added' });
        return object;
    }

    /**
     * Removes the entity or THREE object from the scene.
     *
     * @param object - the object to remove.
     */
    public remove(object: Object3D | Entity): void {
        if (isDisposable(object)) {
            object.dispose();
        }

        if (isEntity(object)) {
            if (isEntity3D(object)) {
                object.object3d.removeFromParent();
            }

            this._entities.delete(object);

            this.dispatchEvent({ type: 'entity-removed' });
        } else if (isObject3D(object)) {
            object.removeFromParent();
        }

        this.notifyChange(this._view.camera);
    }

    /**
     * Notifies the scene it needs to be updated due to changes exterior to the
     * scene itself (e.g. camera movement).
     * non-interactive events (e.g: texture loaded)
     *
     * @param changeSources - The source(s) of the change. Might be a single object or an array.
     * @param options - Notification options.
     */
    public notifyChange(
        changeSources: unknown | unknown[] = undefined,
        options?: {
            /**
             * Should we render the scene?
             * @defaultValue true
             */
            needsRedraw?: boolean;
            /**
             * Should the update be immediate? If `false`, the update is deferred to the
             * next animation frame.
             * @defaultValue false
             */
            immediate?: boolean;
        },
    ): void {
        this._mainLoop.scheduleUpdate(this, changeSources, options);
    }

    /**
     * Get all top-level objects (entities and regular THREE objects), using an optional filter
     * predicate.
     *
     * ```js
     * // get all objects
     * const allObjects = instance.getObjects();
     * // get all object whose name includes 'foo'
     * const fooObjects = instance.getObjects(obj => obj.name === 'foo');
     * ```
     * @param filter - the optional filter predicate.
     * @returns an array containing the queried objects
     */
    public getObjects(filter?: (obj: Object3D | Entity) => boolean): (Object3D | Entity)[] {
        const result = [];
        for (const obj of this._entities) {
            if (!filter || filter(obj)) {
                result.push(obj);
            }
        }
        for (const obj of this._threeObjects.children) {
            if (!filter || filter(obj)) {
                result.push(obj);
            }
        }
        return result;
    }

    /**
     * Get all entities, with an optional predicate applied.
     *
     * ```js
     * // get all entities
     * const allEntities = instance.getEntities();
     *
     * // get all entities whose name contains 'building'
     * const buildings = instance.getEntities(entity => entity.name.includes('building'));
     * ```
     * @param filter - the optional filter predicate
     * @returns an array containing the queried entities
     */
    public getEntities(filter?: (obj: Entity) => boolean): Entity[] {
        const result = [];

        for (const obj of this._entities) {
            if (!filter || filter(obj)) {
                result.push(obj);
            }
        }

        return result;
    }

    /**
     * Executes the rendering.
     * Internal use only.
     *
     * @internal
     */
    public render(): void {
        this._engine.render(this._scene, this._view.camera);
    }

    /**
     * Extract canvas coordinates from a mouse-event / touch-event.
     *
     * @param event - event can be a MouseEvent or a TouchEvent
     * @param target - The target to set with the result.
     * @param touchIdx - Touch index when using a TouchEvent (default: 0)
     * @returns canvas coordinates (in pixels, 0-0 = top-left of the instance)
     */
    public eventToCanvasCoords(
        event: MouseEvent | TouchEvent,
        target: Vector2,
        touchIdx = 0,
    ): Vector2 {
        if (window.TouchEvent != null && event instanceof TouchEvent) {
            const touchEvent = event as TouchEvent;
            const br = this.domElement.getBoundingClientRect();
            return target.set(
                touchEvent.touches[touchIdx].clientX - br.x,
                touchEvent.touches[touchIdx].clientY - br.y,
            );
        }

        const mouseEvent = event as MouseEvent;

        if (mouseEvent.target === this.domElement) {
            return target.set(mouseEvent.offsetX, mouseEvent.offsetY);
        }

        // Event was triggered outside of the canvas, probably a CSS2DElement
        const br = this.domElement.getBoundingClientRect();
        return target.set(mouseEvent.clientX - br.x, mouseEvent.clientY - br.y);
    }

    /**
     * Extract normalized coordinates (NDC) from a mouse-event / touch-event.
     *
     * @param event - event can be a MouseEvent or a TouchEvent
     * @param target - The target to set with the result.
     * @param touchIdx - Touch index when using a TouchEvent (default: 0)
     * @returns NDC coordinates (x and y are [-1, 1])
     */
    public eventToNormalizedCoords(
        event: MouseEvent | TouchEvent,
        target: Vector2,
        touchIdx = 0,
    ): Vector2 {
        return this.canvasToNormalizedCoords(
            this.eventToCanvasCoords(event, target, touchIdx),
            target,
        );
    }

    /**
     * Convert canvas coordinates to normalized device coordinates (NDC).
     *
     * @param canvasCoords - (in pixels, 0-0 = top-left of the instance)
     * @param target - The target to set with the result.
     * @returns NDC coordinates (x and y are [-1, 1])
     */
    public canvasToNormalizedCoords(canvasCoords: Vector2, target: Vector2): Vector2 {
        target.x = 2 * (canvasCoords.x / this._view.width) - 1;
        target.y = -2 * (canvasCoords.y / this._view.height) + 1;
        return target;
    }

    /**
     * Convert NDC coordinates to canvas coordinates.
     *
     * @param ndcCoords - The NDC coordinates to convert
     * @param target - The target to set with the result.
     * @returns canvas coordinates (in pixels, 0-0 = top-left of the instance)
     */
    public normalizedToCanvasCoords(ndcCoords: Vector2, target: Vector2): Vector2 {
        target.x = (ndcCoords.x + 1) * 0.5 * this._view.width;
        target.y = (ndcCoords.y - 1) * -0.5 * this._view.height;
        return target;
    }

    /**
     * Gets the object by it's id property.
     *
     * @param objectId - Object id
     * @returns Object found
     * @throws Error if object cannot be found
     */
    private objectIdToObject(objectId: string | number): Object3D | Entity {
        const lookup = this.getObjects(l => l.id === objectId);
        if (!lookup.length) {
            throw new Error(`Invalid object id used as where argument (value = ${objectId})`);
        }
        return lookup[0];
    }

    /**
     * Return objects from some layers/objects3d under the mouse in this instance.
     *
     * @param mouseOrEvt - mouse position in window coordinates, i.e [0, 0] = top-left,
     * or `MouseEvent` or `TouchEvent`
     * @param options - Options
     * @returns An array of objects. Each element contains at least an object
     * property which is the Object3D under the cursor. Then depending on the queried
     * layer/source, there may be additionnal properties (coming from THREE.Raycaster
     * for instance).
     * If `options.pickFeatures` if `true`, `features` property may be set.
     *
     * ```js
     * instance.pickObjectsAt(mouseEvent)
     * instance.pickObjectsAt(mouseEvent, { radius: 1, where: [entity0, entity1] })
     * instance.pickObjectsAt(mouseEvent, { radius: 3, where: [entity0] })
     * ```
     */
    public pickObjectsAt(
        mouseOrEvt: Vector2 | MouseEvent | TouchEvent,
        options: PickObjectsAtOptions = {},
    ): PickResult[] {
        this.dispatchEvent({ type: 'picking-start' });
        this._pickingClock.start();

        let results: PickResult[] = [];
        const sources =
            options.where && options.where.length > 0 ? [...options.where] : this.getObjects();
        const mouse =
            mouseOrEvt instanceof Event
                ? this.eventToCanvasCoords(mouseOrEvt, vectors.evtToCanvas)
                : mouseOrEvt;
        const radius = options.radius ?? 0;
        const limit = options.limit ?? Infinity;
        const sortByDistance = options.sortByDistance ?? false;
        const pickFeatures = options.pickFeatures ?? false;

        for (const source of sources) {
            const object = typeof source === 'string' ? this.objectIdToObject(source) : source;

            if (!(object as Object3D | Entity3D).visible) {
                continue;
            }

            const pickOptions = {
                ...options,
                radius,
                limit: limit - results.length,
                vec2: vectors.pickVec2,
                sortByDistance: false,
            };
            if (sortByDistance) {
                pickOptions.limit = Infinity;
                pickOptions.pickFeatures = false;
            }

            if (isPickable(object)) {
                const res = object.pick(mouse, pickOptions);
                results.push(...res);
            } else if ((object as Object3D).isObject3D) {
                const res = pickObjectsAt(this, mouse, object as Object3D, pickOptions);
                results.push(...res);
            }

            if (results.length >= limit && !sortByDistance) {
                break;
            }
        }

        if (sortByDistance) {
            results.sort((a, b) => a.distance - b.distance);
            if (limit !== Infinity) {
                results = results.slice(0, limit);
            }
        }

        if (pickFeatures) {
            const pickFeaturesOptions = options;

            results.forEach(result => {
                if (result.entity && isPickableFeatures(result.entity)) {
                    result.entity.pickFeaturesFrom(result, pickFeaturesOptions);
                } else if (result.object != null && isPickableFeatures(result.object)) {
                    result.object.pickFeaturesFrom(result, pickFeaturesOptions);
                }
            });
        }

        const elapsed = this._pickingClock.getElapsedTime();
        this._pickingClock.stop();
        this.dispatchEvent({ type: 'picking-end', elapsed, results });

        return results;
    }

    public getMemoryUsage(): MemoryUsageReport {
        const context: GetMemoryUsageContext = {
            renderer: this.renderer,
            objects: new globalThis.Map(),
        };

        for (const entity of this._entities) {
            if (isEntity3D(entity)) {
                entity.getMemoryUsage(context);
            }
        }

        this.threeObjects.traverse(obj => {
            getObject3DMemoryUsage(context, obj);
        });

        GlobalRenderTargetPool.getMemoryUsage(context);

        GlobalCache.getMemoryUsage(context);

        return aggregateMemoryUsage(context);
    }
}

export default Instance;
