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

import type { Coordinate } from 'ol/coordinate';

import {
    LineString,
    type Geometry,
    type LinearRing,
    type MultiLineString,
    type MultiPoint,
    type MultiPolygon,
    type Point,
    type Polygon,
} from 'ol/geom';
import {
    BufferAttribute,
    BufferGeometry,
    EventDispatcher,
    MeshBasicMaterial,
    MeshLambertMaterial,
    SpriteMaterial,
    SRGBColorSpace,
    Vector3,
    type Material,
    type Texture,
} from 'three';
import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry.js';
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial.js';

import type SimpleGeometryMesh from './SimpleGeometryMesh';
import type { DefaultUserData } from './SimpleGeometryMesh';

import {
    getFullFillStyle,
    getFullPointStyle,
    getFullStrokeStyle,
    hashStyle,
    type BaseStyle,
    type FeatureElevation,
    type FeatureExtrusionOffset,
    type FillStyle,
    type LineMaterialGenerator,
    type PointMaterialGenerator,
    type PointStyle,
    type StrokeStyle,
    type SurfaceMaterialGenerator,
} from '../../core/FeatureTypes';
import RequestQueue from '../../core/RequestQueue';
import { Vector3Array } from '../../core/VectorArray';
import Fetcher from '../../utils/Fetcher';
import { triangulate } from '../../utils/tessellator';
import LineStringMesh from './LineStringMesh';
import MultiLineStringMesh from './MultiLineStringMesh';
import MultiPointMesh from './MultiPointMesh';
import MultiPolygonMesh from './MultiPolygonMesh';
import PointMesh from './PointMesh';
import PolygonMesh from './PolygonMesh';
import SurfaceMesh from './SurfaceMesh';

const VERT_STRIDE = 3; // 3 elements per vertex position (X, Y, Z)
const X = 0;
const Y = 1;
const Z = 2;

export interface InputMap {
    Point: Point;
    MultiPoint: MultiPoint;
    LineString: LineString;
    MultiLineString: MultiLineString;
    Polygon: Polygon;
    MultiPolygon: MultiPolygon;
}

export interface OutputMap<UserData extends DefaultUserData = DefaultUserData> {
    Point: PointMesh<UserData>;
    MultiPoint: MultiPointMesh<UserData>;
    LineString: LineStringMesh<UserData>;
    MultiLineString: MultiLineStringMesh<UserData>;
    Polygon: PolygonMesh<UserData>;
    MultiPolygon: MultiPolygonMesh<UserData>;
}

export interface BaseOptions {
    /**
     * The point of origin for relative coordinates.
     */
    origin?: Vector3;
    /**
     * Ignores the Z component of coordinates.
     */
    ignoreZ?: boolean;
}

export interface PointOptions extends BaseOptions, Partial<PointStyle> {}
export interface PolygonOptions extends BaseOptions {
    fill?: FillStyle;
    stroke?: StrokeStyle;
    extrusionOffset?: FeatureExtrusionOffset;
    elevation?: FeatureElevation;
}
export interface LineOptions extends BaseOptions, StrokeStyle {}

export interface OptionMap {
    Point: PointOptions;
    MultiPoint: PointOptions;
    LineString: LineOptions;
    MultiLineString: LineOptions;
    Polygon: PolygonOptions;
    MultiPolygon: PolygonOptions;
}

function isTexture(o: unknown): o is Texture {
    return (o as Texture)?.isTexture ?? false;
}

const ZERO = new Vector3(0, 0, 0);

/**
 * This methods prepares vertices for three.js with coordinates coming from openlayers.
 *
 * It does 2 things:
 *
 * - flatten the array while removing the last vertex of each rings
 * - builds the new hole indices taking into account vertex removals
 */
function createFloorVertices(params: {
    polygon: Polygon;
    /** The offset to apply to vertex positions */
    offset: Vector3;
    elevation?: FeatureElevation;
    ignoreZ: boolean;
}): { vertices: Vector3Array; holes: number[] } {
    const { offset, ignoreZ, elevation, polygon } = params;

    const stride = polygon.getStride();
    // Note that we are forcing the ordering of coordinates to
    // ensure that we always have clockwise coordinates. This
    // is vital to ensure a proper triangulation.
    const USE_RIGHT_HAND_RULE = true;
    const rings = polygon.getCoordinates(USE_RIGHT_HAND_RULE);

    const coordinateCount = polygon.getFlatCoordinates().length / polygon.getStride();
    // There is one less vertex since we are removing
    // the duplicated first/last coordinate for each ring.
    const vertexCount = coordinateCount - polygon.getLinearRingCount();

    // iterate on polygon and holes
    const holesIndices: number[] = [];
    let currentIndex = 0;
    const positions = new Vector3Array(new Float32Array(vertexCount * 3));
    positions.length = 0;

    for (const ring of rings) {
        // NOTE: rings coming from openlayers are auto-closing,
        // so we need to remove the last vertex of each ring here
        if (currentIndex > 0) {
            holesIndices.push(currentIndex);
        }

        const callback: (i: number) => void = i => {
            const coord = ring[i];

            const x = coord[X] - offset.x;
            const y = coord[Y] - offset.y;
            let z = 0;
            if (!ignoreZ && stride === 3) {
                z = coord[Z];
            } else if (elevation != null) {
                z = Array.isArray(elevation) ? elevation[i] : elevation;
            }
            z -= offset.z;

            positions.push(x, y, z);
        };

        for (let i = 0; i < ring.length - 1; i++) {
            currentIndex++;
            callback(i);
        }
    }
    return { vertices: positions, holes: holesIndices };
}

/**
 * Create a roof, basically a copy of the floor with faces shifted by 'pointcount' elem
 *
 * NOTE: at the moment, this method must be executed before `createWallForRings`, because we copy
 * the indices array as it is.
 *
 * @param positions - a flat array of coordinates
 * @param pointCount - the number of points to read from position, starting with the first vertex
 * @param indices - the indices to duplicate for the roof
 * @param extrusionOffset - the extrusion offset(s) to apply to the roof element.
 */
function createRoof(
    positions: Vector3Array,
    pointCount: number,
    indices: Array<number>,
    extrusionOffset: FeatureExtrusionOffset,
): void {
    const array = positions.array;

    for (let i = 0; i < pointCount; i++) {
        const zOffset = Array.isArray(extrusionOffset) ? extrusionOffset[i] : extrusionOffset;

        const x = array[i * VERT_STRIDE + X];
        const y = array[i * VERT_STRIDE + Y];
        const z = array[i * VERT_STRIDE + Z] + zOffset;

        positions.push(x, y, z);
    }

    const iLength = indices.length;

    for (let i = 0; i < iLength; i++) {
        indices.push(indices[i] + pointCount);
    }
}

/**
 * This methods creates vertex and faces for the walls
 *
 * @param positions - The array containing the positions of the vertices.
 * @param start - vertex in positions to start with
 * @param end - vertex in positions to end with
 * @param indices - The index array.
 * @param extrusionOffset - The extrusion distance.
 */
function createWallForRings(
    vertices: Vector3Array,
    start: number,
    end: number,
    indices: Array<number>,
    extrusionOffset: FeatureExtrusionOffset,
): void {
    // Each side is formed by the A, B, C, D vertices, where A is the current coordinate,
    // and B is the next coordinate (thus the segment AB is one side of the polygon).
    // C and D are the same points but with a Z offset.
    // Note that each side has its own vertices, as vertices of sides are not shared with
    // other sides (i.e duplicated) in order to have faceted normals for each side.
    let vertexOffset = 0;
    const pointCount = vertices.length;
    const positions = vertices.array;
    const isNegativeExtrusion = typeof extrusionOffset === 'number' && extrusionOffset < 0;

    // If extrusion is negative (goes downward), then all triangles
    // must be inverted so that normals keep pointing in the correct direction.
    const reverse = isNegativeExtrusion;

    const count = end - start;
    const bOffset = reverse ? -1 : +1;

    for (let i = 0; i < count; i++) {
        // Should we move along the ring in forward or reverse order ?
        const iA = reverse ? end - i - 1 : start + i + 0;
        // const iB = ccw ? end - i - 1 : start + i + 1;
        let iB = iA + bOffset;
        if (iB < start) {
            iB = end - 1;
        } else if (iB > end - 1) {
            iB = start;
        }

        const idxA = iA * VERT_STRIDE;
        const idxB = iB * VERT_STRIDE;

        const Ax = positions[idxA + X];
        const Ay = positions[idxA + Y];
        const Az = positions[idxA + Z];

        const Bx = positions[idxB + X];
        const By = positions[idxB + Y];
        const Bz = positions[idxB + Z];

        const zOffsetA = Array.isArray(extrusionOffset) ? extrusionOffset[iA] : extrusionOffset;
        const zOffsetB = Array.isArray(extrusionOffset) ? extrusionOffset[iB] : extrusionOffset;

        // +Z top
        //      A                    B
        // (Ax, Ay, zMax) ---- (Bx, By, zMax)
        //      |                    |
        //      |                    |
        // (Ax, Ay, zMin) ---- (Bx, By, zMin)
        //      C                    D
        // -Z bottom

        vertices.push(Ax, Ay, Az); // A
        vertices.push(Bx, By, Bz); // B
        vertices.push(Ax, Ay, Az + zOffsetA); // C
        vertices.push(Bx, By, Bz + zOffsetB); // D

        // The indices of the side are the following
        // [A, B, C, C, B, D] to form the two triangles.

        const A = 0;
        const B = 1;
        const C = 2;
        const D = 3;

        const idx = pointCount + vertexOffset;

        indices.push(idx + A);
        indices.push(idx + B);
        indices.push(idx + C);

        indices.push(idx + C);
        indices.push(idx + B);
        indices.push(idx + D);

        vertexOffset += 4;
    }
}

function createSurfaces(
    polygon: Polygon,
    options: PolygonOptions,
): { positions: Float32Array; indices: Uint16Array | Uint32Array } {
    // First we compute the positions of the top vertices (that make the 'floor').
    // note that in some dataset, it's the roof and user needs to extrusionOffset down.
    const { vertices, holes } = createFloorVertices({
        polygon,
        ignoreZ: options.ignoreZ ?? false,
        offset: options.origin ?? ZERO,
        elevation: options.elevation,
    });

    const forceFlat = options.extrusionOffset != null;
    const triangles = triangulate(vertices.array, holes, forceFlat);
    const pointCount = vertices.length;

    if (options.extrusionOffset != null) {
        createRoof(vertices, vertices.length, triangles, options.extrusionOffset);

        createWallForRings(vertices, 0, holes[0] || pointCount, triangles, options.extrusionOffset);

        for (let i = 0; i < holes.length; i++) {
            createWallForRings(
                vertices,
                holes[i],
                holes[i + 1] || pointCount,
                triangles,
                options.extrusionOffset,
            );
        }
    }

    const indices =
        vertices.length <= 65536 ? new Uint16Array(triangles) : new Uint32Array(triangles);

    return { positions: vertices.toFloat32Array(), indices };
}

const tempOrigin = new Vector3();

function createPositionBuffer(coordinates: Coordinate[], options: BaseOptions): Float32Array {
    const bufferSize = 3 * coordinates.length;
    const result = new Float32Array(bufferSize);

    const origin = tempOrigin;
    const ignoreZ = options.ignoreZ;

    if (options.origin) {
        origin.copy(options.origin);
    } else {
        origin.set(0, 0, 0);
    }

    for (let i = 0; i < coordinates.length; i++) {
        const p = coordinates[i];

        const i0 = i * 3;

        const x = p[0];
        const y = p[1];
        const z = ignoreZ === true ? 0 : (p[2] ?? 0);

        result[i0 + 0] = x - origin.x;
        result[i0 + 1] = y - origin.y;
        result[i0 + 2] = z - origin.z;
    }

    return result;
}

interface GeometryGeneratorEventMap {
    'texture-loaded': { texture: Texture };
}

/**
 * Generates three.js meshes from OpenLayers geometries.
 *
 * Supported geometries:
 * - Point / MultiPoint
 * - LineString / MultiLineString
 * - Polygon / MultiPolygon, 2D or 3D (extruded).
 *
 * Important note: features with the same styles will share the same material instance, to
 * avoid duplication and improve performance. This means that modifying the material will
 * affect all geometries that use it.
 */
export default class GeometryConverter<
    UserData extends DefaultUserData = DefaultUserData,
> extends EventDispatcher<GeometryGeneratorEventMap> {
    private readonly _materialCache: Map<unknown, Material> = new Map();
    private readonly _downloadQueue = new RequestQueue();
    private readonly _downloadedTextures: Map<string, Texture> = new Map();
    private readonly _shadedSurfaceMaterialGenerator: SurfaceMaterialGenerator;
    private readonly _unshadedSurfaceMaterialGenerator: SurfaceMaterialGenerator;
    private readonly _lineMaterialGenerator: LineMaterialGenerator;
    private readonly _pointMaterialGenerator: PointMaterialGenerator;
    private _disposed = false;

    public constructor(options?: {
        shadedSurfaceMaterialGenerator?: SurfaceMaterialGenerator;
        unshadedSurfaceMaterialGenerator?: SurfaceMaterialGenerator;
        lineMaterialGenerator?: LineMaterialGenerator;
        pointMaterialGenerator?: PointMaterialGenerator;
    }) {
        super();

        this._shadedSurfaceMaterialGenerator =
            options?.shadedSurfaceMaterialGenerator ?? this.getShadedSurfaceMaterial.bind(this);

        this._unshadedSurfaceMaterialGenerator =
            options?.unshadedSurfaceMaterialGenerator ?? this.getUnshadedSurfaceMaterial.bind(this);

        this._lineMaterialGenerator =
            options?.lineMaterialGenerator ?? this.getLineMaterial.bind(this);

        this._pointMaterialGenerator =
            options?.pointMaterialGenerator ?? this.getSpriteMaterial.bind(this);
    }

    /**
     * Gets whether this generator is disposed. A disposed generator can no longer be used.
     */
    public get disposed(): boolean {
        return this._disposed;
    }

    public get materialCount(): number {
        return this._materialCache.size;
    }

    // Convenience overloads
    /**
     * Converts a {@link Point}.
     * @param geometry - The `Point` to convert.
     * @param options  - The options.
     */
    public build(geometry: Point, options?: PointOptions): PointMesh<UserData>;

    /**
     * Converts a {@link MultiPoint}.
     * @param geometry - The `MultiPoint` to convert.
     * @param options  - The options.
     */
    public build(geometry: MultiPoint, options?: PointOptions): MultiPointMesh<UserData>;

    /**
     * Converts a {@link MultiPoint} or {@link Point}.
     * @param geometry - The `MultiPoint` or `Point` to convert.
     * @param options  - The options.
     */
    public build(
        geometry: Point | MultiPoint,
        options?: PointOptions,
    ): SimpleGeometryMesh<UserData>;

    /**
     * Converts a {@link Polygon}.
     * @param geometry - The `Polygon` to convert.
     * @param options  - The options.
     */
    public build(geometry: Polygon, options?: PolygonOptions): PolygonMesh<UserData>;

    /**
     * Converts a {@link MultiPolygon}.
     *
     * Note: if the `MultiPolygon` has only one polygon, then a {@link PolygonMesh} is returned instead of a {@link MultiPolygonMesh}.
     * @param geometry - The `MultiPolygon` to convert.
     * @param options  - The options.
     */
    public build(
        geometry: MultiPolygon,
        options?: PolygonOptions,
    ): PolygonMesh<UserData> | MultiPolygonMesh<UserData>;

    /**
     * Converts a {@link Polygon}.
     * @param geometry - The `Polygon` to convert.
     * @param options  - The options.
     */
    public build(
        geometry: Polygon | MultiPolygon,
        options?: PolygonOptions,
    ): SimpleGeometryMesh<UserData>;

    /**
     * Converts a {@link LineString}.
     * @param geometry - The `LineString` to convert.
     * @param options  - The options.
     */
    public build(geometry: LineString, options?: LineOptions): LineStringMesh<UserData>;

    /**
     * Converts a {@link MultiLineString}.
     *
     * Note: if the `MultiLineString` has only one polygon, then a {@link LineStringMesh} is returned instead of a {@link MultiLineStringMesh}.
     * @param geometry - The `MultiLineString` to convert.
     * @param options  - The options.
     */
    public build(
        geometry: MultiLineString,
        options?: LineOptions,
    ): MultiLineStringMesh<UserData> | LineStringMesh<UserData>;

    /**
     * Converts a {@link MultiLineString} or {@link LineString}.
     *
     * Note: if the `MultiLineString` has only one polygon, then a {@link LineStringMesh} is returned instead of a {@link MultiLineStringMesh}.
     * @param geometry - The `MultiLineString` or `LineString` to convert.
     * @param options  - The options.
     */
    public build(
        geometry: LineString | MultiLineString,
        options?: LineOptions,
    ): SimpleGeometryMesh<UserData>;

    /**
     * Create 3D objects from the input geometry and options.
     * @param geometry - The geometry to transform.
     * @param options - The options.
     * @returns The generated 3D object(s).
     */
    public build<K extends keyof OutputMap>(
        geometry: InputMap[K],
        options?: OptionMap[K],
    ): OutputMap<UserData>[K] {
        type ReturnType = OutputMap<UserData>[K];

        options = options ?? {};

        this.setDefaultOrigin(geometry, options);

        let result: ReturnType;

        switch (geometry.getType()) {
            case 'LineString':
                result = this.buildLineString(
                    geometry as LineString,
                    options as LineOptions,
                ) as ReturnType;
                break;
            case 'MultiLineString':
                result = this.buildMultiLineString(
                    geometry as MultiLineString,
                    options as LineOptions,
                ) as ReturnType;
                break;
            case 'Point':
                result = this.buildPoint(geometry as Point, options as PointOptions) as ReturnType;
                break;
            case 'MultiPoint':
                result = this.buildMultiPoint(
                    geometry as MultiPoint,
                    options as PointOptions,
                ) as ReturnType;
                break;
            case 'Polygon':
                result = this.buildPolygon(
                    geometry as Polygon,
                    options as PolygonOptions,
                ) as ReturnType;
                break;
            case 'MultiPolygon':
                result = this.buildMultiPolygon(
                    geometry as MultiPolygon,
                    options as PolygonOptions,
                ) as ReturnType;
                break;
            default:
                throw new Error('unimplemented');
        }

        this.finalize(result, options);

        return result;
    }

    public updatePolygonMesh(mesh: PolygonMesh, options: PolygonOptions): void {
        if (options.stroke && mesh.linearRings == null) {
            // If the style is added, we have to create the rings
            const rings = this.getPolygonRings(mesh.source, options);
            mesh.linearRings = rings;
        } else if (!options.stroke && mesh.linearRings != null) {
            // If the style is removed, we have to remove the rings
            mesh.linearRings = null;
        } else if (mesh.linearRings) {
            // Else, just update the existing rings with the new style
            const stroke = getFullStrokeStyle(options.stroke);
            const lineMaterial = this._lineMaterialGenerator(stroke);
            mesh.linearRings.forEach(ring =>
                ring.update({
                    material: lineMaterial,
                    opacity: stroke.opacity,
                    renderOrder: stroke.renderOrder,
                }),
            );
        }

        if (!options.fill && mesh.surface != null) {
            // If there is a surface, but no surface style, we must hide the existing surface
            mesh.surface.visible = false;
        } else if (options.fill && mesh.surface == null) {
            // If the surface does not exist, we have to create it
            const surface = this.getSurfaceMesh(mesh.source, options);
            mesh.surface = surface;
        } else if (options.fill && mesh.surface) {
            // Rebuild mesh if extrusion offset / elevation change
            if (
                mesh.surface.extrusionOffset !== options.extrusionOffset ||
                mesh.surface.elevation !== options.elevation
            ) {
                const surface = this.getSurfaceMesh(mesh.source, options);
                mesh.surface = surface;
            } else {
                const fill = getFullFillStyle(options.fill);

                const surfacematerial =
                    fill.shading === true
                        ? this._shadedSurfaceMaterialGenerator(fill)
                        : this._unshadedSurfaceMaterialGenerator(fill);

                mesh.surface.update({
                    material: surfacematerial,
                    opacity: fill.opacity,
                    renderOrder: fill.renderOrder,
                });
            }
        }
    }

    public updateMultiPolygonMesh(mesh: MultiPolygonMesh, options: PolygonOptions): void {
        mesh.traversePolygons(obj => this.updatePolygonMesh(obj, options));
    }

    public updateMultiLineStringMesh(mesh: MultiLineStringMesh, options: LineOptions): void {
        mesh.traverseLineStrings(obj => this.updateLineStringMesh(obj, options));
    }

    public updateLineStringMesh(mesh: LineStringMesh, options: LineOptions): void {
        const style = getFullStrokeStyle(options);
        const lineMaterial = this._lineMaterialGenerator(style);

        mesh.update({
            material: lineMaterial,
            opacity: style.opacity,
            renderOrder: style.renderOrder,
        });
    }

    public updatePointMesh(mesh: PointMesh, style: Partial<PointStyle>): void {
        const fullStyle = getFullPointStyle(style);
        const material = this._pointMaterialGenerator(fullStyle);
        mesh.update({
            material,
            pointSize: fullStyle.pointSize,
            opacity: fullStyle.opacity,
            renderOrder: fullStyle.renderOrder,
        });
    }

    public updateSurfaceMesh(mesh: SurfaceMesh, options: PolygonOptions): void {
        if (mesh.parent == null) {
            throw new Error('mesh has no parent polygon');
        }
        this.updatePolygonMesh(mesh.parent, options);
    }

    /**
     * Perform the last transformation on generated objects.
     * @param object - The object to finalize.
     * @param options - Options
     */
    private finalize(object: SimpleGeometryMesh, options: BaseOptions & BaseStyle): void {
        if (options.origin) {
            object.geometryOrigin = options.origin;
            object.position.copy(options.origin);
        }

        object.traverse(desc => {
            desc.updateMatrix();
        });
        object.updateMatrixWorld(true);
    }

    private getSurfaceGeometry(polygon: Polygon, options: PolygonOptions): BufferGeometry {
        const { positions, indices } = createSurfaces(polygon, options);

        const surfaceGeometry = new BufferGeometry();
        surfaceGeometry.setAttribute('position', new BufferAttribute(positions, 3));
        surfaceGeometry.setIndex(new BufferAttribute(indices, 1));

        surfaceGeometry.computeBoundingBox();
        surfaceGeometry.computeBoundingSphere();

        return surfaceGeometry;
    }

    /**
     * If origin has not be set, compute a default origin point by taking the first
     * coordinate of the geometry.
     */
    private setDefaultOrigin(geometry: Geometry, options: BaseOptions): void {
        if (options.origin != null) {
            return;
        }

        let first: Coordinate;

        switch (geometry.getType()) {
            case 'LineString':
            case 'LinearRing':
            case 'Polygon':
            case 'MultiLineString':
            case 'MultiPolygon':
                first = (
                    geometry as LineString | LinearRing | Polygon | MultiLineString | MultiPolygon
                ).getFirstCoordinate();
                break;
            default:
                // TODO What to do with other types (GeometryCollection) ?
                return;
        }

        if (first != null) {
            const x = first[0] ?? 0;
            const y = first[1] ?? 0;
            const z = first[2] ?? 0;

            options.origin = new Vector3(x, y, z);
        }
    }

    private getSurfaceMesh(polygon: Polygon, options: PolygonOptions): SurfaceMesh {
        const fill = getFullFillStyle(options.fill);

        const material =
            fill.shading === true
                ? this._shadedSurfaceMaterialGenerator(fill)
                : this._unshadedSurfaceMaterialGenerator(fill);

        const geometry = this.getSurfaceGeometry(polygon, options);

        const surface = new SurfaceMesh({ geometry, material, opacity: fill.opacity });

        // Surfaces can either be extruded (3D) or non-extruded (2D).
        if (fill.shading === true) {
            geometry.computeVertexNormals();
        }

        surface.renderOrder = fill.renderOrder;

        // Store extrusionOffset / elevation to be able to regenerate surfaces if values change
        surface.extrusionOffset = options.extrusionOffset;
        surface.elevation = options.elevation;

        return surface;
    }

    private getPolygonRings(polygon: Polygon, options: PolygonOptions): LineStringMesh[] {
        const ringCount = polygon.getLinearRingCount();
        const linearRings: LineStringMesh[] = [];
        for (let i = 0; i < ringCount; i++) {
            const inputRing = polygon.getLinearRing(i);
            if (inputRing) {
                const lineString = new LineString(inputRing.getCoordinates());
                const ring = this.buildLineString(lineString, {
                    origin: options.origin,
                    ignoreZ: options.ignoreZ,
                    ...options.stroke,
                });
                linearRings.push(ring);
            }
        }

        return linearRings;
    }

    private buildPolygon(polygon: Polygon, options: PolygonOptions): PolygonMesh {
        let surface: SurfaceMesh | undefined = undefined;
        let linearRings: LineStringMesh[] | undefined = undefined;

        if (options.fill) {
            surface = this.getSurfaceMesh(polygon, options);
        }

        // If line style is specified, we draw the linear rings of the polygon
        if (options.stroke) {
            linearRings = this.getPolygonRings(polygon, options);
        }

        const result = new PolygonMesh({
            source: polygon,
            surface,
            linearRings,
            isExtruded: options.extrusionOffset != null,
        });

        return result;
    }

    private buildMultiPolygon(
        multiPolygon: MultiPolygon,
        options: PolygonOptions,
    ): MultiPolygonMesh | PolygonMesh {
        const inputGeometries = multiPolygon.getPolygons();

        // Optimization
        if (inputGeometries.length === 1) {
            return this.buildPolygon(inputGeometries[0], options);
        }

        const polygons: PolygonMesh[] = [];
        for (const polygon of inputGeometries) {
            const p = this.buildPolygon(polygon, options);

            polygons.push(p);
        }

        const result = new MultiPolygonMesh(polygons);

        return result;
    }

    private buildPointMesh(point: Point, options: PointOptions): PointMesh {
        const style = getFullPointStyle(options);
        const material = this._pointMaterialGenerator(style);

        const coordinate = point.getCoordinates();

        const pointMesh = new PointMesh({
            material,
            opacity: style.opacity,
            pointSize: style.pointSize,
        });

        pointMesh.renderOrder = style.renderOrder;

        pointMesh.position.setX(coordinate[0] ?? 0);
        pointMesh.position.setY(coordinate[1] ?? 0);
        pointMesh.position.setZ(coordinate[2] ?? 0);

        return pointMesh;
    }

    private buildPoint(point: Point, options: PointOptions): PointMesh {
        return this.buildPointMesh(point, options);
    }

    private buildMultiPoint(multiPoint: MultiPoint, options: PointOptions): MultiPointMesh {
        return new MultiPointMesh(multiPoint.getPoints().map(p => this.buildPointMesh(p, options)));
    }

    private getShadedSurfaceMaterial(style: Required<FillStyle>): MeshLambertMaterial {
        if (style == null) {
            throw new Error('missing style');
        }

        const key = hashStyle('shaded-surface', style);

        if (this._materialCache.has(key)) {
            return this._materialCache.get(key) as MeshLambertMaterial;
        }

        const { color, opacity, depthTest } = style;

        const material = new MeshLambertMaterial({
            color,
            opacity,
            transparent: opacity < 1,
            side: style.side,
            depthTest,
            depthWrite: depthTest,
        });

        this._materialCache.set(key, material);

        return material;
    }

    private getUnshadedSurfaceMaterial(style: Required<FillStyle>): MeshBasicMaterial {
        if (style == null) {
            throw new Error('missing style');
        }

        const key = hashStyle('unshaded-surface', style);

        if (this._materialCache.has(key)) {
            return this._materialCache.get(key) as MeshBasicMaterial;
        }

        const { color, opacity, depthTest } = style;

        const material = new MeshBasicMaterial({
            color,
            opacity,
            transparent: opacity < 1,
            side: style.side,
            depthTest,
            depthWrite: depthTest,
        });

        this._materialCache.set(key, material);

        return material;
    }

    private getSpriteMaterial(style: Required<PointStyle>): SpriteMaterial {
        if (style == null) {
            throw new Error('missing style');
        }

        // TODO support point shapes
        // TODO support image placement (hotspot)
        const styleKey = hashStyle('sprite', style);

        if (this._materialCache.has(styleKey)) {
            return this._materialCache.get(styleKey) as SpriteMaterial;
        }

        const { color, opacity, sizeAttenuation, depthTest } = style;

        const result = new SpriteMaterial({
            color,
            opacity,
            transparent: true,
            sizeAttenuation,
            depthTest,
            depthWrite: depthTest,
            map:
                style.image != null
                    ? isTexture(style.image)
                        ? style.image
                        : this.getCachedTexture(style.image)
                    : null,
        });

        // Download image from URL
        if (typeof style.image === 'string' && result.map == null) {
            // Hide material until the image is loaded to avoid displaying a blank square.
            result.visible = false;

            // Download the image
            this.loadRemoteTexture(style.image)
                .then(texture => {
                    result.map = texture;
                    result.needsUpdate = true;
                    result.visible = true;
                    result.transparent = true;
                })
                .catch(console.error);
        }

        this._materialCache.set(styleKey, result);

        return result;
    }

    private getCachedTexture(url: string): Texture | null {
        const cached = this._downloadedTextures.get(url);
        if (cached) {
            return cached;
        }

        return null;
    }

    private loadRemoteTexture(url: string): Promise<Texture> {
        const cached = this._downloadedTextures.get(url);
        if (cached) {
            return Promise.resolve(cached);
        }

        return this._downloadQueue.enqueue({
            id: url,
            request: () => this.fetchTexture(url),
        });
    }

    private fetchTexture(url: string): Promise<Texture> {
        // Download the image
        return Fetcher.texture(url, { flipY: true }).then(texture => {
            texture.colorSpace = SRGBColorSpace;
            this._downloadedTextures.set(url, texture);
            texture.generateMipmaps = true;
            this.dispatchEvent({ type: 'texture-loaded', texture });
            return texture;
        });
    }

    private getLineMaterial(style: Required<StrokeStyle>): LineMaterial {
        if (style == null) {
            throw new Error('missing style');
        }

        const styleKey = hashStyle('line', style);

        if (this._materialCache.has(styleKey)) {
            return this._materialCache.get(styleKey) as LineMaterial;
        }

        const { color, lineWidth, opacity, lineWidthUnits, depthTest } = style;

        const material = new LineMaterial({
            color,
            linewidth: lineWidth, // Notice the different case
            opacity,
            transparent: opacity < 1,
            worldUnits: lineWidthUnits === 'world',
            depthTest,
            depthWrite: depthTest,
        });

        this._materialCache.set(styleKey, material);

        return material;
    }

    private getLineGeometry(coordinates: Coordinate[], options: BaseOptions): LineGeometry {
        const result = new LineGeometry();
        result.setPositions(createPositionBuffer(coordinates, options));
        result.computeBoundingBox();

        return result;
    }

    private buildLineString(
        geometry: LineString,
        options: OptionMap['LineString'],
    ): LineStringMesh {
        const fullStyle = getFullStrokeStyle(options);
        const lineStringMesh = new LineStringMesh(
            this.getLineGeometry(geometry.getCoordinates(), options),
            this._lineMaterialGenerator(fullStyle),
            fullStyle.opacity,
        );

        lineStringMesh.renderOrder = fullStyle.renderOrder;

        return lineStringMesh;
    }

    private buildMultiLineString(
        geometry: MultiLineString,
        options: OptionMap['MultiLineString'],
    ): MultiLineStringMesh | LineStringMesh {
        const lineStrings = geometry.getLineStrings();

        // Optimization
        if (lineStrings.length === 1) {
            return this.buildLineString(lineStrings[0], options);
        }

        const meshes: LineStringMesh[] = [];
        for (const line of lineStrings) {
            const lineStringMesh = this.buildLineString(line, options);

            meshes.push(lineStringMesh);
        }

        return new MultiLineStringMesh(meshes);
    }

    /**
     * Disposes this generator and all cached materials. Once disposed, this generator cannot be used anymore.
     */
    public dispose({
        disposeTextures = true,
        disposeMaterials = true,
    }: {
        /** Dispose the textures created by this generator */
        disposeTextures?: boolean;
        /** Dispose the materials created by this generator */
        disposeMaterials?: boolean;
    }): void {
        if (this._disposed) {
            return;
        }
        if (disposeTextures) {
            this._downloadedTextures.forEach(texture => texture.dispose());
        }
        if (disposeMaterials) {
            this._materialCache.forEach(material => material.dispose());
        }
        this._downloadedTextures.clear();
        this._materialCache.clear();
    }
}
