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

import type {
    ColorRepresentation,
    IUniform,
    Light,
    Side,
    Texture,
    TextureDataType,
    WebGLProgramParametersWithUniforms,
    WebGLRenderer,
} from 'three';

import {
    Color,
    GLSL3,
    NoBlending,
    NormalBlending,
    RGBAFormat,
    ShaderMaterial,
    Uniform,
    UniformsLib,
    UnsignedByteType,
    Vector2,
    Vector3,
    Vector4,
} from 'three';

import type ColorimetryOptions from '../core/ColorimetryOptions';
import type ColorMapMode from '../core/ColorMapMode';
import type ContourLineOptions from '../core/ContourLineOptions';
import type ElevationRange from '../core/ElevationRange';
import type Extent from '../core/geographic/Extent';
import type GraticuleOptions from '../core/GraticuleOptions';
import type ColorLayer from '../core/layer/ColorLayer';
import type ElevationLayer from '../core/layer/ElevationLayer';
import type Layer from '../core/layer/Layer';
import type { TextureAndPitch } from '../core/layer/Layer';
import type MaskLayer from '../core/layer/MaskLayer';
import type { MaskMode } from '../core/layer/MaskLayer';
import type MemoryUsage from '../core/MemoryUsage';
import type TerrainOptions from '../core/TerrainOptions';
import type MapLightingOptions from '../entities/MapLightingOptions';
import type { AtlasInfo, LayerAtlasInfo } from './AtlasBuilder';
import type ColorMapAtlas from './ColorMapAtlas';

import BlendingMode from '../core/layer/BlendingMode';
import { type GetMemoryUsageContext } from '../core/MemoryUsage';
import OffsetScale from '../core/OffsetScale';
import Rect from '../core/Rect';
import { MapLightingMode } from '../entities/MapLightingOptions';
import Capabilities from '../utils/Capabilities';
import { getColor } from '../utils/predicates';
import TextureGenerator from '../utils/TextureGenerator';
import { nonNull } from '../utils/tsutils';
import AtlasBuilder from './AtlasBuilder';
import WebGLComposer from './composition/WebGLComposer';
import EmptyTexture from './EmptyTexture';
import MaterialUtils from './MaterialUtils';
import MemoryTracker from './MemoryTracker';
import RenderingState from './RenderingState';
import TileFS from './shader/TileFS.glsl';
import TileVS from './shader/TileVS.glsl';

const EMPTY_IMAGE_SIZE = 16;

const tmpDims = new Vector2();

const emptyTexture = new EmptyTexture();

const COLORMAP_DISABLED = 0;

const DISABLED_ELEVATION_RANGE = new Vector2(-999999, 999999);

class TextureInfo {
    public readonly layer: ColorLayer;

    public originalOffsetScale: OffsetScale;
    public offsetScale: OffsetScale;
    public texture: Texture;
    public opacity: number;
    public visible: boolean;
    public color: Color;
    public elevationRange?: Vector2;
    public brightnessContrastSaturation: Vector3;

    public constructor(layer: ColorLayer) {
        this.layer = layer;
        this.opacity = layer.opacity;
        this.visible = layer.visible;
        this.offsetScale = new OffsetScale(0, 0, 0, 0);
        this.originalOffsetScale = new OffsetScale(0, 0, 0, 0);
        this.texture = emptyTexture;
        this.color = new Color(1, 1, 1);
        this.brightnessContrastSaturation = new Vector3(0, 1, 1);
    }

    public get mode(): MaskMode {
        return (this.layer as MaskLayer).maskMode ?? 0;
    }
}
export const DEFAULT_OUTLINE_COLOR = 'red';
export const DEFAULT_HILLSHADING_INTENSITY = 1;
export const DEFAULT_HILLSHADING_ZFACTOR = 1;
export const DEFAULT_AZIMUTH = 135;
export const DEFAULT_ZENITH = 45;
export const DEFAULT_GRATICULE_COLOR = new Color(0, 0, 0);
export const DEFAULT_GRATICULE_STEP = 500; // meters
export const DEFAULT_GRATICULE_THICKNESS = 1;
export const DEFAULT_SUN_DIRECTION = new Vector3(1, 0, 0);

function drawImageOnAtlas(
    width: number,
    height: number,
    composer: WebGLComposer,
    atlasInfo: LayerAtlasInfo,
    texture: Texture,
): void {
    const dx = atlasInfo.x;
    const dy = atlasInfo.y + nonNull(atlasInfo.offset);
    const dw = width;
    const dh = height;

    const rect = new Rect(dx, dx + dw, dy, dy + dh);

    composer.draw(texture, rect);
}

function updateOffsetScale(
    imageSize: Vector2,
    atlas: LayerAtlasInfo,
    originalOffsetScale: OffsetScale,
    width: number,
    height: number,
    target: OffsetScale,
): void {
    if (originalOffsetScale.z === 0 || originalOffsetScale.w === 0) {
        target.set(0, 0, 0, 0);
        return;
    }
    // compute offset / scale
    const xRatio = imageSize.width / width;
    const yRatio = imageSize.height / height;

    target.set(
        atlas.x / width + originalOffsetScale.x * xRatio,
        (atlas.y + nonNull(atlas.offset)) / height + originalOffsetScale.y * yRatio,
        originalOffsetScale.z * xRatio,
        originalOffsetScale.w * yRatio,
    );
}

function repeat<T extends object>(value: T, count: number): T[] {
    const result: T[] = new Array(count);
    for (let i = 0; i < count; i++) {
        result[i] = { ...value };
    }
    return result;
}

export interface MaterialOptions {
    /**
     * Discards no-data pixels.
     */
    discardNoData: boolean;
    /**
     * Geometric terrain options.
     */
    terrain: Required<TerrainOptions>;
    /**
     * Colorimetry options for the entire material.
     */
    colorimetry: Required<ColorimetryOptions>;
    /**
     * The sidedness.
     */
    side: Side;
    /**
     * Contour lines options.
     */
    contourLines: Required<ContourLineOptions>;
    /**
     * Lighting options.
     */
    lighting: Required<MapLightingOptions>;
    /**
     * Graticule options.
     */
    graticule: Required<GraticuleOptions>;
    /**
     * The elevation range.
     */
    elevationRange: { min: number; max: number } | null;
    /**
     * The colormap atlas.
     */
    colorMapAtlas: ColorMapAtlas | null;
    /**
     * The background color.
     */
    backgroundColor: Color;
    /**
     * The background opacity.
     */
    backgroundOpacity: number;
    /**
     * Show the outlines of tile meshes.
     */
    showTileOutlines: boolean;
    /**
     * The tile outline color.
     * @defaultValue {@link DEFAULT_OUTLINE_COLOR}
     */
    tileOutlineColor: Color;
    /**
     * Force using texture atlases even when not required by WebGL limitations.
     */
    forceTextureAtlases: boolean;
    /**
     * Displays the collider meshes used for raycast.
     */
    showColliderMeshes: boolean;
    /**
     * Displays the bounding boxes of tiles.
     */
    showBoundingBoxes: boolean;
    /**
     * Displays the bounding spheres of tiles.
     */
    showBoundingSpheres: boolean;
    helperColor: ColorRepresentation;
    depthTest: boolean;
}

enum InternalShadingMode {
    Disabled = 0,
    Simple = 1,
    Realistic = 2,
}

function mapLightingMode(input: MapLightingOptions): InternalShadingMode {
    if (input.enabled !== true) {
        return InternalShadingMode.Disabled;
    }

    if (input.mode === MapLightingMode.LightBased) {
        return InternalShadingMode.Realistic;
    }
    return InternalShadingMode.Simple;
}

interface HillshadingUniform {
    mode: InternalShadingMode;
    intensity: number;
    zFactor: number;
    zenith: number;
    azimuth: number;
}

interface ContourLineUniform {
    thickness: number;
    primaryInterval: number;
    secondaryInterval: number;
    color: Vector4;
}

interface GraticuleUniform {
    thickness: number;
    /** xOffset, yOffset, xStep, yStep */
    position: Vector4;
    color: Vector4;
}

interface LayerUniform {
    offsetScale: Vector4;
    color: Vector4;
    textureSize: Vector2;
    elevationRange: Vector2;
    brightnessContrastSaturation: Vector3;
    mode: 0 | MaskMode;
    blendingMode: BlendingMode;
}

interface NeighbourUniform {
    offsetScale: Vector4 | null;
    diffLevel: number;
}

interface ColorMapUniform {
    mode: ColorMapMode | 0;
    min: number;
    max: number;
    offset: number;
}

interface Defines extends Record<string, unknown> {
    ENABLE_CONTOUR_LINES?: 1;
    STITCHING?: 1;
    TERRAIN_DEFORMATION?: 1;
    DISCARD_NODATA_ELEVATION?: 1;
    ENABLE_ELEVATION_RANGE?: 1;
    ELEVATION_LAYER?: 1;
    ENABLE_LAYER_MASKS?: 1;
    ENABLE_OUTLINES?: 1;
    APPLY_SHADING_ON_COLORLAYERS?: 1;
    ENABLE_GRATICULE?: 1;
    USE_ATLAS_TEXTURE?: 1;

    /** Normal color rendering */
    COLOR_RENDER?: 1;
    /** For depth-based effects, such as shadow maps for directional lights */
    DEPTH_RENDER?: 1;
    /** For distance-based effects, such as shadow maps for point lights */
    DISTANCE_RENDER?: 1;

    /** The number of _visible_ color layers */
    /**
     * The z coordinate of vertices is reset before computing terrain
     */
    GLOBE?: 1;
    /**
     * The number of _visible_ color layers
     */
    VISIBLE_COLOR_LAYER_COUNT: number;

    ENABLE_SKIRTS?: 1;
}

type ThreeUniforms = typeof UniformsLib.common & typeof UniformsLib.fog & typeof UniformsLib.lights;

interface Uniforms extends ThreeUniforms, Record<string, IUniform> {
    // The id of the tile encoded into a single float
    uuid: IUniform<number>;

    // Lighting & shading
    hillshading: IUniform<HillshadingUniform>;

    renderingState: IUniform<RenderingState>;

    segments: IUniform<number>;
    extent: IUniform<Vector4>;
    tileDimensions: IUniform<Vector2>;
    neighbours: IUniform<NeighbourUniform[]>;
    neighbourTextures: IUniform<(Texture | null)[]>;

    elevationRange: IUniform<Vector2>;

    baseTextureSize: IUniform<Vector2>;

    graticule: IUniform<GraticuleUniform>;

    contourLines: IUniform<ContourLineUniform>;

    backgroundColor: IUniform<Vector4>;
    tileOutlineColor: IUniform<Color>;

    brightnessContrastSaturation: IUniform<Vector3>;

    colorMapAtlas: IUniform<Texture | null>;
    layersColorMaps: IUniform<ColorMapUniform[]>;
    elevationColorMap: IUniform<ColorMapUniform>;

    elevationScaling: IUniform<number>;

    elevationTexture: IUniform<Texture | null>;
    atlasTexture: IUniform<Texture | null>;
    colorTextures: IUniform<Texture[]>;

    layers: IUniform<LayerUniform[]>;
    elevationLayer: IUniform<LayerUniform>;

    // For distance-based rendering (point light shadow maps)
    referencePosition: IUniform<Vector3>;
    nearDistance: IUniform<number>;
    farDistance: IUniform<number>;

    // Skirts related uniforms
    // The skirt elevation, in CRS units (might be negative)
    skirtElevation: IUniform<number>;
    // The start and end index of vertices located at the bottom of the skirt
    skirtVertexRange: IUniform<Vector2>;
}

class LayeredMaterial extends ShaderMaterial implements MemoryUsage {
    public readonly isMemoryUsage = true as const;

    // Used for point-light shadow maps
    public light?: Light;

    private readonly _getIndexFn: (arg0: Layer) => number;
    private readonly _renderer: WebGLRenderer;
    private readonly _colorLayers: ColorLayer[] = [];
    private readonly _atlasInfo: AtlasInfo;
    private readonly _forceTextureAtlas: boolean;
    private readonly _maxTextureImageUnits: number;
    private readonly _textureSize: Vector2;
    private readonly _texturesInfo: {
        color: {
            infos: TextureInfo[];
            atlasTexture: Texture | null;
        };
        elevation: {
            offsetScale: OffsetScale;
            texture: Texture | null;
        };
    };

    private _elevationLayer: ElevationLayer | null = null;
    private _mustUpdateUniforms = true;
    private _needsSorting = true;
    private _needsAtlasRepaint = false;
    private _composer: WebGLComposer | null = null;
    private _colorMapAtlas: ColorMapAtlas | null = null;
    private _composerDataType: TextureDataType = UnsignedByteType;

    public override readonly uniforms: Uniforms;

    public override readonly defines: Defines = {
        VISIBLE_COLOR_LAYER_COUNT: 0,
    };

    private _options?: MaterialOptions;

    public getMemoryUsage(context: GetMemoryUsageContext): void {
        // We only consider textures that this material owns. That excludes layer textures.
        const atlas = this._texturesInfo.color.atlasTexture;
        if (atlas) {
            TextureGenerator.getMemoryUsage(context, atlas);
        }
    }

    public constructor(params: {
        /** the material options. */
        options: MaterialOptions;
        /** the WebGL renderer. */
        renderer: WebGLRenderer;
        /** The number of maximum texture units in fragment shaders */
        maxTextureImageUnits: number;
        /** The function to help sorting color layers. */
        getIndexFn: (arg0: Layer) => number;
        /** The texture data type to be used for the atlas texture. */
        textureDataType: TextureDataType;
        hasElevationLayer: boolean;
        tileDimensions: Vector2;
        extent: Extent;
        textureSize: Vector2;
        isGlobe: boolean;
    }) {
        super({ clipping: true, glslVersion: GLSL3 });

        this._atlasInfo = { maxX: 0, maxY: 0, atlas: null };
        this._textureSize = params.textureSize;
        this.fog = true;
        this._maxTextureImageUnits = params.maxTextureImageUnits;
        this._getIndexFn = params.getIndexFn;

        const options = params.options;

        MaterialUtils.setDefine(this, 'USE_ATLAS_TEXTURE', false);
        MaterialUtils.setDefine(this, 'STITCHING', options.terrain.stitching);
        MaterialUtils.setDefine(this, 'GLOBE', params.isGlobe);
        MaterialUtils.setDefine(this, 'TERRAIN_DEFORMATION', options.terrain.enabled);
        MaterialUtils.setDefine(this, 'DISCARD_NODATA_ELEVATION', options.discardNoData);
        MaterialUtils.setDefine(this, 'ENABLE_ELEVATION_RANGE', options.elevationRange != null);
        MaterialUtils.setDefineValue(this, 'VISIBLE_COLOR_LAYER_COUNT', 0);
        MaterialUtils.setDefine(this, 'COLOR_RENDER', true);

        this.fragmentShader = TileFS;
        this.vertexShader = TileVS;

        this._texturesInfo = {
            color: {
                infos: [],
                atlasTexture: null,
            },
            elevation: {
                offsetScale: new OffsetScale(0, 0, 0, 0),
                texture: null,
            },
        };

        this.side = options.side;
        this.lights = true;
        this._renderer = params.renderer;
        this._forceTextureAtlas = options.forceTextureAtlases ?? false;
        this._composerDataType = params.textureDataType;
        this._colorMapAtlas = options.colorMapAtlas ?? null;

        const elevationRange = options.elevationRange
            ? new Vector2(options.elevationRange.min, options.elevationRange.max)
            : DISABLED_ELEVATION_RANGE;

        const elevInfo = this._texturesInfo.elevation;

        const extent = params.extent;

        const { width, height } = extent.dimensions(tmpDims);

        this.uniforms = {
            // Automatically updated by THREE.js
            ...UniformsLib.common,
            ...UniformsLib.lights,
            ...UniformsLib.fog,

            // Uniforms for point light shadow maps
            referencePosition: new Uniform(new Vector3()),
            nearDistance: new Uniform(1),
            farDistance: new Uniform(1000),

            uuid: new Uniform(0),

            baseTextureSize: new Uniform(this._textureSize),

            hillshading: new Uniform<HillshadingUniform>({
                mode: mapLightingMode(options.lighting),
                zenith: DEFAULT_ZENITH,
                azimuth: DEFAULT_AZIMUTH,
                intensity: DEFAULT_HILLSHADING_INTENSITY,
                zFactor: DEFAULT_HILLSHADING_ZFACTOR,
            }),

            renderingState: new Uniform(RenderingState.FINAL),

            extent: new Uniform(new Vector4(extent.minX, extent.minY, width, height)),
            tileDimensions: new Uniform(params.tileDimensions),
            segments: new Uniform(options.terrain.segments ?? 8),
            neighbours: new Uniform(
                repeat<NeighbourUniform>(
                    {
                        diffLevel: 0,
                        offsetScale: null,
                    },
                    8,
                ),
            ),
            neighbourTextures: new Uniform([null, null, null, null, null, null, null, null]),

            elevationRange: new Uniform(elevationRange),

            graticule: new Uniform<GraticuleUniform>({
                color: new Vector4(0, 0, 0, 1),
                thickness: DEFAULT_GRATICULE_THICKNESS,
                position: new Vector4(0, 0, DEFAULT_GRATICULE_STEP, DEFAULT_GRATICULE_STEP),
            }),

            contourLines: new Uniform({
                thickness: 1,
                primaryInterval: 100,
                secondaryInterval: 20,
                color: new Vector4(0, 0, 0, 1),
            }),

            backgroundColor: new Uniform(new Vector4()),
            tileOutlineColor: new Uniform(new Color(DEFAULT_OUTLINE_COLOR)),

            brightnessContrastSaturation: new Uniform(new Vector3(0, 1, 1)),

            colorMapAtlas: new Uniform(null),
            layersColorMaps: new Uniform([]),
            elevationColorMap: new Uniform<ColorMapUniform>({
                mode: 0,
                offset: 0,
                max: 0,
                min: 0,
            }),

            elevationScaling: new Uniform(1),

            elevationTexture: new Uniform(elevInfo.texture),
            atlasTexture: new Uniform(this._texturesInfo.color.atlasTexture),
            colorTextures: new Uniform([]),

            // Describe the properties of each color layer (offsetScale, color...).
            layers: new Uniform([]),
            elevationLayer: new Uniform<LayerUniform>({
                brightnessContrastSaturation: new Vector3(0, 1, 1),
                color: new Vector4(0, 0, 0, 0),
                elevationRange: new Vector2(0, 0),
                offsetScale: new OffsetScale(0, 0, 0, 0),
                textureSize: new Vector2(0, 0),
                blendingMode: BlendingMode.None,
                mode: 0,
            }),

            skirtVertexRange: new Uniform(new Vector2(0, 0)),
            skirtElevation: new Uniform(0),
        };

        this.uniformsNeedUpdate = true;

        this.update(options);

        MemoryTracker.track(this, 'LayeredMaterial');
    }

    /**
     * @param v - The number of segments.
     */
    public set segments(v: number) {
        this.uniforms.segments.value = v;
    }

    public updateNeighbour(
        neighbour: number,
        diffLevel: number,
        offsetScale: OffsetScale,
        texture: Texture | null,
    ): void {
        this.uniforms.neighbours.value[neighbour].diffLevel = diffLevel;
        this.uniforms.neighbours.value[neighbour].offsetScale = offsetScale;
        this.uniforms.neighbourTextures.value[neighbour] = texture;
    }

    public setElevationScaling(scaling: number): void {
        this.uniforms.elevationScaling.value = scaling;
    }

    public override onBeforeCompile(parameters: WebGLProgramParametersWithUniforms): void {
        // This is a workaround due to a limitation in three.js, documented
        // here: https://github.com/mrdoob/three.js/issues/28020
        // Normally, we would not have to do this and let the loop unrolling do its job.
        // However, in our case, the loop end index is not an integer, but a define.
        // We have to patch the fragment shader ourselves because three.js will not do it
        // before the loop is unrolled, leading to a compilation error.
        parameters.fragmentShader = parameters.fragmentShader.replaceAll(
            'COLOR_LAYERS_LOOP_END',
            `${this.defines.VISIBLE_COLOR_LAYER_COUNT}`,
        );
    }

    private updateColorLayerUniforms(): void {
        const useAtlas = this.defines.USE_ATLAS_TEXTURE === 1;

        this.sortLayersIfNecessary();

        if (this._mustUpdateUniforms) {
            const layersUniform: LayerUniform[] = [];
            const infos = this._texturesInfo.color.infos;
            const textureUniforms = this.uniforms.colorTextures.value;
            textureUniforms.length = 0;

            for (const info of infos) {
                const layer = info.layer;
                // Ignore non-visible layers
                if (!layer.visible) {
                    continue;
                }

                // If we use an atlas, the offset/scale is different.
                const offsetScale = useAtlas ? info.offsetScale : info.originalOffsetScale;
                const tex = info.texture;
                let textureSize = new Vector2(0, 0);
                const image = tex.image;
                if (image != null) {
                    textureSize = new Vector2(image.width, image.height);
                }

                const rgb = info.color;
                const a = info.visible ? info.opacity : 0;
                const color = new Vector4(rgb.r, rgb.g, rgb.b, a);
                const elevationRange = info.elevationRange || DISABLED_ELEVATION_RANGE;

                const uniform: LayerUniform = {
                    offsetScale,
                    color,
                    textureSize,
                    elevationRange,
                    mode: info.mode,
                    blendingMode: layer.blendingMode,
                    brightnessContrastSaturation: info.brightnessContrastSaturation,
                };

                layersUniform.push(uniform);

                if (!useAtlas) {
                    textureUniforms.push(tex);
                }
            }

            this.uniforms.layers.value = layersUniform;
        }
    }

    public override dispose(): void {
        this.dispatchEvent({
            type: 'dispose',
        });

        for (const layer of this._colorLayers) {
            const index = this.indexOfColorLayer(layer);
            if (index === -1) {
                continue;
            }
            delete this._texturesInfo.color.infos[index];
        }

        this._colorLayers.length = 0;
        this._composer?.dispose();
        this._texturesInfo.color.atlasTexture?.dispose();
    }

    public getColorTexture(layer: ColorLayer): Texture | null {
        const index = this.indexOfColorLayer(layer);

        if (index === -1) {
            return null;
        }
        return this._texturesInfo.color.infos[index].texture;
    }

    private countIndividualTextures(): { totalTextureUnits: number; visibleColorLayers: number } {
        let totalTextureUnits = 0;
        if (this._elevationLayer) {
            totalTextureUnits++;

            if (this.defines.STITCHING) {
                // We use 8 neighbour textures for stit-ching
                totalTextureUnits += 8;
            }
        }
        if (this._colorMapAtlas) {
            totalTextureUnits++;
        }

        const visibleColorLayers = this.getVisibleColorLayerCount();
        // Count only visible color layers
        totalTextureUnits += visibleColorLayers;

        return { totalTextureUnits, visibleColorLayers };
    }

    public override onBeforeRender(): void {
        this.updateOpacityParameters(this.opacity);

        if (this.defines.USE_ATLAS_TEXTURE && this._needsAtlasRepaint) {
            this.repaintAtlas();
            this._needsAtlasRepaint = false;
        }

        this.updateColorWrite();

        this.updateColorLayerUniforms();

        this.updateColorMaps();
    }

    /**
     * Determine if this material should write to the color buffer.
     */
    private updateColorWrite(): void {
        if (this._texturesInfo.elevation.texture == null && this.defines.DISCARD_NODATA_ELEVATION) {
            // No elevation texture means that every single fragment will be discarded,
            // which is an illegal operation in WebGL (raising warnings).
            this.colorWrite = false;
        } else {
            this.colorWrite = true;
        }
    }

    public repaintAtlas(): void {
        this.rebuildAtlasIfNecessary();

        const composer = nonNull(this._composer);

        composer.clear();

        // Redraw all visible color layers on the canvas
        for (const l of this._colorLayers) {
            if (!l.visible) {
                continue;
            }

            const idx = this.indexOfColorLayer(l);
            const atlas = nonNull(this._atlasInfo.atlas)[l.id];

            const layerTexture = this._texturesInfo.color.infos[idx].texture;

            const w = layerTexture?.image?.width ?? EMPTY_IMAGE_SIZE;
            const h = layerTexture?.image?.height ?? EMPTY_IMAGE_SIZE;

            updateOffsetScale(
                new Vector2(w, h),
                atlas,
                this._texturesInfo.color.infos[idx].originalOffsetScale,
                this.composerWidth,
                this.composerHeight,
                this._texturesInfo.color.infos[idx].offsetScale,
            );

            if (layerTexture != null) {
                drawImageOnAtlas(w, h, nonNull(composer), atlas, layerTexture);
            }
        }

        const rendered = composer.render();
        rendered.name = 'LayeredMaterial - Atlas';

        MemoryTracker.track(rendered, rendered.name);

        // Even though we asked the composer to reuse the same texture, sometimes it has
        // to recreate a new texture when some parameters change, such as pixel format.
        if (rendered.uuid !== this._texturesInfo.color.atlasTexture?.uuid) {
            this.rebuildAtlasTexture(rendered);
        }

        this.uniforms.atlasTexture.value = this._texturesInfo.color.atlasTexture;
    }

    public setColorTextures(layer: ColorLayer, textureAndPitch: TextureAndPitch): void {
        const index = this.indexOfColorLayer(layer);
        if (index < 0) {
            this.pushColorLayer(layer);
        }

        const { pitch, texture } = textureAndPitch;
        this._texturesInfo.color.infos[index].originalOffsetScale.copy(pitch);
        this._texturesInfo.color.infos[index].texture = texture;

        const currentSize = TextureGenerator.getBytesPerChannel(this._composerDataType);
        const textureSize = TextureGenerator.getBytesPerChannel(texture.type);
        if (textureSize > currentSize) {
            // The new layer uses a bigger data type, we need to recreate the atlas
            this._composerDataType = texture.type;
        }

        this._needsAtlasRepaint = true;
    }

    public pushElevationLayer(layer: ElevationLayer): void {
        this._elevationLayer = layer;
    }

    public removeElevationLayer(): void {
        this._elevationLayer = null;
        this.uniforms.elevationTexture.value = null;
        this._texturesInfo.elevation.texture = null;
        MaterialUtils.setDefine(this, 'ELEVATION_LAYER', false);
    }

    public setElevationTexture(
        layer: ElevationLayer,
        { texture, pitch }: { texture: Texture; pitch: OffsetScale },
    ): void {
        this._elevationLayer = layer;

        MaterialUtils.setDefine(this, 'ELEVATION_LAYER', true);

        this.uniforms.elevationTexture.value = texture;
        this._texturesInfo.elevation.texture = texture;
        this._texturesInfo.elevation.offsetScale.copy(pitch);

        const uniform = this.uniforms.elevationLayer.value;
        uniform.offsetScale = pitch;
        uniform.textureSize = new Vector2(texture.image.width, texture.image.height);
        uniform.color = new Vector4(1, 1, 1, 1);
        uniform.brightnessContrastSaturation = new Vector3(1, 1, 1);
        uniform.elevationRange = new Vector2();

        this.updateColorMaps();
    }

    private rebuildAtlasInfo(): void {
        const colorLayers = this._colorLayers;

        // rebuild color textures atlas
        // We use a margin to prevent atlas bleeding.
        const margin = 1.1;
        const { width, height } = this._textureSize;

        const { atlas, maxX, maxY } = AtlasBuilder.pack(
            Capabilities.getMaxTextureSize(),
            colorLayers.map(l => ({
                id: l.id,
                size: new Vector2(
                    Math.round(width * l.resolutionFactor * margin),
                    Math.round(height * l.resolutionFactor * margin),
                ),
            })),
            this._atlasInfo.atlas,
        );
        this._atlasInfo.atlas = atlas;
        this._atlasInfo.maxX = Math.max(this._atlasInfo.maxX, maxX);
        this._atlasInfo.maxY = Math.max(this._atlasInfo.maxY, maxY);
    }

    public pushColorLayer(newLayer: ColorLayer): void {
        if (this._colorLayers.includes(newLayer)) {
            return;
        }
        this._colorLayers.push(newLayer);

        const info = new TextureInfo(newLayer);

        if (newLayer.type === 'MaskLayer') {
            MaterialUtils.setDefine(this, 'ENABLE_LAYER_MASKS', true);
        }

        this.rebuildAtlasInfo();

        // Optional feature: limit color layer display within an elevation range
        if (newLayer.elevationRange != null) {
            MaterialUtils.setDefine(this, 'ENABLE_ELEVATION_RANGE', true);
            const { min, max } = newLayer.elevationRange;
            info.elevationRange = new Vector2(min, max);
        }

        this._texturesInfo.color.infos.push(info);

        this.updateColorLayerCount();

        this.updateColorMaps();

        this.needsUpdate = true;
    }

    private getVisibleColorLayerCount(): number {
        let result = 0;
        for (let i = 0; i < this._colorLayers.length; i++) {
            const layer = this._colorLayers[i];
            if (layer.visible) {
                result++;
            }
        }
        return result;
    }

    public reorderLayers(): void {
        this._needsSorting = true;
    }

    private sortLayersIfNecessary(): void {
        const idx = this._getIndexFn;
        if (this._needsSorting) {
            this._colorLayers.sort((a, b) => idx(a) - idx(b));
            this._texturesInfo.color.infos.sort((a, b) => idx(a.layer) - idx(b.layer));
            this._needsSorting = false;
        }
    }

    public removeColorLayer(layer: ColorLayer): void {
        const index = this.indexOfColorLayer(layer);
        if (index === -1) {
            return;
        }
        // NOTE: we cannot dispose the texture here, because it might be cached for later.
        this._texturesInfo.color.infos.splice(index, 1);
        this._colorLayers.splice(index, 1);

        this.updateColorMaps();
        this.rebuildAtlasInfo();

        this.updateColorLayerCount();
    }

    /**
     * Sets the colormap atlas.
     *
     * @param atlas - The atlas.
     */
    public setColorMapAtlas(atlas: ColorMapAtlas | null): void {
        this._colorMapAtlas = atlas;
    }

    private updateColorMaps(): void {
        this.sortLayersIfNecessary();

        const atlas = this._colorMapAtlas;

        const elevationColorMap = this._elevationLayer?.colorMap;

        const elevationUniform = this.uniforms.elevationColorMap;
        if (elevationColorMap?.active === true) {
            elevationUniform.value.mode = elevationColorMap?.mode ?? COLORMAP_DISABLED;
            elevationUniform.value.min = elevationColorMap?.min ?? 0;
            elevationUniform.value.max = elevationColorMap?.max ?? 0;
            elevationUniform.value.offset = atlas?.getOffset(elevationColorMap) ?? 0;
        } else {
            elevationUniform.value.mode = COLORMAP_DISABLED;
            elevationUniform.value.min = 0;
            elevationUniform.value.max = 0;
        }

        const colorLayers = this._texturesInfo.color.infos;
        const uniforms: ColorMapUniform[] = [];

        for (let i = 0; i < colorLayers.length; i++) {
            const texInfo = colorLayers[i];
            if (!texInfo.layer.visible) {
                continue;
            }

            const colorMap = texInfo.layer.colorMap;

            const uniform: ColorMapUniform = {
                mode: colorMap?.active === true ? colorMap.mode : COLORMAP_DISABLED,
                min: colorMap?.min ?? 0,
                max: colorMap?.max ?? 0,
                offset: colorMap ? (atlas?.getOffset(colorMap) ?? 0) : 0,
            };

            uniforms.push(uniform);
        }

        this.uniforms.layersColorMaps = new Uniform(uniforms);

        if (atlas?.texture) {
            const luts = atlas.texture ?? null;
            this.uniforms.colorMapAtlas.value = luts;
        }
    }

    private updateGraticuleUniforms(opts: MaterialOptions): void {
        const graticule = opts.graticule;
        const enabled = graticule.enabled ?? false;
        MaterialUtils.setDefine(this, 'ENABLE_GRATICULE', enabled);
        if (enabled) {
            const uniform = this.uniforms.graticule.value;
            uniform.thickness = graticule.thickness;
            uniform.position.set(
                graticule.xOffset,
                graticule.yOffset,
                graticule.xStep,
                graticule.yStep,
            );
            const rgb = getColor(graticule.color);
            uniform.color.set(rgb.r, rgb.g, rgb.b, graticule.opacity ?? 0);
        }
    }

    private updateContourLineUniforms(opts: MaterialOptions): void {
        const contourLines = opts.contourLines;

        if (contourLines.enabled) {
            const c = getColor(contourLines.color);
            const a = contourLines.opacity;

            this.uniforms.contourLines.value = {
                thickness: contourLines.thickness ?? 1,
                primaryInterval: contourLines.interval ?? 100,
                secondaryInterval: contourLines.secondaryInterval ?? 0,
                color: new Vector4(c.r, c.g, c.b, a),
            };
        }

        MaterialUtils.setDefine(this, 'ENABLE_CONTOUR_LINES', contourLines.enabled);
    }

    private updateColorUniforms(opts: MaterialOptions): void {
        const a = opts.backgroundOpacity;
        const c = opts.backgroundColor;
        const vec4 = new Vector4(c.r, c.g, c.b, a);
        this.uniforms.backgroundColor.value.copy(vec4);

        const colorimetry = opts.colorimetry;
        this.uniforms.brightnessContrastSaturation.value.set(
            colorimetry.brightness,
            colorimetry.contrast,
            colorimetry.saturation,
        );
    }

    private updateHillshadingUniforms(opts: MaterialOptions): void {
        const params = opts.lighting;

        MaterialUtils.setDefine(this, 'APPLY_SHADING_ON_COLORLAYERS', !params.elevationLayersOnly);

        const uniform = this.uniforms.hillshading.value;

        if (params.mode === MapLightingMode.Hillshade) {
            uniform.zenith = params.hillshadeZenith ?? DEFAULT_ZENITH;
            uniform.azimuth = params.hillshadeAzimuth ?? DEFAULT_AZIMUTH;
            uniform.intensity = params.hillshadeIntensity ?? 1;
        }

        uniform.mode = mapLightingMode(params);
        uniform.zFactor = params.zFactor ?? 1;
    }

    public update(opts?: MaterialOptions): boolean {
        if (opts) {
            this._options = opts;

            this.depthTest = opts.depthTest;

            if (this._colorMapAtlas) {
                this.updateColorMaps();
            }

            this.updateColorUniforms(opts);
            this.updateGraticuleUniforms(opts);
            this.updateContourLineUniforms(opts);
            this.updateHillshadingUniforms(opts);

            if (opts.elevationRange) {
                const { min, max } = opts.elevationRange;
                this.uniforms.elevationRange.value.set(min, max);
            }

            MaterialUtils.setDefine(this, 'ELEVATION_LAYER', this._elevationLayer?.visible);
            MaterialUtils.setDefine(this, 'ENABLE_OUTLINES', opts.showTileOutlines);
            if (opts.showTileOutlines) {
                this.uniforms.tileOutlineColor.value = getColor(opts.tileOutlineColor);
            }
            MaterialUtils.setDefine(this, 'DISCARD_NODATA_ELEVATION', opts.discardNoData);

            MaterialUtils.setDefine(this, 'TERRAIN_DEFORMATION', opts.terrain.enabled);
            MaterialUtils.setDefine(this, 'STITCHING', opts.terrain.stitching);

            const newSide = opts.side;
            if (this.side !== newSide) {
                this.side = newSide;
                this.needsUpdate = true;
            }
        }

        if (this._colorLayers.length === 0) {
            return true;
        }

        return this.rebuildAtlasIfNecessary();
    }

    private updateColorLayerCount(): void {
        // If we have fewer textures than allowed by WebGL max texture units,
        // then we can directly use those textures in the shader.
        // Otherwise we have to reduce the number of color textures by aggregating
        // them in a texture atlas. Note that doing so will have a performance cost,
        // both increasing memory consumption and GPU time, since each color texture
        // must rendered into the atlas.
        const { totalTextureUnits, visibleColorLayers } = this.countIndividualTextures();

        const shouldUseAtlas =
            this._forceTextureAtlas || totalTextureUnits > this._maxTextureImageUnits;
        MaterialUtils.setDefine(this, 'USE_ATLAS_TEXTURE', shouldUseAtlas);

        // If the number of visible layers has changed, we need to repaint the
        // atlas because it only shows visible layers.
        if (MaterialUtils.setDefineValue(this, 'VISIBLE_COLOR_LAYER_COUNT', visibleColorLayers)) {
            this._mustUpdateUniforms = true;
            this._needsAtlasRepaint = true;
            this.needsUpdate = true;
        }
    }

    public override customProgramCacheKey(): string {
        return (this.defines.VISIBLE_COLOR_LAYER_COUNT ?? 0).toString();
    }

    public createComposer(): WebGLComposer {
        const newComposer = new WebGLComposer({
            extent: new Rect(0, this._atlasInfo.maxX, 0, this._atlasInfo.maxY),
            width: this._atlasInfo.maxX,
            height: this._atlasInfo.maxY,
            reuseTexture: true,
            webGLRenderer: this._renderer,
            pixelFormat: RGBAFormat,
            textureDataType: this._composerDataType,
        });
        return newComposer;
    }

    private get composerWidth(): number {
        return this._composer?.width ?? 0;
    }

    private get composerHeight(): number {
        return this._composer?.height ?? 0;
    }

    public rebuildAtlasIfNecessary(): boolean {
        if (
            this._composer == null ||
            this._atlasInfo.maxX > this.composerWidth ||
            this._atlasInfo.maxY > this.composerHeight ||
            this._composer.dataType !== this._composerDataType
        ) {
            const newComposer = this.createComposer();

            let newTexture: Texture | null = null;

            const currentTexture = this._texturesInfo.color.atlasTexture;

            if (this._composer && currentTexture && this.composerWidth > 0) {
                // repaint the old canvas into the new one.
                newComposer.draw(
                    currentTexture,
                    new Rect(0, this.composerWidth, 0, this.composerHeight),
                );
                newTexture = newComposer.render();
            }

            this._composer?.dispose();
            currentTexture?.dispose();
            this._composer = newComposer;
            const atlases = nonNull(this._atlasInfo.atlas);

            for (let i = 0; i < this._colorLayers.length; i++) {
                const layer = this._colorLayers[i];
                const atlas = atlases[layer.id];
                const pitch = this._texturesInfo.color.infos[i].originalOffsetScale;
                const texture = this._texturesInfo.color.infos[i].texture;

                // compute offset / scale
                const w = texture?.image?.width ?? EMPTY_IMAGE_SIZE;
                const h = texture?.image?.height ?? EMPTY_IMAGE_SIZE;
                const xRatio = w / this.composerWidth;
                const yRatio = h / this.composerHeight;
                this._texturesInfo.color.infos[i].offsetScale = new OffsetScale(
                    atlas.x / this.composerWidth + pitch.x * xRatio,
                    (atlas.y + nonNull(atlas.offset)) / this.composerHeight + pitch.y * yRatio,
                    pitch.z * xRatio,
                    pitch.w * yRatio,
                );
            }

            this.rebuildAtlasTexture(newTexture);
        }
        return this.composerWidth > 0;
    }

    private rebuildAtlasTexture(newTexture: Texture | null): void {
        if (newTexture) {
            newTexture.name = 'LayeredMaterial - Atlas';
        }
        this._texturesInfo.color.atlasTexture?.dispose();
        this._texturesInfo.color.atlasTexture = newTexture;
        this.uniforms.atlasTexture.value = this._texturesInfo.color.atlasTexture;
    }

    public changeState(state: RenderingState): void {
        if (this.uniforms.renderingState.value === state) {
            return;
        }

        this.uniforms.renderingState.value = state;
        this.updateOpacityParameters(this.opacity);
        this.updateBlendingMode();

        this.needsUpdate = true;
    }

    private updateBlendingMode(): void {
        const state = this.uniforms.renderingState.value;
        if (state === RenderingState.FINAL) {
            const background = this._options?.backgroundOpacity ?? 1;
            this.transparent = this.opacity < 1 || background < 1;
            this.needsUpdate = true;
            this.blending = NormalBlending;
        } else {
            // We cannot use alpha blending with custom rendering states because the alpha component
            // of the fragment in those modes has nothing to do with transparency at all.
            this.blending = NoBlending;
            this.transparent = false;
            this.needsUpdate = true;
        }
    }

    public hasColorLayer(layer: ColorLayer): boolean {
        return this.indexOfColorLayer(layer) !== -1;
    }

    public hasElevationLayer(layer: ElevationLayer): boolean {
        return this._elevationLayer !== layer;
    }

    public indexOfColorLayer(layer: ColorLayer): number {
        return this._colorLayers.indexOf(layer);
    }

    private updateOpacityParameters(opacity: number): void {
        this.uniforms.opacity.value = opacity;
        this.updateBlendingMode();
    }

    public setLayerOpacity(layer: ColorLayer, opacity: number): void {
        const index = this.indexOfColorLayer(layer);
        this._texturesInfo.color.infos[index].opacity = opacity;
        this._mustUpdateUniforms = true;
    }

    public setLayerVisibility(layer: ColorLayer, visible: boolean): void {
        const index = this.indexOfColorLayer(layer);
        this._texturesInfo.color.infos[index].visible = visible;
        this._mustUpdateUniforms = true;
        this.needsUpdate = true;
        this.reorderLayers();
        this.updateColorLayerCount();
    }

    public setLayerElevationRange(layer: ColorLayer, range: ElevationRange | null): void {
        if (range != null) {
            MaterialUtils.setDefine(this, 'ENABLE_ELEVATION_RANGE', true);
        }
        const index = this.indexOfColorLayer(layer);
        const value = range ? new Vector2(range.min, range.max) : DISABLED_ELEVATION_RANGE;
        this._texturesInfo.color.infos[index].elevationRange = value;
        this._mustUpdateUniforms = true;
    }

    public setColorimetry(
        layer: ColorLayer,
        brightness: number,
        contrast: number,
        saturation: number,
    ): void {
        const index = this.indexOfColorLayer(layer);
        this._texturesInfo.color.infos[index].brightnessContrastSaturation.set(
            brightness,
            contrast,
            saturation,
        );
        this._mustUpdateUniforms = true;
    }

    public getElevationTexture(): Texture | null {
        return this._texturesInfo.elevation.texture;
    }

    public getElevationOffsetScale(): OffsetScale {
        return this._texturesInfo.elevation.offsetScale;
    }

    /** @internal */
    public getElevationLayer(): ElevationLayer | null {
        return this._elevationLayer;
    }

    /**
     * Gets the number of layers on this material.
     *
     * @returns The number of layers present on this material.
     */
    public getLayerCount(): number {
        return (this._elevationLayer ? 1 : 0) + this._colorLayers.length;
    }

    public setUuid(uuid: number): void {
        this.uniforms.uuid.value = uuid;
    }
}

export default LayeredMaterial;
