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

import {
    Group,
    MathUtils,
    Matrix4,
    Mesh,
    MeshBasicMaterial,
    Ray,
    RGBAFormat,
    Sphere,
    SphereGeometry,
    UnsignedByteType,
    Vector2,
    Vector3,
    type Box3,
    type BufferGeometry,
    type ColorRepresentation,
    type Intersection,
    type Object3D,
    type Object3DEventMap,
    type Raycaster,
    type Texture,
    type WebGLRenderer,
    type WebGLRenderTarget,
} from 'three';
import { type OBB } from 'three/examples/jsm/Addons.js';

import type Disposable from '../../core/Disposable';
import type Ellipsoid from '../../core/geographic/Ellipsoid';
import type Extent from '../../core/geographic/Extent';
import type GetElevationOptions from '../../core/GetElevationOptions';
import type ElevationLayer from '../../core/layer/ElevationLayer';
import type Layer from '../../core/layer/Layer';
import type MemoryUsage from '../../core/MemoryUsage';
import type { GetMemoryUsageContext } from '../../core/MemoryUsage';
import type UniqueOwner from '../../core/UniqueOwner';
import type LayeredMaterial from '../../renderer/LayeredMaterial';
import type { MaterialOptions } from '../../renderer/LayeredMaterial';
import type RenderingState from '../../renderer/RenderingState';
import type ShadowLayeredMaterial from '../../renderer/ShadowLayeredMaterial';
import type View from '../../renderer/View';
import type TileCoordinate from './TileCoordinate';
import type TileGeometry from './TileGeometry';
import type { TileGeometryBuilder } from './TileGeometry';
import type { NeighbourList } from './TileIndex';
import type TileVolume from './TileVolume';

import HeightMap from '../../core/HeightMap';
import { isElevationLayer } from '../../core/layer/ElevationLayer';
import OffsetScale from '../../core/OffsetScale';
import Rect from '../../core/Rect';
import { intoUniqueOwner } from '../../core/UniqueOwner';
import OBBHelper from '../../helpers/OBBHelper';
import { readRGRenderTargetIntoRGBAU8Buffer } from '../../renderer/composition/WebGLComposer';
import MaterialUtils from '../../renderer/MaterialUtils';
import MemoryTracker from '../../renderer/MemoryTracker';
import { isPerspectiveCamera } from '../../utils/predicates';
import { nonNull } from '../../utils/tsutils';

const ray = new Ray();
const inverseMatrix = new Matrix4();
const THIS_RECT = new Rect(0, 1, 0, 1);
const tmpSphere = new Sphere();

const sphereGeometry = new SphereGeometry(1, 32, 16);

const helperMaterial = new MeshBasicMaterial({
    color: '#75eba8',
    depthTest: false,
    depthWrite: false,
    wireframe: true,
    transparent: true,
});

const noRaycast = (): void => {};

const NO_NEIGHBOUR = -99;
const NO_OFFSET_SCALE = new OffsetScale(0, 0, 0, 0);
const tempVec2 = new Vector2();
const tempVec3 = new Vector3();
const tempAbsolutePosition = new Vector3();

export interface TileMeshEventMap extends Object3DEventMap {
    'visibility-changed': unknown;
    dispose: unknown;
}

class TileMesh
    extends Mesh<TileGeometry, LayeredMaterial, TileMeshEventMap>
    implements Disposable, MemoryUsage
{
    public readonly isTileMesh = true as const;
    public override readonly type = 'TileMesh' as const;
    public readonly isMemoryUsage = true as const;
    public readonly extent: Extent;
    public readonly textureSize: Vector2;

    private _verticalScaling = 1;

    public override customDepthMaterial: ShadowLayeredMaterial;
    public override customDistanceMaterial: ShadowLayeredMaterial;

    public readonly coordinate: TileCoordinate;

    private readonly _extentDimensions: Vector2;
    private readonly _geometryBuilder: TileGeometryBuilder<TileGeometry>;
    private readonly _volume: TileVolume;
    private readonly _renderer: WebGLRenderer;
    private readonly _onElevationChanged: (tile: this) => void;

    private _heightMap: UniqueOwner<HeightMap, this> | null = null;
    private _enableTerrainDeformation: boolean;

    private _tileGeometry: TileGeometry;
    private _segments: number;
    private _skirtDepth: number | undefined;
    private _minmax: { min: number; max: number } = { min: -Infinity, max: +Infinity };
    private _shouldUpdateHeightMap = false;

    private _childTiles: [TileMesh | null, TileMesh | null, TileMesh | null, TileMesh | null] = [
        null,
        null,
        null,
        null,
    ];

    private readonly _helpers: {
        root: Group | null;
        boundingSphere?: Mesh<SphereGeometry, MeshBasicMaterial>;
        color: ColorRepresentation;
        colliderMesh?: Mesh<BufferGeometry, MeshBasicMaterial, Object3DEventMap>;
        boundingBox?: OBBHelper;
    } = {
        root: null,
        color: 'cyan',
    };
    private _elevationLayerInfo: {
        layer: ElevationLayer;
        offsetScale: OffsetScale;
        renderTarget: WebGLRenderTarget<Texture>;
    } | null = null;

    public disposed = false;
    public isLeaf = true;

    public getMemoryUsage(context: GetMemoryUsageContext): void {
        this.material?.getMemoryUsage(context);

        // We only count what we own, otherwise the same heightmap will be counted more than once.
        if (this._heightMap && this._heightMap.owner === this) {
            context.objects.set(`heightmap-${this._heightMap.owner.id}`, {
                cpuMemory: this._heightMap.payload.buffer.byteLength,
                gpuMemory: 0,
            });
        }

        this.geometry.getMemoryUsage(context);
    }

    public get boundingBox(): Box3 {
        if (!this._enableTerrainDeformation || this._elevationLayerInfo?.layer.visible !== true) {
            this._volume.setElevationRange({ min: 0, max: 0 });
        } else {
            this._volume.setElevationRange(this.minmax);
        }
        return this._volume.localBox;
    }

    /**
     * The LOD. Root nodes have LOD 0.
     */
    public get lod(): number {
        return this.coordinate.z;
    }

    public getOBB(): OBB {
        return this._volume.getOBB(this.matrixWorld);
    }

    public getWorldSpaceBoundingBox(target: Box3): Box3 {
        const local = this._volume.getLocalBoundingBox(target);

        this.updateMatrixWorld(true);

        local.applyMatrix4(this.matrixWorld);

        return local;
    }

    public getWorldSpaceBoundingSphere(target: Sphere): Sphere {
        this.updateWorldMatrix(true, false);
        return this._volume.getWorldSpaceBoundingSphere(target, this.matrixWorld);
    }

    public getBoundingBoxCorners(): Vector3[] {
        this.updateWorldMatrix(true, false);
        return this._volume.getWorldSpaceCorners(this.matrixWorld);
    }

    /**
     * Creates an instance of TileMesh.
     */
    public constructor(params: {
        geometryBuilder: TileGeometryBuilder<TileGeometry>;
        volume: TileVolume;
        /** The tile material. */
        material: LayeredMaterial;
        depthMaterial: ShadowLayeredMaterial;
        distanceMaterial: ShadowLayeredMaterial;
        /** The tile extent. */
        extent: Extent;
        /** The subdivisions. */
        segments: number;
        skirtDepth?: number;
        /** The tile coordinate. */
        coord: TileCoordinate;
        /** The texture size. */
        textureSize: Vector2;
        ellipsoid?: Ellipsoid;
        renderer: WebGLRenderer;
        enableTerrainDeformation: boolean;
        onElevationChanged: (tile: TileMesh) => void;
    }) {
        super(
            params.geometryBuilder.build({ extent: params.extent, tile: params.coord }),
            params.material,
        );

        this._geometryBuilder = params.geometryBuilder;
        this._tileGeometry = this.geometry;
        this._segments = params.segments;
        this._skirtDepth = params.skirtDepth;
        this._renderer = params.renderer;
        this._onElevationChanged = params.onElevationChanged;

        this.matrixAutoUpdate = false;

        this.coordinate = params.coord;
        this.extent = params.extent;
        this.textureSize = params.textureSize;
        this._enableTerrainDeformation = params.enableTerrainDeformation;

        this.customDepthMaterial = params.depthMaterial;
        this.customDistanceMaterial = params.distanceMaterial;

        if (!this.geometry.boundingBox) {
            this.geometry.computeBoundingBox();
        }

        this._volume = params.volume;

        const { z, x, y } = this.coordinate;
        this.name = `tile @ (z=${z}, x=${x}, y=${y})`;

        this.frustumCulled = false;

        // Layer
        this.setDisplayed(false);

        this.material.setUuid(this.id);
        const dim = params.extent.dimensions();
        this._extentDimensions = dim;

        // Sets the default bbox volume
        this.setBBoxZ(-0.5, +0.5);

        MemoryTracker.track(this, this.name);

        this.updateSkirtParameters();
    }

    public override onBeforeShadow(): void {
        this.customDepthMaterial.onBeforeRender();
        this.customDistanceMaterial.onBeforeRender();
    }

    private updateSkirtParameters(): void {
        const skirtDepth = this._skirtDepth;

        if (skirtDepth != null) {
            this.forEachMaterial(material => {
                MaterialUtils.setDefine(material, 'ENABLE_SKIRTS', true);
                const vertexCount = this.geometry.vertexCount;
                const rowSize = this.segments + 1;
                const firstSkirtVertex = rowSize * rowSize;
                const lastSkirtVertex = vertexCount - 1;
                material.uniforms.skirtVertexRange.value = new Vector2(
                    firstSkirtVertex,
                    lastSkirtVertex,
                );
                material.uniforms.skirtElevation.value = skirtDepth;
            });
        } else {
            this.forEachMaterial(material => {
                MaterialUtils.setDefine(material, 'ENABLE_SKIRTS', false);
            });
        }
    }

    public setVerticalScaling(scaling: number): void {
        this._verticalScaling = scaling;
        this.material.setElevationScaling(scaling);
    }

    public get absolutePosition(): Vector3 {
        return this.geometry.origin;
    }

    public get showColliderMesh(): boolean {
        if (!this._helpers.colliderMesh) {
            return false;
        }
        return this._helpers.colliderMesh.material.visible;
    }

    public set showColliderMesh(visible: boolean) {
        if (visible && !this._helpers.colliderMesh) {
            this._helpers.colliderMesh = new Mesh(this.geometry.raycastGeometry, helperMaterial);
            this._helpers.colliderMesh.matrixAutoUpdate = false;
            this._helpers.colliderMesh.name = 'collider helper';
            this.createHelperRootIfNecessary();
            this._helpers.root?.add(this._helpers.colliderMesh);
            this._helpers.colliderMesh.updateMatrix();
            this._helpers.colliderMesh.updateMatrixWorld(true);
        }

        if (!visible && this._helpers.colliderMesh) {
            this._helpers.colliderMesh.removeFromParent();
            this._helpers.colliderMesh = undefined;
        }

        if (this._helpers.colliderMesh) {
            this._helpers.colliderMesh.material.visible = visible;
        }
    }

    private deleteBoundingBoxHelper(): void {
        if (this._helpers.boundingBox != null) {
            this._helpers.boundingBox.dispose();
            this._helpers.boundingBox.removeFromParent();
            this._helpers.boundingBox = undefined;
        }
    }

    private deleteBoundingSphereHelper(): void {
        if (this._helpers.boundingSphere != null) {
            this._helpers.boundingSphere.removeFromParent();
            this._helpers.boundingSphere = undefined;
        }
    }

    private recreateBoundingBoxHelper(): void {
        this.deleteBoundingBoxHelper();

        const obb = this._volume.getOBB(this.matrixWorld);

        const helper = new OBBHelper(obb, this.helperColor);

        helper.raycast = noRaycast;

        this.createHelperRootIfNecessary();

        nonNull(this._helpers.root).attach(helper);
        helper.updateMatrixWorld(true);

        this._helpers.boundingBox = helper;
    }

    private recreateBoundingSphereHelper(): void {
        this.deleteBoundingSphereHelper();

        this._helpers.boundingSphere = new Mesh(
            sphereGeometry,
            new MeshBasicMaterial({ color: this.helperColor, wireframe: true }),
        );

        this._helpers.boundingSphere.rotateX(MathUtils.degToRad(90));
        this._helpers.boundingSphere.raycast = noRaycast;

        const sphere = this._volume.getWorldSpaceBoundingSphere(tmpSphere, this.matrixWorld);
        this._helpers.boundingSphere.scale.set(sphere.radius, sphere.radius, sphere.radius);
        this._helpers.boundingSphere.position.copy(sphere.center);

        this.createHelperRootIfNecessary();

        nonNull(this._helpers.root).attach(this._helpers.boundingSphere);

        this._helpers.boundingSphere.updateMatrixWorld(true);
    }

    public get showBoundingBox(): boolean {
        return this._helpers.boundingBox?.visible ?? false;
    }

    public set showBoundingBox(show: boolean) {
        if (show && this._helpers.boundingBox == null) {
            this.recreateBoundingBoxHelper();
        } else if (!show && this._helpers.boundingBox != null) {
            this.deleteBoundingBoxHelper();
        }
    }

    public get showBoundingSphere(): boolean {
        return this._helpers.boundingSphere?.visible ?? false;
    }

    public set showBoundingSphere(show: boolean) {
        if (show && this._helpers.boundingSphere == null) {
            this.recreateBoundingSphereHelper();
        } else if (!show && this._helpers.boundingSphere != null) {
            this.deleteBoundingSphereHelper();
        }
    }

    public get helperColor(): ColorRepresentation {
        return this._helpers.color;
    }

    public set helperColor(color: ColorRepresentation) {
        this._helpers.color = color;
        if (this.showBoundingBox) {
            this.recreateBoundingBoxHelper();
        }
        if (this.showBoundingSphere) {
            this.recreateBoundingSphereHelper();
        }
    }

    public get segments(): number {
        return this._segments;
    }

    public set segments(v: number) {
        if (this._segments !== v) {
            this._segments = v;
            this.forEachMaterial(material => (material.segments = v));
            this.createGeometry();
            this._shouldUpdateHeightMap = true;
        }
    }

    private createHelperRootIfNecessary(): void {
        if (!this._helpers.root) {
            this._helpers.root = new Group();
            this._helpers.root.name = 'helpers';
            this.add(this._helpers.root);
            this._helpers.root.updateMatrixWorld(true);
        }
    }

    private createGeometry(): void {
        this.geometry.dispose();
        this.geometry = this._geometryBuilder.build({ extent: this.extent, tile: this.coordinate });
        this._tileGeometry = this.geometry;

        if (this._helpers.colliderMesh) {
            this._helpers.colliderMesh.geometry = this.geometry.raycastGeometry;
        }

        this.updateSkirtParameters();
    }

    public onLayerVisibilityChanged(layer: Layer): void {
        if (isElevationLayer(layer)) {
            this._shouldUpdateHeightMap = true;
        }
    }

    public addChildTile(tile: TileMesh): void {
        // The absolute position here means "absolute position in the cartographic coordinate system", not in the scene.
        const absolutePosition = tempAbsolutePosition.copy(tile.absolutePosition);
        tile.position.copy(absolutePosition.sub(this.absolutePosition));

        this.add(tile);
        tile.updateMatrix();
        tile.updateMatrixWorld();

        const center = tile.extent.centerAsVector2(tempVec2);
        const quadrant = this.extent.getQuadrant(center.x, center.y);

        this._childTiles[quadrant] = tile;
        this.isLeaf = false;

        if (this._heightMap) {
            const heightMap = this._heightMap.payload;
            const inheritedHeightMap = heightMap.clone();
            const offsetScale = tile.extent.offsetToParent(this.extent);
            heightMap.offsetScale.combine(offsetScale, inheritedHeightMap.offsetScale);
            tile.inheritHeightMap(intoUniqueOwner(inheritedHeightMap, this));
        }
    }

    public reorderLayers(): void {
        this.material.reorderLayers();
    }

    /**
     * Checks that the given raycaster intersects with this tile's volume.
     */
    private checkRayVolumeIntersection(raycaster: Raycaster): boolean {
        const matrixWorld = this.matrixWorld;

        // convert ray to local space of mesh

        inverseMatrix.copy(matrixWorld).invert();
        ray.copy(raycaster.ray).applyMatrix4(inverseMatrix);

        // test with bounding box in local space

        // Note that we are not using the bounding box of the geometry, because at this moment,
        // the mesh might still be completely flat, as the heightmap might not be computed yet.
        // This is the whole point of this method: to avoid computing the heightmap if not necessary.
        // So we are using the logical bounding box provided by the volume.
        return ray.intersectsBox(this.boundingBox);
    }

    public override raycast(raycaster: Raycaster, intersects: Intersection[]): void {
        if (!this.material.visible) {
            return;
        }

        // Updating the heightmap is quite costly operation that requires a texture readback.
        // Let's do it only if the ray intersects the volume of this tile.
        if (this.checkRayVolumeIntersection(raycaster)) {
            this.updateHeightMapIfNecessary();

            // We have to distinguish between the rendered geometry and the raycasting geometry.
            // However, three.js does not let use choose which will be used for raycasting,
            // so we temporarily swap the geometry with the raycast geometry to perform raycasting.
            // @ts-expect-error type mismatch is expected and transient
            this.geometry = this._tileGeometry.raycastGeometry;

            super.raycast(raycaster, intersects);

            this.geometry = this._tileGeometry;
        }
    }

    private updateHeightMapIfNecessary(): void {
        if (this._shouldUpdateHeightMap) {
            this._shouldUpdateHeightMap = false;

            if (this._elevationLayerInfo) {
                this.createHeightMap(
                    this._elevationLayerInfo.renderTarget,
                    this._elevationLayerInfo.offsetScale,
                );

                const shouldHeightmapBeActive =
                    this._elevationLayerInfo.layer.visible && this._enableTerrainDeformation;

                if (shouldHeightmapBeActive) {
                    this.applyHeightMap();
                } else {
                    this.resetHeights();
                }
            }
        }
    }

    /**
     * @param neighbour - The neighbour.
     * @param location - Its location in the neighbour array.
     */
    private processNeighbour(neighbour: TileMesh, location: number): void {
        const diff = neighbour.lod - this.lod;

        const neighbourTexture = neighbour.material.getElevationTexture();
        const neighbourOffsetScale = neighbour.material.getElevationOffsetScale();

        const offsetScale = this.extent.offsetToParent(neighbour.extent);
        const nOffsetScale = neighbourOffsetScale.combine(offsetScale);

        this.forEachMaterial(material => {
            material.updateNeighbour(location, diff, nOffsetScale, neighbourTexture);
        });
    }

    /**
     * @param neighbours - The neighbours.
     */
    public processNeighbours(neighbours: NeighbourList<TileMesh>): void {
        for (let i = 0; i < neighbours.length; i++) {
            const neighbour = neighbours[i];
            if (neighbour != null && neighbour.material != null && neighbour.material.visible) {
                this.processNeighbour(neighbour, i);
            } else {
                this.forEachMaterial(material =>
                    material.updateNeighbour(i, NO_NEIGHBOUR, NO_OFFSET_SCALE, null),
                );
            }
        }
    }

    public update(materialOptions: MaterialOptions): void {
        if (this._heightMap && this._elevationLayerInfo) {
            if (this._enableTerrainDeformation !== materialOptions.terrain.enabled) {
                this._enableTerrainDeformation = materialOptions.terrain.enabled;
                this._shouldUpdateHeightMap = true;
            }
        }

        this.helperColor = materialOptions.helperColor ?? 'cyan';
        this.showColliderMesh = materialOptions.showColliderMeshes ?? false;
        this.showBoundingBox = materialOptions.showBoundingBoxes ?? false;
        this.showBoundingSphere = materialOptions.showBoundingSpheres ?? false;
    }

    public isVisible(): boolean {
        return this.visible;
    }

    public setDisplayed(show: boolean): void {
        const currentVisibility = this.material.visible;
        this.material.visible = show && this.material.update();
        if (this._helpers.root) {
            if (this._helpers.boundingBox) {
                this._helpers.boundingBox.color = show ? this.helperColor : 'gray';
            }
        }
        if (currentVisibility !== show) {
            this.dispatchEvent({ type: 'visibility-changed' });
        }
    }

    /**
     * @param v - The new opacity.
     */
    public set opacity(v: number) {
        this.material.opacity = v;
    }

    public setVisibility(show: boolean): void {
        const currentVisibility = this.visible;
        this.visible = show;
        if (currentVisibility !== show) {
            this.dispatchEvent({ type: 'visibility-changed' });
        }
    }

    public isDisplayed(): boolean {
        return this.material.visible;
    }

    /**
     * Updates the rendering state of the tile's material.
     *
     * @param state - The new rendering state.
     */
    public changeState(state: RenderingState): void {
        this.material.changeState(state);
    }

    public static applyChangeState(o: Object3D, s: RenderingState): void {
        if ((o as TileMesh).isTileMesh) {
            (o as TileMesh).changeState(s);
        }
    }

    public pushRenderState(state: RenderingState): () => void {
        if (this.material.uniforms.renderingState.value === state) {
            return (): void => {
                /** do nothing */
            };
        }

        const oldState = this.material.uniforms.renderingState.value;
        this.traverse(n => TileMesh.applyChangeState(n, state));

        return (): void => {
            this.traverse(n => TileMesh.applyChangeState(n, oldState));
        };
    }

    public canProcessColorLayer(): boolean {
        if (!this._elevationLayerInfo) {
            // No elevation layer that prevents loading color data
            return true;
        }

        return this._elevationLayerInfo.layer.isLoaded(this.id);
    }

    public removeElevationTexture(): void {
        this._elevationLayerInfo = null;
        this._shouldUpdateHeightMap = true;
        this.material.removeElevationLayer();
    }

    public setElevationTexture(
        layer: ElevationLayer,
        elevation: {
            texture: Texture;
            pitch: OffsetScale;
            min?: number;
            max?: number;
            renderTarget: WebGLRenderTarget;
        },
    ): void {
        if (this.disposed) {
            return;
        }

        this._elevationLayerInfo = {
            layer,
            offsetScale: elevation.pitch,
            renderTarget: elevation.renderTarget,
        };

        this.material.setElevationTexture(layer, elevation);

        this.setBBoxZ(elevation.min, elevation.max);

        this._shouldUpdateHeightMap = true;

        this._onElevationChanged(this);
    }

    public getScreenPixelSize(view: View, target?: Vector2): Vector2 {
        target = target ?? new Vector2();

        const sphere = this.getWorldSpaceBoundingSphere(tmpSphere);

        const distance = sphere.center.distanceTo(view.camera.getWorldPosition(tempVec3));

        let height: number;
        let width: number;

        const camera = view.camera;

        if (isPerspectiveCamera(camera)) {
            const fovRads = MathUtils.degToRad(camera.fov);
            height = 2 * Math.tan(fovRads / 2) * distance;
            width = height * camera.aspect;
        } else {
            height = Math.abs(camera.top - camera.bottom);
            width = Math.abs(camera.right - camera.left);
        }

        const diameter = sphere.radius * 2;

        const wRatio = diameter / width;
        const hRatio = diameter / height;

        target.setX(Math.ceil(wRatio * view.width));
        target.setY(Math.ceil(hRatio * view.height));

        return target;
    }

    private createHeightMap(renderTarget: WebGLRenderTarget, offsetScale: OffsetScale): void {
        const outputHeight = Math.floor(renderTarget.height);
        const outputWidth = Math.floor(renderTarget.width);

        // One millimeter
        const precision = 0.001;

        // To ensure that all values are positive before encoding
        const offset = -this._minmax.min;

        const buffer = readRGRenderTargetIntoRGBAU8Buffer({
            renderTarget,
            renderer: this._renderer,
            outputWidth,
            outputHeight,
            precision,
            offset,
        });

        const heightMap = new HeightMap(
            buffer,
            outputWidth,
            outputHeight,
            offsetScale,
            RGBAFormat,
            UnsignedByteType,
            precision,
            offset,
            this._verticalScaling,
        );
        this._heightMap = intoUniqueOwner(heightMap, this);
    }

    private inheritHeightMap(heightMap: UniqueOwner<HeightMap, this>): void {
        this._heightMap = heightMap;
        this._shouldUpdateHeightMap = true;

        // Let's get a more precise minmax from the inherited heightmap, but
        // only on the region of the inherited heightmap that matches this tile's extent
        // (otherwise this would not provide any benefit at all);
        const minmax = heightMap.payload.getMinMax(THIS_RECT);
        if (minmax != null) {
            this._minmax = minmax;
        }
    }

    private resetHeights(): void {
        this.geometry.resetHeights();
        this.setBBoxZ(0, 0);

        this._onElevationChanged(this);
    }

    /** @internal */
    public applyHeightMap(): void {
        if (!this._heightMap) {
            return;
        }

        const { min, max } = this.geometry.applyHeightMap(this._heightMap.payload);

        if (min > this._minmax.min && max < this._minmax.max) {
            this.setBBoxZ(min, max);
        }

        if (this._helpers.colliderMesh) {
            this._helpers.colliderMesh.geometry = this.geometry.raycastGeometry;
        }

        this._onElevationChanged(this);
    }

    public setBBoxZ(min: number | undefined, max: number | undefined): void {
        // 0 is an acceptable value
        if (min == null || max == null) {
            return;
        }
        this._minmax = { min, max };

        if (this._skirtDepth != null) {
            this._minmax.min = Math.min(this._skirtDepth, this._minmax.min);
        }

        this.updateVolume(min, max);
    }

    public traverseTiles(callback: (descendant: TileMesh) => void): void {
        this.traverse(obj => {
            if (isTileMesh(obj)) {
                callback(obj);
            }
        });
    }

    /**
     * Removes the child tiles and returns the detached tiles.
     */
    public detachChildren(): TileMesh[] {
        const childTiles = this.children.filter(c => isTileMesh(c)) as TileMesh[];
        childTiles.forEach(c => c.dispose());
        this.remove(...childTiles);
        this.isLeaf = true;
        return childTiles;
    }

    private updateVolume(min: number, max: number): void {
        this._volume.setElevationRange({ min, max });

        if (this.showBoundingBox) {
            this.recreateBoundingBoxHelper();
        }
        if (this.showBoundingSphere) {
            this.recreateBoundingSphereHelper();
        }
    }

    public get minmax(): { min: number; max: number } {
        const range = Math.abs(this._minmax.max - this._minmax.min);
        const width = this._extentDimensions.width;
        const height = this._extentDimensions.height;
        const RATIO = 3;

        // If the current volume is very elongated in the vertical axis,
        // this can cause excessive subdivisions of the tile. Let's compute
        // the heightmap to get a more precise min/max and hopefully a tighter
        // volume. Note that the heightmap will be computed only if it does not
        // exist, avoiding unnecessary computations.
        if (range / Math.max(width, height) > RATIO) {
            this.updateHeightMapIfNecessary();
        }

        return this._minmax;
    }

    public getExtent(): Extent {
        return this.extent;
    }

    public getElevation(
        params: GetElevationOptions,
    ): { elevation: number; resolution: number } | null {
        this.updateHeightMapIfNecessary();

        if (this._heightMap) {
            const uv = this.extent.offsetInExtent(params.coordinates, tempVec2);

            const heightMap = this._heightMap.payload;
            const elevation = heightMap.getValue(uv.x, uv.y);

            if (elevation != null) {
                const dims = this.extent.dimensions(tempVec2);
                const xRes = dims.x / heightMap.width;
                const yRes = dims.y / heightMap.height;
                const resolution = Math.min(xRes, yRes);

                return { elevation, resolution };
            }
        }

        return null;
    }

    /**
     * Search for a common ancestor between this tile and another one. It goes
     * through parents on each side until one is found.
     *
     * @param tile - the tile to evaluate
     * @returns the resulting common ancestor
     */
    public findCommonAncestor(tile: TileMesh): TileMesh | null {
        if (tile == null) {
            return null;
        }
        if (tile.lod === this.lod) {
            if (tile.id === this.id) {
                return tile;
            }
            if (tile.lod !== 0) {
                return (this.parent as TileMesh).findCommonAncestor(tile.parent as TileMesh);
            }
            return null;
        }
        if (tile.lod < this.lod) {
            return (this.parent as TileMesh).findCommonAncestor(tile);
        }
        return this.findCommonAncestor(tile.parent as TileMesh);
    }

    public isAncestorOf(tile: TileMesh): boolean {
        return tile.findCommonAncestor(this) === this;
    }

    private forEachMaterial(callbackFn: (material: LayeredMaterial) => void): void {
        callbackFn(this.material);
        callbackFn(this.customDepthMaterial);
        callbackFn(this.customDistanceMaterial);
    }

    public getLeafThatContains(x: number, y: number): TileMesh | undefined {
        if (!this.extent.isXYInside(x, y)) {
            throw new Error('this tile does not contain the coordinates');
        }

        if (this.isLeaf) {
            return this;
        }

        const quadrant = this.extent.getQuadrant(x, y);

        return this._childTiles[quadrant]?.getLeafThatContains(x, y);
    }

    public dispose(): void {
        if (this.disposed) {
            return;
        }
        this.disposed = true;
        this.dispatchEvent({ type: 'dispose' });

        this.forEachMaterial(m => m.dispose());
        this.geometry.dispose();
    }
}

export function isTileMesh(o: unknown): o is TileMesh {
    return (o as TileMesh).isTileMesh;
}

export default TileMesh;
