import { BatchedMesh, BufferGeometry, Color, Material, Matrix4, Mesh, MeshStandardMaterial, Object3D, RawShaderMaterial } from "three";

import { isDevEnvironment, showBalloonError } from "../engine/debug/index.js";
import { Gizmos } from "../engine/engine_gizmos.js";
import { $instancingAutoUpdateBounds, $instancingRenderer, NEED_UPDATE_INSTANCE_KEY } from "../engine/engine_instancing.js";
import { Context } from "../engine/engine_setup.js";
import { getParam, makeIdFromRandomWords } from "../engine/engine_utils.js";
import { NEEDLE_progressive } from "../engine/extensions/index.js";
import { GameObject } from "./Component.js";
import type { Renderer } from "./Renderer.js";

const debugInstancing = getParam("debuginstancing");

declare class InstancingSetupArgs {
    rend: Renderer;
    foundMeshes: number;
    useMatrixWorldAutoUpdate: boolean;
};

/**
 * Handles instancing for Needle Engine.
 */
export class InstancingHandler {
    static readonly instance: InstancingHandler = new InstancingHandler();

    /** This is the initial instance count when creating a new instancing structure.    
     * Override this and the number of max instances that you expect for a given object.
     * The larger the value the more objects can be added without having to resize but it will also consume more memory.    
     * (The instancing mesh renderer will grow x2 if the max instance count is reached)
     * @default 4
     * @returns The initial instance count
     */
    // @ts-ignore (ignore the unused parameter warning)
    static getStartInstanceCount = (obj: Object3D) => {
        return 4;
    };

    public objs: InstancedMeshRenderer[] = [];

    public setup(renderer: Renderer, obj: Object3D, context: Context, handlesArray: InstanceHandle[] | null, args: InstancingSetupArgs, level: number = 0)
        : InstanceHandle[] | null {

        // make sure setting casting settings are applied so when we add the mesh to the InstancedMesh we can ask for the correct cast shadow setting
        renderer.applySettings(obj);
        const res = this.tryCreateOrAddInstance(obj, context, args);
        if (res) {
            if (handlesArray === null) handlesArray = [];
            handlesArray.push(res);

            // Load LOD for textures
            const mat = res.object.material;
            if(Array.isArray(mat)) {
                mat.forEach(m => NEEDLE_progressive.assignTextureLOD(m, 0));
            }
            else {
                NEEDLE_progressive.assignTextureLOD(mat, 0);
            }

            // Load LOD for geometry
            const mesh = res.object;
            const geometry = mesh.geometry;
            NEEDLE_progressive.assignMeshLOD(mesh, 0).then(lod => {
                if (lod && geometry != lod) {
                    res.setGeometry(lod);
                }
            });
        }

        else if (level <= 0 && obj.type !== "Mesh") {
            const nextLevel = level + 1;
            for (const ch of obj.children) {
                handlesArray = this.setup(renderer, ch, context, handlesArray, args, nextLevel);
            }
        }

        if (level === 0) {
            // For multi material objects we only want to track the root object's matrix
            if (args.useMatrixWorldAutoUpdate && handlesArray && handlesArray.length >= 0) {
                this.autoUpdateInstanceMatrix(obj);
            }
        }

        return handlesArray;
    }

    private tryCreateOrAddInstance(obj: Object3D, context: Context, args: InstancingSetupArgs): InstanceHandle | null {
        if (obj.type === "Mesh") {
            const index = args.foundMeshes;
            args.foundMeshes += 1;
            if (!args.rend.enableInstancing) return null;
            if (args.rend.enableInstancing === true) {
                // instancing is enabled globally
                // continue....
            }
            else {
                if (index >= args.rend.enableInstancing.length) {
                    if (debugInstancing) console.error("Something is wrong with instance setup", obj, args.rend.enableInstancing, index);
                    return null;
                }
                if (!args.rend.enableInstancing[index]) {
                    // instancing is disabled
                    // console.log("Instancing is disabled", obj);
                    return null;
                }
            }
            // instancing is enabled:
            const mesh = obj as Mesh;
            // const geo = mesh.geometry as BufferGeometry;
            const mat = mesh.material as Material;

            for (const i of this.objs) {
                if (!i.canAdd(mesh.geometry, mat)) continue;
                const handle = i.addInstance(mesh);
                return handle;
            }
            let maxInstances = InstancingHandler.getStartInstanceCount(obj);
            if (!maxInstances || maxInstances < 0) {
                maxInstances = 4;
            }
            let name = obj.name;
            if (!name?.length) name = makeIdFromRandomWords();
            const i = new InstancedMeshRenderer(name, mesh.geometry, mat, maxInstances, context);
            this.objs.push(i);
            const handle = i.addInstance(mesh);
            return handle;
        }
        return null;
    }

    private autoUpdateInstanceMatrix(obj: Object3D) {
        const original = obj.matrixWorld["multiplyMatrices"].bind(obj.matrixWorld);
        const previousMatrix: Matrix4 = obj.matrixWorld.clone();
        const matrixChangeWrapper = (a: Matrix4, b: Matrix4) => {
            const newMatrixWorld = original(a, b);
            if (obj[NEED_UPDATE_INSTANCE_KEY] || previousMatrix.equals(newMatrixWorld) === false) {
                previousMatrix.copy(newMatrixWorld)
                obj[NEED_UPDATE_INSTANCE_KEY] = true;
            }
            return newMatrixWorld;
        };
        obj.matrixWorld["multiplyMatrices"] = matrixChangeWrapper;
        // wrap matrixWorldNeedsUpdate
        // let originalMatrixWorldNeedsUpdate = obj.matrixWorldNeedsUpdate;
        // Object.defineProperty(obj, "matrixWorldNeedsUpdate", {
        //     get: () => {
        //         return originalMatrixWorldNeedsUpdate;
        //     },
        //     set: (value: boolean) => {
        //         if(value) console.warn("SET MATRIX WORLD NEEDS UPDATE");
        //         originalMatrixWorldNeedsUpdate = value;
        //     }
        // });
    }
}

/**
 * The instance handle is used to interface with the mesh that is rendered using instancing.
 */
export class InstanceHandle {

    static readonly all: InstanceHandle[] = [];

    /** The name of the object */
    get name(): string {
        return this.object.name;
    }
    get isActive() {
        return this.__instanceIndex >= 0;
    }
    get vertexCount() {
        return this.object.geometry.attributes.position.count;
    }
    get maxVertexCount() {
        // we get the max of the estimated MAX lod vertex count and the actual vertex count
        return Math.max(this.meshInformation.vertexCount, this.vertexCount);
    }
    get reservedVertexCount() {
        return this.__reservedVertexRange;
    }
    get indexCount() {
        return this.object.geometry.index ? this.object.geometry.index.count : 0;
    }
    get maxIndexCount() {
        // we get the max of the estimated MAX lod index count and the actual index count
        return Math.max(this.meshInformation.indexCount, this.indexCount);
    }
    get reservedIndexCount() {
        return this.__reservedIndexRange;
    }

    /** The object that is being instanced */
    readonly object: Mesh;

    /** The instancer/BatchedMesh that is rendering this object*/
    readonly renderer: InstancedMeshRenderer;

    /** @internal */
    __instanceIndex: number = -1;
    /** @internal */
    __reservedVertexRange: number = 0;
    /** @internal */
    __reservedIndexRange: number = 0;
    __geometryIndex: number = -1;

    /** The mesh information of the object - this tries to also calculate the LOD info */
    readonly meshInformation: MeshInformation;

    constructor(originalObject: Mesh, instancer: InstancedMeshRenderer) {
        this.__instanceIndex = -1;
        this.object = originalObject;
        this.renderer = instancer;
        originalObject[$instancingRenderer] = instancer;
        // TODO: this doesn't have LOD information *yet* in some cases - hence we can not rely on it for the max vertex counts
        this.meshInformation = getMeshInformation(originalObject.geometry);
        InstanceHandle.all.push(this);
    }

    /** Calculates the mesh information again
     * @returns true if the vertex count or index count has changed
     */
    updateMeshInformation(): boolean {
        const newMeshInformation = getMeshInformation(this.object.geometry);
        const oldVertexCount = this.meshInformation.vertexCount;
        const oldIndexCount = this.meshInformation.indexCount;
        Object.assign(this.meshInformation, newMeshInformation);
        return oldVertexCount !== this.meshInformation.vertexCount || oldIndexCount !== this.meshInformation.indexCount;
    }

    /** Updates the matrix from the rendered object. Will also call updateWorldMatrix internally */
    updateInstanceMatrix(updateChildren: boolean = false, updateMatrix: boolean = true) {
        if (this.__instanceIndex < 0) return;
        if (updateMatrix) this.object.updateWorldMatrix(true, updateChildren);
        this.renderer.updateInstance(this.object.matrixWorld, this.__instanceIndex);
    }
    /** Updates the matrix of the instance */
    setMatrix(matrix: Matrix4) {
        if (this.__instanceIndex < 0) return;
        this.renderer.updateInstance(matrix, this.__instanceIndex);
    }

    /** Can be used to change the geometry of this instance */
    setGeometry(geo: BufferGeometry) {
        if (this.__geometryIndex < 0) return false;

        const self = this;

        if (this.vertexCount > this.__reservedVertexRange) {
            return handleInvalidRange(`Instancing: Can not update geometry (${this.name}), reserved vertex range is too small: ${this.__reservedVertexRange.toLocaleString()} < ${this.vertexCount.toLocaleString()} vertices for ${this.name}`);

        }
        if (this.indexCount > this.__reservedIndexRange) {
            return handleInvalidRange(`Instancing: Can not update geometry (${this.name}), reserved index range is too small: ${this.__reservedIndexRange.toLocaleString()} < ${this.indexCount.toLocaleString()} indices for ${this.name}`);
        }


        return this.renderer.updateGeometry(geo, this.__geometryIndex);

        function handleInvalidRange(error: string): boolean {
            if (self.updateMeshInformation()) {
                // Gizmos.DrawWireSphere(self.object.worldPosition, .5, 0xff0000, 5);
                self.renderer.remove(self, true);
                // Gizmos.DrawWireSphere(self.object.worldPosition, .5, 0x33ff00, 5);
                // self.object.scale.multiplyScalar(10);
                if (self.renderer.add(self)) {
                    return true;
                }
            }
            if (isDevEnvironment() || debugInstancing) {
                console.error(error);
            }
            return false;
        }
    }

    /** Adds this object to the instancing renderer (effectively activating instancing) */
    add() {
        if (this.__instanceIndex >= 0) return;
        this.renderer.add(this);
        GameObject.markAsInstancedRendered(this.object, true);
    }

    /** Removes this object from the instancing renderer 
     * @param delete_ If true, the instance handle will be removed from the global list
    */
    remove(delete_: boolean) {
        if (this.__instanceIndex < 0) return;
        this.renderer.remove(this, delete_);
        GameObject.markAsInstancedRendered(this.object, false);
        if (delete_) {
            const i = InstanceHandle.all.indexOf(this);
            if (i >= 0) {
                InstanceHandle.all.splice(i, 1);
            }
        }
    }
}

class InstancedMeshRenderer {
    /** The three instanced mesh
     * @link https://threejs.org/docs/#api/en/objects/InstancedMesh
     */
    get batchedMesh() {
        return this._batchedMesh;
    }
    get visible(): boolean {
        return this._batchedMesh.visible;
    }
    set visible(val: boolean) {
        this._batchedMesh.visible = val;
    }
    get castShadow(): boolean {
        return this._batchedMesh.castShadow;
    }
    set castShadow(val: boolean) {
        this._batchedMesh.castShadow = val;
    }
    set receiveShadow(val: boolean) {
        this._batchedMesh.receiveShadow = val;
    }

    /** If true, the instancer is allowed to grow when the max instance count is reached */
    allowResize: boolean = true;

    /** The name of the instancer */
    name: string = "";

    /** The added geometry */
    readonly geometry: BufferGeometry;

    /** The material used for the instanced mesh */
    readonly material: Material;

    /** The current number of instances */
    get count(): number { return this._currentInstanceCount; }

    /** Update the bounding box and sphere of the instanced mesh 
     * @param box If true, update the bounding box
     * @param sphere If true, update the bounding sphere
    */
    updateBounds(box: boolean = true, sphere: boolean = true) {
        this._needUpdateBounds = false;
        if (box)
            this._batchedMesh.computeBoundingBox();
        if (sphere)
            this._batchedMesh.computeBoundingSphere();
        if (debugInstancing && this._batchedMesh.boundingSphere) {
            const sphere = this._batchedMesh.boundingSphere;
            // const worldPos = this._batchedMesh.worldPosition.add(sphere.center);
            // const worldRadius = sphere!.radius;
            Gizmos.DrawWireSphere(sphere.center, sphere.radius, 0x00ff00);
        }
    }

    private _context: Context;
    private _batchedMesh: BatchedMesh;
    private _handles: (InstanceHandle | null)[] = [];
    private _geometryIds = new WeakMap<BufferGeometry, number>();
    private _maxInstanceCount: number;

    private _currentInstanceCount = 0;
    private _currentVertexCount = 0;
    private _currentIndexCount = 0;

    private _maxVertexCount: number;
    private _maxIndexCount: number;

    private static nullMatrix: Matrix4 = new Matrix4();

    /** Check if the geometry can be added to this instancer
     * @param geometry The geometry to check
     * @param material The material of the geometry
     * @returns true if the geometry can be added
     */
    canAdd(geometry: BufferGeometry, material: Material): boolean {

        if (this._maxVertexCount > 10_000_000) return false;

        // The material instance must match
        // perhaps at some point later we *could* check if it's the same shader and properties but this would be risky
        if (material !== this.material) {
            const canMergeMaterial = false;
            // if (material.type === this.material.type) {
            //     switch (material.type) {
            //         case "MeshStandardMaterial":
            //             // check if the material properties are the same
            //             const m0 = this.material as MeshStandardMaterial;
            //             const m1 = material as MeshStandardMaterial;
            //             if(m0.map !== m1.map) return false;
            //             // if(m0.color.equals(m1.color) === false) return false;
            //             if(m0.roughness !== m1.roughness) return false;
            //             if(m0.metalness !== m1.metalness) return false;
            //             if(m0.envMap !== m1.envMap) return false;
            //             if(m0.envMapIntensity !== m1.envMapIntensity) return false;
            //             if(m0.lightMap !== m1.lightMap) return false;
            //             if(m0.lightMapIntensity !== m1.lightMapIntensity) return false;
            //             if(m0.aoMap !== m1.aoMap) return false;
            //             if(m0.aoMapIntensity !== m1.aoMapIntensity) return false;
            //             if(m0.emissive.equals(m1.emissive) === false) return false;
            //             if(m0.emissiveIntensity !== m1.emissiveIntensity) return false;
            //             if(m0.emissiveMap !== m1.emissiveMap) return false;
            //             if(m0.bumpMap !== m1.bumpMap) return false;
            //             if(m0.bumpScale !== m1.bumpScale) return false;
            //             if(m0.normalMap !== m1.normalMap) return false;
            //             if(m0.normalScale.equals(m1.normalScale) === false) return false;
            //             if(m0.displacementMap !== m1.displacementMap) return false;
            //             if(m0.displacementScale !== m1.displacementScale) return false;
            //             if(m0.displacementBias !== m1.displacementBias) return false;
            //             if(m0.roughnessMap !== m1.roughnessMap) return false;
            //             if(m0.metalnessMap !== m1.metalnessMap) return false;
            //             if(m0.alphaMap !== m1.alphaMap) return false;
            //             if(m0.envMapIntensity !== m1.envMapIntensity) return false;
            //             canMergeMaterial = true;
            //             break;
            //     }
            // }
            if (!canMergeMaterial) {
                return false;
            }
        }

        // if(this.geometry !== _geometry) return false;

        // console.log(geometry.name, geometry.uuid);

        if (!this.validateGeometry(geometry)) return false;

        // const validationMethod = this.inst["_validateGeometry"];
        // if (!validationMethod) throw new Error("InstancedMesh does not have a _validateGeometry method");
        // try {
        //     validationMethod.call(this.inst, _geometry);
        // }
        // catch (err) {
        //     // console.error(err);
        //     return false;
        // }

        const hasSpace = !this.mustGrow(geometry);
        if (hasSpace) return true;
        if (this.allowResize) return true;

        return false;
    }

    private _needUpdateBounds: boolean = false;
    private _debugMaterial: MeshStandardMaterial | null = null;

    private getBatchedMeshName() {
        return this.name ? `${this.name} (BatchedMesh)` : "BatchedMesh";
    }

    constructor(name: string, geo: BufferGeometry, material: Material, initialMaxCount: number, context: Context) {
        this.name = name;
        this.geometry = geo;
        this.material = material;
        this._context = context;
        this._maxInstanceCount = Math.max(2, initialMaxCount);
        if (debugInstancing) {
            this._debugMaterial = createDebugMaterial();
        }
        const estimate = this.tryEstimateVertexCountSize(this._maxInstanceCount, [geo], initialMaxCount);
        this._maxVertexCount = estimate.vertexCount;
        this._maxIndexCount = estimate.indexCount;
        this._batchedMesh = new BatchedMesh(this._maxInstanceCount, this._maxVertexCount, this._maxIndexCount, this._debugMaterial ?? this.material);
        this._batchedMesh.name = this.getBatchedMeshName();
        // this.inst = new InstancedMesh(geo, material, count);
        this._batchedMesh[$instancingAutoUpdateBounds] = true;
        // this.inst.count = 0;
        this._batchedMesh.visible = true;
        this._context.scene.add(this._batchedMesh);

        // Not handled by RawShaderMaterial, so we need to set the define explicitly.
        // Edge case: theoretically some users of the material could use it in an
        // instanced fashion, and some others not. In that case, the material would not
        // be able to be shared between the two use cases. We could probably add a
        // onBeforeRender call for the InstancedMesh and set the define there.
        // Same would apply if we support skinning - 
        // there we would have to split instanced batches so that the ones using skinning
        // are all in the same batch.
        if (material instanceof RawShaderMaterial) {
            material.defines["USE_INSTANCING"] = true;
            material.needsUpdate = true;
        }

        context.pre_render_callbacks.push(this.onBeforeRender);
        context.post_render_callbacks.push(this.onAfterRender);

        if (debugInstancing) {
            console.log(`Instanced renderer (${this.name}) created with ${this._maxInstanceCount} instances, ${this._maxVertexCount} max vertices and ${this._maxIndexCount} max indices for \"${name}\"`)
        }
    }

    dispose() {
        if (debugInstancing) console.warn("Dispose instanced renderer", this.name);
        this._context.scene.remove(this._batchedMesh);
        this._batchedMesh.dispose();
        this._batchedMesh = null as any;
        this._handles = [];
    }

    addInstance(obj: Mesh): InstanceHandle | null {

        const handle = new InstanceHandle(obj, this);

        if (obj.castShadow === true && this._batchedMesh.castShadow === false) {
            this._batchedMesh.castShadow = true;
        }
        if (obj.receiveShadow === true && this._batchedMesh.receiveShadow === false) {
            this._batchedMesh.receiveShadow = true;
        }

        try {
            this.add(handle);
        }
        catch (e) {
            console.error(`Failed adding mesh to instancing (object name: \"${obj.name}\", instances: ${this._currentInstanceCount.toLocaleString()}/${this._maxInstanceCount.toLocaleString()}, vertices: ${this._currentVertexCount.toLocaleString()}/${this._maxVertexCount.toLocaleString()}, indices: ${this._currentIndexCount.toLocaleString()}/${this._maxIndexCount.toLocaleString()})\n`, e);
            if (isDevEnvironment()) {
                showBalloonError("Failed instancing mesh. See the browser console for details.");
                debugger;
            }
            return null;
        }

        return handle;
    }


    add(handle: InstanceHandle) {
        const geo = handle.object.geometry as BufferGeometry;

        if (!geo || !geo.attributes) {
            console.error("Cannot add object to instancing without geometry", handle.name);
            return false;
        }

        const newInstanceCount = this._currentInstanceCount + 1;
        if (newInstanceCount > this._maxInstanceCount || this.mustGrow(geo)) {
            if (this.allowResize) {
                this.grow(geo);
            }
            else {
                console.error("Cannot add instance, max count reached", this.name, this.count, this._maxInstanceCount);
                return false;
            }
        }

        handle.object.updateWorldMatrix(true, true);
        this.addGeometry(handle);
        this._handles[handle.__instanceIndex] = handle;
        this._currentInstanceCount += 1;

        this.markNeedsUpdate();

        if (this._currentInstanceCount > 0)
            this._batchedMesh.visible = true;

        return true;
    }

    remove(handle: InstanceHandle, delete_: boolean) {
        if (!handle) {
            return;
        }
        if (handle.__instanceIndex < 0 || this._handles[handle.__instanceIndex] != handle || this._currentInstanceCount <= 0) {
            // console.warn("Cannot remove instance, handle is invalid", handle.name);
            return;
        }

        this.removeGeometry(handle, delete_);
        this._handles[handle.__instanceIndex] = null;
        handle.__instanceIndex = -1;

        if (this._currentInstanceCount > 0) {
            this._currentInstanceCount -= 1;
        }

        if (this._currentInstanceCount <= 0)
            this._batchedMesh.visible = false;

        this.markNeedsUpdate();
    }

    updateInstance(mat: Matrix4, index: number) {
        this._batchedMesh.setMatrixAt(index, mat);
        this.markNeedsUpdate();
    }

    updateGeometry(geo: BufferGeometry, geometryIndex: number): boolean {
        if (!this.validateGeometry(geo)) {
            return false;
        }

        if (this.mustGrow()) {
            this.grow(geo);
        }
        if (debugInstancing)
            console.debug("[Instancing] UPDATE GEOMETRY at " + geometryIndex, this._batchedMesh["_geometryCount"], geo.name, getMeshInformation(geo), geo.attributes.position.count, geo.index ? geo.index.count : 0);

        this._batchedMesh.setGeometryAt(geometryIndex, geo);
        // for LOD mesh updates we need to make sure to save the geometry index
        this._geometryIds.set(geo, geometryIndex);
        this.markNeedsUpdate();
        return true;
    }

    private onBeforeRender = () => {
        // ensure the instanced mesh is rendered / has correct layers
        this._batchedMesh.layers.enableAll();

        if (this._needUpdateBounds && this._batchedMesh[$instancingAutoUpdateBounds] === true) {
            if (debugInstancing === "verbose") console.log("Update instancing bounds", this.name, this._batchedMesh.matrixWorldNeedsUpdate);
            this.updateBounds();
        }
    }

    private onAfterRender = () => {
        // hide the instanced mesh again when its not being rendered (for raycasting we still use the original object)
        this._batchedMesh.layers.disableAll();
    }

    private validateGeometry(geometry: BufferGeometry): boolean {
        const batchGeometry = this.geometry;

        for (const attributeName in batchGeometry.attributes) {
            if (attributeName === "batchId") {
                continue;
            }
            if (!geometry.hasAttribute(attributeName)) {
                if (isDevEnvironment())
                    console.warn(`BatchedMesh: Added geometry missing "${attributeName}". All geometries must have consistent attributes.`);
                // geometry.setAttribute(attributeName, batchGeometry.getAttribute(attributeName).clone());
                return false;
                // throw new Error(`BatchedMesh: Added geometry missing "${attributeName}". All geometries must have consistent attributes.`);
            }
            // const srcAttribute = geometry.getAttribute(attributeName);
            // const dstAttribute = batchGeometry.getAttribute(attributeName);
            // if (srcAttribute.itemSize !== dstAttribute.itemSize || srcAttribute.normalized !== dstAttribute.normalized) {
            //     if (debugInstancing) throw new Error('BatchedMesh: All attributes must have a consistent itemSize and normalized value.');
            //     return false;
            // }
        }
        return true;
    }

    private markNeedsUpdate() {
        if (debugInstancing === "verbose") {
            console.warn("Marking instanced mesh dirty", this.name);
        }
        this._needUpdateBounds = true;
        // this.inst.instanceMatrix.needsUpdate = true;
    }

    /**
     * @param geo The geometry to add (if none is provided it means the geometry is already added and just updated)
     */
    private mustGrow(geo?: BufferGeometry): boolean {
        if (this.count >= this._maxInstanceCount) return true;
        if (!geo || !geo.attributes) return false;

        const isKnownGeometry = this._geometryIds.has(geo);
        if (isKnownGeometry) return false;

        const meshInfo = getMeshInformation(geo);
        const newVertexCount = meshInfo.vertexCount;
        const newIndexCount = meshInfo.indexCount;
        return this._currentVertexCount + newVertexCount > this._maxVertexCount || this._currentIndexCount + newIndexCount > this._maxIndexCount;
    }

    private _growId = 0;
    private grow(geometry: BufferGeometry) {
        const id = ++this._growId;
        const growFactor = 2;

        const growInstances = this.count >= this._maxInstanceCount;
        const newSize = growInstances ? Math.ceil(this._maxInstanceCount * growFactor) : this._maxInstanceCount;

        // create a new BatchedMesh instance
        // TODO: we should keep track of how many instances for each geometry we have and consider that when estimating new space
        const estimatedSpace = this.tryEstimateVertexCountSize(newSize, [geometry]);// geometry.attributes.position.count;
        // const indices = geometry.index ? geometry.index.count : 0;
        const vertexGrowFactor = 1.25;
        const newMaxVertexCount = Math.max(this._maxVertexCount, Math.ceil(estimatedSpace.vertexCount * vertexGrowFactor));
        const newMaxIndexCount = Math.max(this._maxIndexCount, Math.ceil(estimatedSpace.indexCount * vertexGrowFactor));

        if (debugInstancing) {
            const geometryInfo = getMeshInformation(geometry);
            console.warn(`[Instancing] Growing Buffer\nMesh: \"${this.name}${geometry.name?.length ? "/" + geometry.name : ""}\" (${geometryInfo.vertexCount.toLocaleString()} vertices, ${geometryInfo.indexCount.toLocaleString()} indices)\nMax count ${this._maxInstanceCount.toLocaleString()} → ${newSize.toLocaleString()}\nMax vertex count ${this._maxVertexCount.toLocaleString()} -> ${newMaxVertexCount.toLocaleString()}\nMax index count ${this._maxIndexCount.toLocaleString()} -> ${newMaxIndexCount.toLocaleString()}`);
            this._debugMaterial = createDebugMaterial();
        }
        else if (isDevEnvironment()) {
            console.debug(`[Instancing] Growing Buffer\nMesh: \"${this.name}${geometry.name?.length ? "/" + geometry.name : ""}\"\nMax count ${this._maxInstanceCount} → ${newSize}\nMax vertex count ${this._maxVertexCount.toLocaleString()} -> ${newMaxVertexCount.toLocaleString()}\nMax index count ${this._maxIndexCount.toLocaleString()} -> ${newMaxIndexCount.toLocaleString()}`);
        }

        this._maxVertexCount = newMaxVertexCount;
        this._maxIndexCount = newMaxIndexCount;
        const newInst = new BatchedMesh(newSize, this._maxVertexCount, this._maxIndexCount, this._debugMaterial ?? this.material);
        newInst.name = this.getBatchedMeshName();
        newInst.layers = this._batchedMesh.layers;
        newInst.castShadow = this._batchedMesh.castShadow;
        newInst.receiveShadow = this._batchedMesh.receiveShadow;
        newInst.visible = this._batchedMesh.visible;
        newInst[$instancingAutoUpdateBounds] = this._batchedMesh[$instancingAutoUpdateBounds];
        newInst.matrixAutoUpdate = this._batchedMesh.matrixAutoUpdate;
        newInst.matrixWorldNeedsUpdate = this._batchedMesh.matrixWorldNeedsUpdate;
        newInst.matrixAutoUpdate = this._batchedMesh.matrixAutoUpdate;
        newInst.matrixWorld.copy(this._batchedMesh.matrixWorld);
        newInst.matrix.copy(this._batchedMesh.matrix);

        // dispose the old batched mesh
        this._batchedMesh.dispose();
        this._batchedMesh.removeFromParent();
        this._geometryIds = new WeakMap<BufferGeometry, number>();

        this._batchedMesh = newInst;
        this._maxInstanceCount = newSize;

        // since we have a new batched mesh we need to re-add all the instances
        // fixes https://linear.app/needle/issue/NE-5711

        // add current instances to new instanced mesh
        const original = [...this._handles];
        this._handles = [];
        for (const handle of original) {
            if (id !== this._growId) {
                // another grow happened in the meantime
                if (debugInstancing) console.warn("[Instancing] Aborting grow since another grow happened in the meantime");
                return;
            }
            if (handle && handle.__instanceIndex >= 0) {
                this.addGeometry(handle);
                this._handles[handle.__instanceIndex] = handle;
            }
        }

        this._context.scene.add(newInst);
    }

    private tryEstimateVertexCountSize(newMaxInstances: number, _newGeometries?: BufferGeometry[], newGeometriesFactor: number = 1): MeshInformation {
        /** Used geometries and how many instances use them  */
        const usedGeometries = new Map<BufferGeometry, MeshInformation & { count: number }>();
        for (const handle of this._handles) {
            if (handle && handle.__instanceIndex >= 0 && handle.object.geometry) {
                if (!usedGeometries.has(handle.object.geometry as BufferGeometry)) {
                    const data = getMeshInformation(handle.object.geometry as BufferGeometry);
                    const meshinfo = { count: 1, ...data };
                    usedGeometries.set(handle.object.geometry as BufferGeometry, meshinfo);
                }
                else {
                    const entry = usedGeometries.get(handle.object.geometry as BufferGeometry)!;
                    entry.count += 1;
                }

                if (_newGeometries && _newGeometries?.length > 0) {
                    const index = _newGeometries.indexOf(handle.object.geometry as BufferGeometry);
                    if (index !== -1) {
                        _newGeometries.splice(index, 1);
                    }
                }
            }
        }

        // then calculate the total vertex count
        let totalVertices = 0;
        let totalIndices = 0;
        let totalGeometries = 0;
        // let maxVertices = 0;
        for (const [_geo, data] of usedGeometries) {
            totalGeometries += 1;
            totalVertices += data.vertexCount;
            totalIndices += data.indexCount;
            // maxVertices = Math.max(maxVertices, geo.attributes.position.count * count);
        }
        // we calculate the average to make an educated guess of how many vertices will be needed with the new buffer count
        const averageVerts = Math.ceil(totalVertices / Math.max(1, totalGeometries));
        let maxVertexCount = averageVerts * totalGeometries;
        const averageIndices = Math.ceil(totalIndices / Math.max(1, totalGeometries));
        let maxIndexCount = averageIndices * totalGeometries;

        // if new geometries are provided we *know* that they will be added
        // so we make sure to include them in the calculation
        if (_newGeometries) {
            for (const geo of _newGeometries) {
                const meshinfo = getMeshInformation(geo);
                if (meshinfo != null) {
                    maxVertexCount += meshinfo.vertexCount * newGeometriesFactor;
                    maxIndexCount += meshinfo.indexCount * newGeometriesFactor;
                }

            }
        }

        if (debugInstancing) {
            console.log(`[Instancing] Estimated size for new buffer ${this.name}\nGeometries: ${totalGeometries} (New: ${_newGeometries?.length || 0})\nInstances: ${newMaxInstances}\nEstimated Vertices: ${maxVertexCount.toLocaleString()}\nEstimated Indices: ${maxIndexCount.toLocaleString()}`);
        }

        return { vertexCount: maxVertexCount, indexCount: maxIndexCount };
    }


    private addGeometry(handle: InstanceHandle) {

        const obj = handle.object;
        const geo = obj.geometry as BufferGeometry;
        if (!geo) {
            // if the geometry is null we cannot add it
            return;
        }

        // otherwise add more geometry / instances
        let geometryId = this._geometryIds.get(geo);
        if (geometryId === undefined || geometryId === null) {
            if (debugInstancing)
                console.warn(`[Instancing] > ADD NEW GEOMETRY \"${handle.name} (${geo.name}; ${geo.uuid})\"\nCurrent Instances: ${this._currentInstanceCount}\nMax Vertices: ${handle.maxVertexCount.toLocaleString()}\nMax Indices: ${handle.maxIndexCount.toLocaleString()}\nMax Triangles: ${(handle.maxIndexCount / 3).toLocaleString()}`);

            geometryId = this._batchedMesh.addGeometry(geo, handle.maxVertexCount, handle.maxIndexCount);
            this._geometryIds.set(geo, geometryId);
            this._currentVertexCount += handle.maxVertexCount;
            this._currentIndexCount += handle.maxIndexCount;
        }
        else {
            if (debugInstancing === "verbose") console.log(`[Instancing] > ADD INSTANCE \"${handle.name}\"\nGEOMETRY_ID=${geometryId}\n${this._currentInstanceCount} instances`);
        }
        const i = this._batchedMesh.addInstance(geometryId);
        handle.__geometryIndex = geometryId;
        handle.__instanceIndex = i;
        handle.__reservedVertexRange = handle.maxVertexCount;
        handle.__reservedIndexRange = handle.maxIndexCount;
        this._batchedMesh.setMatrixAt(i, handle.object.matrixWorld);
        if (debugInstancing)
            console.debug(`[Instancing] > ADDED INSTANCE \"${handle.name}\"\nGEOMETRY_ID=${geometryId}\n${this._currentInstanceCount} instances\nIndex: ${handle.__instanceIndex}\nVertices: ${this._currentVertexCount.toLocaleString()}/${this._maxVertexCount.toLocaleString()},\nIndices: ${this._currentIndexCount.toLocaleString()}/${this._maxIndexCount.toLocaleString()}`);

    }


    private removeGeometry(handle: InstanceHandle, _del: boolean) {
        if (handle.__instanceIndex < 0) {
            console.warn("Cannot remove geometry, instance index is invalid", handle.name);
            return;
        }
        // deleteGeometry is currently not useable since there's no optimize method
        // https://github.com/mrdoob/three.js/issues/27985
        // if (del)
        //     this.inst.deleteGeometry(handle.__instanceIndex);
        // else 
        // this._batchedMesh.setVisibleAt(handle.__instanceIndex, false);
        if (debugInstancing) {
            console.debug(`[Instancing] < REMOVE INSTANCE \"${handle.name}\" at [${handle.__instanceIndex}]\nGEOMETRY_ID=${handle.__geometryIndex}\n${this._currentInstanceCount} instances\nIndex: ${handle.__instanceIndex}`);
        }
        this._batchedMesh.deleteInstance(handle.__instanceIndex);
    }
}

declare type BucketInfo = {
    geometryIndex: number;
    vertexCount: number;
    indexCount: number;
}

declare type MeshInformation = {
    vertexCount: number;
    indexCount: number;
}

function getMeshInformation(geo: BufferGeometry): MeshInformation {
    if (!geo) {
        if (isDevEnvironment()) console.error("Cannot get mesh information from null geometry");
        return { vertexCount: 0, indexCount: 0 };
    }
    let vertexCount = geo.attributes?.position?.count || 0;
    let indexCount = geo.index ? geo.index.count : 0;
    const lodInfo = NEEDLE_progressive.getMeshLODExtension(geo);
    if (lodInfo) {
        const lod0 = lodInfo.lods[0];
        let lod0Count = lod0.vertexCount;
        let lod0IndexCount = lod0.indexCount;
        // add some wiggle room: https://linear.app/needle/issue/NE-4505
        const extra = Math.min(200, Math.ceil(lod0Count * .05));
        lod0Count += extra;
        lod0IndexCount += 20;
        vertexCount = Math.max(vertexCount, lod0Count);
        indexCount = Math.max(indexCount, lod0IndexCount);
    }
    vertexCount = Math.ceil(vertexCount);
    indexCount = Math.ceil(indexCount);
    return { vertexCount, indexCount };
}

function createDebugMaterial() {
    const mat = new MeshStandardMaterial({ color: new Color(Math.random(), Math.random(), Math.random()) });
    mat.emissive = mat.color;
    mat.emissiveIntensity = .3;
    if (getParam("wireframe"))
        mat.wireframe = true;
    return mat;
}
