import { getRaycastMesh } from "@needle-tools/gltf-progressive";
import { AxesHelper, Material, Mesh, MeshBasicMaterial, MeshPhysicalMaterial, MeshStandardMaterial, Object3D, RawShaderMaterial, ShaderMaterial, SkinnedMesh, Texture, Vector4 } from "three";

import { showBalloonWarning } from "../engine/debug/index.js";
import { getComponent, getOrAddComponent } from "../engine/engine_components.js";
import { Gizmos } from "../engine/engine_gizmos.js";
import { InstancingUtil, NEED_UPDATE_INSTANCE_KEY } from "../engine/engine_instancing.js";
import { isLocalNetwork } from "../engine/engine_networking_utils.js";
import { serializable } from "../engine/engine_serialization_decorator.js";
import { FrameEvent } from "../engine/engine_setup.js";
import { getTempVector } from "../engine/engine_three_utils.js";
import type { IRenderer, ISharedMaterials } from "../engine/engine_types.js";
import { getParam } from "../engine/engine_utils.js";
import { NEEDLE_render_objects } from "../engine/extensions/NEEDLE_render_objects.js";
import { setCustomVisibility } from "../engine/js-extensions/Layers.js";
import { Behaviour, GameObject } from "./Component.js";
import { ReflectionProbe } from "./ReflectionProbe.js";
import { InstanceHandle, InstancingHandler } from "./RendererInstancing.js"
// import { RendererCustomShader } from "./RendererCustomShader.js";
import { RendererLightmap } from "./RendererLightmap.js";


// for staying compatible with old code
export { InstancingUtil } from "../engine/engine_instancing.js";

const debugRenderer = getParam("debugrenderer");
const debugskinnedmesh = getParam("debugskinnedmesh");
const suppressInstancing = getParam("noinstancing");
const showWireframe = getParam("wireframe");

export enum ReflectionProbeUsage {
    Off = 0,
    BlendProbes = 1,
    BlendProbesAndSkybox = 2,
    Simple = 3,
}


export class FieldWithDefault {
    public path: string | null = null;
    public asset: object | null = null;
    public default: any;
}

export enum RenderState {
    Both = 0,
    Back = 1,
    Front = 2,
}

type SharedMaterial = (Material & Partial<MeshStandardMaterial> & Partial<MeshPhysicalMaterial> & Partial<ShaderMaterial> & Partial<RawShaderMaterial>);


// support sharedMaterials[index] assigning materials directly to the objects
class SharedMaterialArray implements ISharedMaterials {

    [num: number]: Material;

    private _renderer: Renderer;
    private _targets: Object3D[] = [];

    private _indexMapMaxIndex?: number;
    private _indexMap?: Map<number, number>;

    private _changed: boolean = false;
    get changed(): boolean {
        return this._changed;
    }
    set changed(value: boolean) {
        if (value === true) {
            if (debugRenderer)
                console.warn("SharedMaterials have changed: " + this._renderer.name);
        }
        this._changed = value;
    }

    is(renderer: Renderer) {
        return this._renderer === renderer;
    }

    constructor(renderer: Renderer, originalMaterials: Material[]) {
        this._renderer = renderer;
        const setMaterial = this.setMaterial.bind(this);
        const getMaterial = this.getMaterial.bind(this);
        const go = renderer.gameObject;
        this._targets = [];
        if (go) {
            switch (go.type) {
                case "Group":
                    this._targets = [...go.children];
                    break;
                case "SkinnedMesh":
                case "Mesh":
                    this._targets.push(go);
                    break;
            }
        }

        // this is useful to have an index map when e.g. materials are trying to be assigned by index
        let hasMissingMaterials = false;
        let indexMap: Map<number, number> | undefined = undefined;
        let maxIndex: number = 0;
        for (let i = 0; i < this._targets.length; i++) {
            const target = this._targets[i] as Mesh;
            if (!target) continue;
            const mat = target.material as Material;
            if (!mat) continue;
            // set the shadow side to the same as the side of the material, three flips this for some reason
            mat.shadowSide = mat.side;
            for (let k = 0; k < originalMaterials.length; k++) {
                const orig = originalMaterials[k];
                if (!orig) {
                    hasMissingMaterials = true;
                    continue;
                }
                if (mat.name === orig.name) {
                    if (indexMap === undefined) indexMap = new Map();
                    indexMap.set(k, i);
                    maxIndex = Math.max(maxIndex, k);
                    // console.log(`Material ${mat.name} at ${k} was found at index ${i} in renderer ${renderer.name}.`)
                    break;
                }
            }
        }
        if (hasMissingMaterials) {
            this._indexMapMaxIndex = maxIndex;
            this._indexMap = indexMap;
            const warningMessage = `Renderer ${renderer.name} was initialized with missing materials - this may lead to unexpected behaviour when trying to access sharedMaterials by index.`;
            console.warn(warningMessage);
            if (isLocalNetwork()) showBalloonWarning("Found renderer with missing materials: please check the console for details.");
        }

        // this lets us override the javascript indexer, only works in ES6 tho
        // but like that we can use sharedMaterials[index] and it will be assigned to the object directly
        return new Proxy(this, {
            get(target, key) {
                if (typeof key === "string") {
                    const index = parseInt(key);
                    if (!isNaN(index)) {
                        return getMaterial(index);
                    }
                }
                return target[key];
            },
            set(target, key, value) {
                if (typeof key === "string")
                    setMaterial(value, Number.parseInt(key));
                // console.log(target, key, value);
                if (Reflect.set(target, key, value)) {
                    if (value instanceof Material)
                        target.changed = true;
                    return true;
                }
                return false;
            }
        });
    }

    get length(): number {
        if (this._indexMapMaxIndex !== undefined) return this._indexMapMaxIndex + 1;
        return this._targets.length;
    }

    // iterator to support: for(const mat of sharedMaterials)
    *[Symbol.iterator]() {
        for (let i = 0; i < this.length; i++) {
            yield this.getMaterial(i);
        }
    }

    private resolveIndex(index: number): number {
        const map = this._indexMap;
        // if we have a index map it means that some materials were missing
        if (map) {
            if (map.has(index)) return map.get(index) as number;
            // return -1;
        }
        return index;
    }

    private setMaterial(mat: Material, index: number) {
        index = this.resolveIndex(index);
        if (index < 0 || index >= this._targets.length) return;
        const target = this._targets[index];
        if (!target || target["material"] === undefined) return;
        target["material"] = mat;
        this.changed = true;
    }

    private getMaterial(index: number): SharedMaterial | null {
        index = this.resolveIndex(index);
        if (index < 0) return null;
        const obj = this._targets;
        if (index >= obj.length) return null;
        const target = obj[index];
        if (!target) return null;
        return target["material"];
    }

}

/**
 * The [Renderer](https://engine.needle.tools/docs/api/Renderer) component controls rendering properties of meshes including materials,  
 * lightmaps, reflection probes, and GPU instancing.  
 *
 * **Materials:**  
 * Access materials via `sharedMaterials` array. Changes affect all instances.  
 * Use material cloning for per-instance variations.  
 *
 * **Instancing:**  
 * Enable GPU instancing for improved performance with many identical objects.  
 * Use `Renderer.setInstanced(obj, true)` or `enableInstancing` property.  
 *
 * **Lightmaps:**  
 * Baked lighting is automatically applied when exported from Unity or Blender.  
 * Access via the associated {@link RendererLightmap} component.    
 * 
 * [![](https://cloud.needle.tools/-/media/Vk944XVswtPEuxlNPLMxPQ.gif)](https://engine.needle.tools/samples/multiple-lightmaps/)
 *
 * **Debug options:**  
 * - `?debugrenderer` - Log renderer info
 * - `?wireframe` - Show wireframe rendering
 * - `?noinstancing` - Disable GPU instancing
 *
 * @example Change material at runtime
 * ```ts
 * const renderer = myObject.getComponent(Renderer);
 * renderer.sharedMaterials[0] = newMaterial;
 * ```
 *
 * @example Enable instancing
 * ```ts
 * Renderer.setInstanced(myObject, true);
 * ```
 *
 * @category Rendering
 * @group Components
 * @see {@link ReflectionProbe} for environment reflections
 * @see {@link Light} for scene lighting
 */
export class Renderer extends Behaviour implements IRenderer {

    /** Enable or disable instancing for an object. This will create a Renderer component if it does not exist yet.
     * @returns the Renderer component that was created or already existed on the object
     */
    static setInstanced(obj: Object3D, enableInstancing: boolean): Renderer {
        const renderer = getOrAddComponent(obj, Renderer);
        renderer.setInstancingEnabled(enableInstancing);
        return renderer;
    }

    /** Check if an object is currently rendered using instancing
     * @returns true if the object is rendered using instancing
     */
    static isInstanced(obj: Object3D): boolean {
        const renderer = getComponent(obj, Renderer);
        if (renderer) return renderer.isInstancingActive;
        return InstancingUtil.isUsingInstancing(obj);
    }

    /** Set the rendering state only of an object (makes it visible or invisible) without affecting component state or child hierarchy visibility! You can also just enable/disable the Renderer component on that object for the same effect!
     * 
     * If you want to activate or deactivate a complete object you can use obj.visible as usual (it acts the same as setActive in Unity) */
    static setVisible(obj: Object3D, visible: boolean) {
        setCustomVisibility(obj, visible);
    }

    @serializable()
    receiveShadows: boolean = false;
    @serializable()
    shadowCastingMode: ShadowCastingMode = ShadowCastingMode.Off;
    @serializable()
    lightmapIndex: number = -1;
    @serializable(Vector4)
    lightmapScaleOffset: Vector4 = new Vector4(1, 1, 0, 0);
    /** If the renderer should use instancing  
     * If this is a boolean (true) all materials will be instanced or (false) none of them.  
     * If this is an array of booleans the materials will be instanced based on the index of the material.
     */
    @serializable()
    enableInstancing: boolean | boolean[] | undefined = undefined;
    @serializable()
    renderOrder: number[] | undefined = undefined;
    @serializable()
    allowOcclusionWhenDynamic: boolean = true;

    @serializable(Object3D)
    probeAnchor?: Object3D;
    @serializable()
    reflectionProbeUsage: ReflectionProbeUsage = ReflectionProbeUsage.Off;

    // custom shader
    // get materialProperties(): Array<MaterialProperties> | undefined {
    //     return this._materialProperties;
    // }
    // set materialProperties(value: Array<MaterialProperties> | undefined) {
    //     this._materialProperties = value;
    // }

    // private customShaderHandler: RendererCustomShader | undefined = undefined;

    // private _materialProperties: Array<MaterialProperties> | undefined = undefined;
    private _lightmaps?: RendererLightmap[];

    /** Get the mesh Object3D for this renderer  
     * Warn: if this is a multimaterial object it will return the first mesh only 
     * @returns a mesh object3D.
     * */
    get sharedMesh(): Mesh | SkinnedMesh | undefined {
        if (this.gameObject.type === "Mesh") {
            return this.gameObject as unknown as Mesh
        }
        else if (this.gameObject.type === "SkinnesMesh") {
            return this.gameObject as unknown as SkinnedMesh;
        }
        else if (this.gameObject.type === "Group") {
            return this.gameObject.children[0] as unknown as Mesh;
        }
        return undefined;
    }

    private readonly _sharedMeshes: Mesh[] = [];
    /** Get all the mesh Object3D for this renderer 
     * @returns an array of mesh object3D.
     */
    get sharedMeshes(): Mesh[] {
        if (this.destroyed || !this.gameObject) return this._sharedMeshes;
        this._sharedMeshes.length = 0;
        if (this.gameObject.type === "Group") {
            for (const ch of this.gameObject.children) {
                if (ch.type === "Mesh" || ch.type === "SkinnedMesh") {
                    this._sharedMeshes.push(ch as Mesh);
                }
            }
        }
        else if (this.gameObject.type === "Mesh" || this.gameObject.type === "SkinnedMesh") {
            this._sharedMeshes.push(this.gameObject as unknown as Mesh);
        }
        return this._sharedMeshes;
    }

    // @ts-ignore
    get sharedMaterial(): SharedMaterial {
        return (this.sharedMaterials?.[0]) as SharedMaterial;
    }

    // @ts-ignore
    set sharedMaterial(mat: SharedMaterial) {
        const cur = this.sharedMaterials[0];
        if (cur === mat) return;
        this.sharedMaterials[0] = mat;
        this.applyLightmapping();
    }

    /**@deprecated Use sharedMaterial */
    get material(): SharedMaterial {
        return this.sharedMaterials?.[0] as SharedMaterial;
    }

    /**@deprecated Use sharedMaterial */
    set material(mat: SharedMaterial) {
        this.sharedMaterial = mat;
    }

    private _sharedMaterials!: SharedMaterialArray;
    private _originalMaterials?: Material[];

    private _probeAnchorLastFrame?: Object3D;

    // this is just available during deserialization
    private set sharedMaterials(_val: Array<SharedMaterial | null>) {
        // TODO: elements in the array might be missing at the moment which leads to problems if an index is serialized
        if (!this._originalMaterials) {
            this._originalMaterials = _val as SharedMaterial[];
        }
        else if (_val) {
            let didWarn = false;
            for (let i = 0; i < this._sharedMaterials.length; i++) {
                const mat = i < _val.length ? _val[i] : null;
                if (mat && mat instanceof Material) {
                    this.sharedMaterials[i] = mat as Material;
                }
                else {
                    if (!didWarn) {
                        didWarn = true;
                        console.warn("Can not assign null as material: " + this.name, mat);
                    }
                }
            }
        }
    }

    //@ts-ignore
    get sharedMaterials(): SharedMaterialArray {

        if (this._originalMaterials === undefined) {
            if (!this.__didAwake) {
                // @ts-ignore (original materials will be set during deserialization)
                return null;
            }
            else {
                this._originalMaterials = [];
            }
        }

        if (!this._sharedMaterials || !this._sharedMaterials.is(this)) {
            if (!this._originalMaterials) this._originalMaterials = [];
            this._sharedMaterials = new SharedMaterialArray(this, this._originalMaterials);
        }
        return this._sharedMaterials!;
    }

    public static get shouldSuppressInstancing() {
        return suppressInstancing;
    }

    private _lightmapTextureOverride: Texture | null | undefined = undefined;
    public get lightmap(): Texture | null {
        if (this._lightmaps?.length) {
            return this._lightmaps[0].lightmap;
        }
        return null;
    }
    /** set undefined to return to default lightmap */
    public set lightmap(tex: Texture | null | undefined) {
        this._lightmapTextureOverride = tex;
        if (tex === undefined) {
            tex = this.context.lightmaps.tryGetLightmap(this.sourceId, this.lightmapIndex);
        }
        if (this._lightmaps?.length) {
            for (const lm of this._lightmaps) {
                lm.lightmap = tex;
            }
        }
    }
    get hasLightmap(): boolean {
        const lm = this.lightmap;
        return lm !== null && lm !== undefined;
    }

    public allowProgressiveLoading: boolean = true;

    private _firstFrame: number = -1;

    registering() {
        if (!this.enabled) {
            this.setVisibility(false);
        }
    }

    awake() {
        this._firstFrame = this.context.time.frame;

        if (debugRenderer) console.log("Renderer ", this.name, this);
        this.clearInstancingState();

        if (this.probeAnchor && debugRenderer) this.probeAnchor.add(new AxesHelper(.2));

        this._reflectionProbe = null;

        if (this.isMultiMaterialObject(this.gameObject)) {
            for (const child of this.gameObject.children) {
                this.context.addBeforeRenderListener(child, this.onBeforeRenderThree);
                child.layers.mask = this.gameObject.layers.mask;
            }

            if (this.renderOrder !== undefined) {
                // Objects can have nested renderers (e.g. contain 2 meshes and then again another group)
                // or perhaps just regular child objects that have their own renderer component (?)
                let index = 0;
                for (let i = 0; i < this.gameObject.children.length; i++) {
                    const ch = this.gameObject.children[i];
                    // ignore nested groups or objects that have their own renderer (aka their own render order settings)
                    if (!this.isMeshOrSkinnedMesh(ch) || GameObject.getComponent(ch, Renderer)) continue;
                    if (this.renderOrder.length <= index) {
                        console.warn("Incorrect renderOrder element count", this, this.renderOrder.length + " but expected " + this.gameObject.children.length, "Index: " + index, "ChildElement:", ch);
                        continue;
                    }
                    // if(debugRenderer) console.log("Setting render order", ch, this.renderOrder[index])
                    ch.renderOrder = this.renderOrder[index];
                    index += 1;
                }
            }
        }
        // TODO: custom shader with sub materials
        else if (this.isMeshOrSkinnedMesh(this.gameObject)) {
            this.context.addBeforeRenderListener(this.gameObject, this.onBeforeRenderThree);

            if (this.renderOrder !== undefined && this.renderOrder.length > 0)
                this.gameObject.renderOrder = this.renderOrder[0];
        }
        else {
            this.context.addBeforeRenderListener(this.gameObject, this.onBeforeRenderThree);
        }

        this._lightmaps = undefined;
        this.applyLightmapping();

        if (showWireframe) {
            for (let i = 0; i < this.sharedMaterials.length; i++) {
                const mat: any = this.sharedMaterials[i];
                if (mat) {
                    mat.wireframe = true;
                }
            }
        }

    }

    private applyLightmapping() {
        if (this.lightmapIndex >= 0 && !this._lightmaps) {
            // const type = this.gameObject.type;

            // use the override lightmap if its not undefined
            const tex = this._lightmapTextureOverride !== undefined
                ? this._lightmapTextureOverride
                : this.context.lightmaps.tryGetLightmap(this.sourceId, this.lightmapIndex);
            if (tex) {
                if (!this._lightmaps) this._lightmaps = [];
                const rm = new RendererLightmap(this);
                rm.init(this.lightmapIndex, this.lightmapScaleOffset, tex);
                this._lightmaps.push(rm);
            }
            else {
                if (debugRenderer) console.warn(`[Renderer] No lightmaps found ${this.name} (${this.sourceId}, ${this.lightmapIndex})`);
            }
        }

    }

    private _isInstancingEnabled: boolean = false;
    private _handles: InstanceHandle[] | null | undefined = undefined;

    /** 
     * @returns true if this renderer has instanced objects
     */
    get isInstancingActive() {
        return this._handles != undefined && this._handles.length > 0 && this._isInstancingEnabled;
    }
    /** @returns the instancing handles */
    get instances(): InstanceHandle[] | null {
        if (!this._handles || this._handles.length <= 0) {
            return null;
        }
        this._handlesTempArray.length = 0;
        if (this._handles) {
            for (const h of this._handles) {
                this._handlesTempArray.push(h);
            }
        }
        return this._handlesTempArray;
    }
    private _handlesTempArray: InstanceHandle[] = [];


    /** Enable or disable instancing for this renderer.
     * @param enabled true to enable instancing, false to disable it
     */
    setInstancingEnabled(enabled: boolean): boolean {
        if (this._isInstancingEnabled === enabled) return enabled && (this._handles === undefined || this._handles != null && this._handles.length > 0);
        this._isInstancingEnabled = enabled;
        if (enabled) {
            if (this.enableInstancing === undefined) this.enableInstancing = true;
            if (this._handles === undefined) {
                this._handles = InstancingHandler.instance.setup(this, this.gameObject, this.context, null, { rend: this, foundMeshes: 0, useMatrixWorldAutoUpdate: this.useInstanceMatrixWorldAutoUpdate() });
                if (this._handles) {
                    GameObject.markAsInstancedRendered(this.gameObject, true);
                    return true;
                }
            }
            else if (this._handles !== null) {
                for (const handler of this._handles) {
                    handler.updateInstanceMatrix(true);
                    handler.add();
                }
                GameObject.markAsInstancedRendered(this.gameObject, true);
                return true;
            }
        }
        else {
            if (this._handles) {
                for (const handler of this._handles) {
                    handler.remove(this.destroyed);
                }
            }
            return true;
        }

        return false;
    }

    private clearInstancingState() {
        this._isInstancingEnabled = false;
        this._handles = undefined;
    }

    /** Return true to wrap matrix update events for instanced rendering to update instance matrices automatically when matrixWorld changes
     * This is a separate method to be overrideable from user code
     */
    useInstanceMatrixWorldAutoUpdate() {
        return true;
    }

    start() {
        if (this.enableInstancing && !suppressInstancing) {
            this.setInstancingEnabled(true);
            // make sure the instance is marked dirty once for cases where e.g. an animator animates the instanced object
            // in the first frame we want the updated matrix then to be applied immediately to the instancing
            InstancingUtil.markDirty(this.gameObject);
        }
        this.gameObject.frustumCulled = this.allowOcclusionWhenDynamic;
        if (this.isMultiMaterialObject(this.gameObject)) {
            for (let i = 0; i < this.gameObject.children.length; i++) {
                const ch = this.gameObject.children[i];
                ch.frustumCulled = this.allowOcclusionWhenDynamic;
            }
        }
    }

    onEnable() {
        // ensure shared meshes are initialized
        const _ = this.sharedMeshes;

        this.setVisibility(true);

        // Check if the renderer is using instancing (or any child object is supposed to use instancing)
        const isUsingInstancing = this._isInstancingEnabled ||
            (this.enableInstancing == true || (Array.isArray(this.enableInstancing) && this.enableInstancing.some(x => x)));

        if (isUsingInstancing) {
            if (this.__internalDidAwakeAndStart) this.setInstancingEnabled(true);
        }
        // if no insancing is used we can apply the stencil settings
        // but instancing and stencil at the same time is not supported
        else if (this.enabled) {
            this.applyStencil();
        }

        this.updateReflectionProbe();

        ReflectionProbe.onEnabled.addEventListener(this.onReflectionProbeEnabled);
        ReflectionProbe.onDisabled.addEventListener(this.onReflectionProbeDisabled);
    }

    onDisable() {
        this.setVisibility(false);

        ReflectionProbe.onEnabled.removeEventListener(this.onReflectionProbeEnabled);
        ReflectionProbe.onDisabled.removeEventListener(this.onReflectionProbeDisabled);

        if (this._handles && this._handles.length > 0) {
            this.setInstancingEnabled(false);
        }
    }

    onDestroy(): void {
        this._handles = null;

        if (this.isMultiMaterialObject(this.gameObject)) {
            for (const child of this.gameObject.children) {
                this.context.removeBeforeRenderListener(child, this.onBeforeRenderThree);
            }
        }
        else {
            this.context.removeBeforeRenderListener(this.gameObject, this.onBeforeRenderThree);
        }
    }

    private readonly onReflectionProbeEnabled = () => {
        this.updateReflectionProbe();
    }
    private onReflectionProbeDisabled = (probe: ReflectionProbe) => {
        if (this._reflectionProbe === probe) {
            this._reflectionProbe.unapply(this.gameObject);
            this._reflectionProbe = null;
        }
    }


    onBeforeRender() {
        if (!this.gameObject) {
            return;
        }

        if (this._probeAnchorLastFrame !== this.probeAnchor || this._reflectionProbe?.activeAndEnabled === false) {
            this._reflectionProbe?.unapply(this.gameObject);
            this.updateReflectionProbe();
        }

        if (debugRenderer == this.name && this.gameObject instanceof Mesh) {
            this.gameObject.geometry.computeBoundingSphere();
            const tempCenter = getTempVector(this.gameObject.geometry.boundingSphere.center).applyMatrix4(this.gameObject.matrixWorld);
            Gizmos.DrawWireSphere(tempCenter, this.gameObject.geometry.boundingSphere.radius, 0x00ddff);
        }

        if (this.isMultiMaterialObject(this.gameObject) && this.gameObject.children?.length > 0) {
            for (const ch of this.gameObject.children) {
                this.applySettings(ch);
            }
        }
        else {
            this.applySettings(this.gameObject);
        }

        if (this.sharedMaterials?.changed) {
            this.sharedMaterials.changed = false;
            this.applyLightmapping();
        }

        if (this._handles?.length) {
            // if (this.name === "Darbouka")
            //     console.log(this.name, this.gameObject.matrixWorldNeedsUpdate);
            const needsUpdate: boolean = this.gameObject[NEED_UPDATE_INSTANCE_KEY] === true;// || this.gameObject.matrixWorldNeedsUpdate;
            if (needsUpdate) {
                // if (debugInstancing) console.log("UPDATE INSTANCED MATRICES at frame #" + this.context.time.frame);
                this.gameObject[NEED_UPDATE_INSTANCE_KEY] = false;
                const remove = false;// Math.random() < .01;
                for (let i = this._handles.length - 1; i >= 0; i--) {
                    const h = this._handles[i];
                    if (remove) {
                        h.remove(this.destroyed);
                        this._handles.splice(i, 1);
                    }
                    else
                        h.updateInstanceMatrix();
                }
                this.gameObject.matrixWorldNeedsUpdate = false;
            }
        }

        if (this._handles && this._handles.length <= 0) {
            GameObject.markAsInstancedRendered(this.gameObject, false);
        }

        if (this._isInstancingEnabled && this._handles) {
            for (let i = 0; i < this._handles.length; i++) {
                const handle = this._handles[i];
                setCustomVisibility(handle.object, false);
            }
        }

        if (this.reflectionProbeUsage !== ReflectionProbeUsage.Off && this._reflectionProbe) {
            // @TODO: when reflection probes are applied via bounding box we currently NEVER update it again
            // if(this.probeAnchor === this._reflectionProbe.gameObject)
            this._reflectionProbe.apply(this.gameObject);
            // else {
            //     const probe = this._reflectionProbe;
            //     this._updateReflectionProbe();
            //     console.log(this.name, "Reflection probe updated on before render. Probe anchor changed:", this.probeAnchor !== this._probeAnchorLastFrame, "Probe active:", probe.activeAndEnabled, "New probe anchor:", this._reflectionProbe?.gameObject.name);
            //     if(!this._reflectionProbe) probe?.unapply(this.gameObject);
            //     else this._reflectionProbe?.apply(this.gameObject);
            // }
        }
        else if (this.reflectionProbeUsage === ReflectionProbeUsage.Off && this._reflectionProbe) {
            this._reflectionProbe.unapply(this.gameObject);
        }

        // since three 163 we need to set the envMap to the scene envMap if it is not set
        // otherwise the envmapIntensity has no effect: https://github.com/mrdoob/three.js/pull/27903
        // internal issue: https://linear.app/needle/issue/NE-6363
        if (this._sharedMaterials) {
            for (const mat of this._sharedMaterials) {
                // If the material has a envMap and is NOT using a reflection probe we set the envMap to the scene environment
                if (mat && "envMap" in mat && "envMapIntensity" in mat && !ReflectionProbe.isUsingReflectionProbe(mat)) {
                    mat.envMap = this.context.scene.environment;
                    mat.envMapRotation = this.context.scene.environmentRotation;
                }
            }
        }
        else if (debugRenderer) console.warn("[Renderer] sharedMaterials not initialized yet: " + this.name);
    }

    private onBeforeRenderThree = (_renderer, _scene, _camera, _geometry, material, _group) => {
        if (material.envMapIntensity !== undefined) {
            const factor = this.hasLightmap ? Math.PI : 1;
            const environmentIntensity = this.context.scene.environmentIntensity;
            material.envMapIntensity = Math.max(0, environmentIntensity * this.context.sceneLighting.environmentIntensity / factor);
            // console.log(this.context.sceneLighting.environmentIntensity);
        }
        if (this._lightmaps) {
            for (const lm of this._lightmaps) {
                lm.updateLightmapUniforms(material);
                lm.applyLightmap();
            }
        }
    }

    onAfterRender() {
        if (this._isInstancingEnabled && this._handles) {
            for (let i = 0; i < this._handles.length; i++) {
                const handle = this._handles[i];
                setCustomVisibility(handle.object, true);
            }
        }

        if (this._reflectionProbe?.activeAndEnabled === false) {
            this._reflectionProbe.unapply(this.gameObject);
        }

        // For network instantiate and such we can disable matrix auto update after a few frames to improve performance, since the object might not move after that
        if (this.static && this.gameObject.matrixAutoUpdate && this.context.time.frame - this._firstFrame >= 10) {
            this.gameObject.matrixAutoUpdate = false;
        }
    }

    /** Applies stencil settings for this renderer's objects (if stencil settings are available) */
    applyStencil() {
        NEEDLE_render_objects.applyStencil(this);
    }




    /** Apply the settings of this renderer to the given object
     * Settings include shadow casting and receiving (e.g. this.receiveShadows, this.shadowCastingMode)
     */
    applySettings(go: Object3D) {
        go.receiveShadow = this.receiveShadows;
        if (this.shadowCastingMode == ShadowCastingMode.On) {
            go.castShadow = true;
        }
        else go.castShadow = false;
    }

    private _reflectionProbe: ReflectionProbe | null = null;
    private updateReflectionProbe() {
        // handle reflection probe
        this._reflectionProbe = null;

        if (this.reflectionProbeUsage !== ReflectionProbeUsage.Off) {
            // update the reflection probe right before rendering
            // if we do it immediately the reflection probe might not be enabled yet 
            // (since this method is called from onEnable)
            this.startCoroutine(this._updateReflectionProbe(), FrameEvent.LateUpdate);

            this._probeAnchorLastFrame = this.probeAnchor;
        }
    }
    private *_updateReflectionProbe() {
        const obj = this.probeAnchor || this.gameObject;
        const isAnchor = this.probeAnchor ? true : false;
        this._reflectionProbe = ReflectionProbe.get(obj, this.context, isAnchor, this.probeAnchor);
    }

    private setVisibility(visible: boolean) {

        if (!this.isMultiMaterialObject(this.gameObject)) {
            setCustomVisibility(this.gameObject, visible);
        }
        else {
            for (const ch of this.gameObject.children) {
                if (this.isMeshOrSkinnedMesh(ch)) {
                    setCustomVisibility(ch, visible);
                }
            }
        }
    }

    private isMultiMaterialObject(obj: Object3D) {
        return obj.type === "Group";
    }

    private isMeshOrSkinnedMesh(obj: Object3D): obj is Mesh | SkinnedMesh {
        return obj.type === "Mesh" || obj.type === "SkinnedMesh";
    }
}

export class MeshRenderer extends Renderer {
}

/**
 * Renders deformable meshes that deform via bones and/or blend shapes.
 * @summary Renderer for deformable meshes
 * @category Rendering
 * @group Components
 **/
export class SkinnedMeshRenderer extends MeshRenderer {

    private _needUpdateBoundingSphere = false;
    // private _lastWorldPosition = new Vector3();

    awake() {
        super.awake();
        if (debugskinnedmesh) console.log("SkinnedMeshRenderer for \"" + this.name + "\"", this);
        // disable skinned mesh occlusion because of https://github.com/mrdoob/js/issues/14499
        this.allowOcclusionWhenDynamic = false;

        for (const mesh of this.sharedMeshes) {
            // If we don't do that here the bounding sphere matrix used for raycasts will be wrong. Not sure *why* this is necessary
            mesh.parent?.updateWorldMatrix(false, true);
            this.markBoundsDirty();
        }
    }
    onAfterRender(): void {
        super.onAfterRender();

        // this.gameObject.parent.position.x += Math.sin(this.context.time.time) * .01;

        // if (this.gameObject instanceof SkinnedMesh && this.gameObject.geometry.boundingSphere) {
        //     const bounds = this.gameObject.geometry.boundingSphere;
        //     const worldpos = getTempVector().setFromMatrixPosition(this.gameObject.matrixWorld);
        //     if (worldpos.distanceTo(this._lastWorldPosition) > bounds.radius) {
        //         this._lastWorldPosition.copy(worldpos);
        //         this.markBoundsDirty();
        //     };
        // }

        if (this._needUpdateBoundingSphere) {
            for (const mesh of this.sharedMeshes) {
                if (mesh instanceof SkinnedMesh) {
                    this._needUpdateBoundingSphere = false;
                    try {
                        const geometry = mesh.geometry;
                        const raycastmesh = getRaycastMesh(mesh);
                        if (raycastmesh) {
                            mesh.geometry = raycastmesh;
                        }
                        mesh.computeBoundingSphere();
                        mesh.geometry = geometry;
                    }
                    catch (err) {
                        console.error(`Error updating bounding sphere for ${mesh.name}`, err);
                    }
                }
            }
        }

        // if (this.context.time.frame % 30 === 0) this.markBoundsDirty();

        if (debugskinnedmesh) {
            for (const mesh of this.sharedMeshes) {
                if (mesh instanceof SkinnedMesh && mesh.boundingSphere) {
                    const tempCenter = getTempVector(mesh.boundingSphere.center).applyMatrix4(mesh.matrixWorld);
                    Gizmos.DrawWireSphere(tempCenter, mesh.boundingSphere.radius, "red");
                }
            }
        }
    }

    markBoundsDirty() {
        this._needUpdateBoundingSphere = true;
    }
}

export enum ShadowCastingMode {
    /// <summary>
    ///   <para>No shadows are cast from this object.</para>
    /// </summary>
    Off,
    /// <summary>
    ///   <para>Shadows are cast from this object.</para>
    /// </summary>
    On,
    /// <summary>
    ///   <para>Shadows are cast from this object, treating it as two-sided.</para>
    /// </summary>
    TwoSided,
    /// <summary>
    ///   <para>Object casts shadows, but is otherwise invisible in the Scene.</para>
    /// </summary>
    ShadowsOnly,
}
