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

import { Box3, BufferAttribute, BufferGeometry, Vector2, Vector3 } from 'three';

import type Ellipsoid from '../../core/geographic/Ellipsoid';
import type Extent from '../../core/geographic/Extent';
import type HeightMap from '../../core/HeightMap';
import type MemoryUsage from '../../core/MemoryUsage';
import type { GetMemoryUsageContext } from '../../core/MemoryUsage';
import type { VectorArray } from '../../core/VectorArray';
import type TileGeometry from './TileGeometry';

import { getGeometryMemoryUsage } from '../../core/MemoryUsage';
import { getGridBuffers } from './GridBuilder';

const tmpVec2 = new Vector2();
const tmpVec3 = new Vector3();
const tmpNormal = new Vector3();

const tmpNW = new Vector3();
const tmpNE = new Vector3();
const tmpSW = new Vector3();
const tmpSE = new Vector3();

enum Usage {
    Rendering,
    Raycasting,
}

function copySkirtValues(array: VectorArray, segments: number): void {
    const rowSize = segments + 1;
    const length = rowSize * rowSize;
    const end = length;

    const UL = 0;
    const UR = rowSize;
    const LL = end - rowSize;
    const LR = end;

    array.copyItem(UL, end + 0);
    array.copyItem(UR, end + 1);
    array.copyItem(LL, end + 2);
    array.copyItem(LR, end + 3);
}

export class EllipsoidTileGeometry extends BufferGeometry implements MemoryUsage, TileGeometry {
    public readonly isMemoryUsage = true as const;
    private readonly _extent: Extent;
    private readonly _origin: Vector3;
    private readonly _ellipsoid: Ellipsoid;
    private readonly _raycastGeometry: BufferGeometry;

    private _segments = 32;
    private _heightMap: HeightMap | null = null;
    private _skirtDepth: number | null = null;

    public get vertexCount(): number {
        return this.getAttribute('position').count;
    }

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

    public set segments(v: number) {
        if (this._segments !== v) {
            this._segments = v;
            this.buildBuffers(this, Usage.Rendering);
            this.buildBuffers(this._raycastGeometry, Usage.Raycasting);
        }
    }

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

    public get raycastGeometry(): BufferGeometry {
        return this._raycastGeometry;
    }

    public constructor(params: {
        extent: Extent;
        segments: number;
        ellipsoid: Ellipsoid;
        skirtDepth: number | null;
    }) {
        super();

        this._segments = params.segments;
        this._extent = params.extent;
        this._skirtDepth = params.skirtDepth;

        this._ellipsoid = params.ellipsoid;

        this._origin = this._ellipsoid.toCartesian(this._extent.maxY, this._extent.minX, 0);

        if (!this._extent.crs.isEpsg(4326)) {
            throw new Error(`invalid CRS. Expected EPSG:4326, got: ${this._extent.crs.id}`);
        }

        this._raycastGeometry = new BufferGeometry();

        this.buildBuffers(this, Usage.Rendering);
        this.buildBuffers(this._raycastGeometry, Usage.Raycasting);
    }

    public resetHeights(): void {
        this.buildBuffers(this.raycastGeometry, Usage.Raycasting);
    }

    public applyHeightMap(heightMap: HeightMap): { min: number; max: number } {
        this._heightMap = heightMap;
        return this.buildBuffers(this.raycastGeometry, Usage.Raycasting);
    }

    public getMemoryUsage(context: GetMemoryUsageContext): void {
        getGeometryMemoryUsage(context, this);
        getGeometryMemoryUsage(context, this.raycastGeometry);
    }

    private buildBuffers(geometry: BufferGeometry, usage: Usage): { min: number; max: number } {
        this.dispose();

        const rowVertices = this._segments + 1;

        const dims = this._extent.dimensions(tmpVec2);
        const width = dims.width;
        const height = dims.height;
        const west = this._extent.minX;
        const north = this._extent.maxY;
        const south = this._extent.minY;
        const east = this._extent.maxX;

        // Positions are relative to the origin of the tile
        const origin = this._origin;

        // A shortcut to get ready to use buffers
        const buffers = getGridBuffers(this.segments, this._skirtDepth != null);

        const heightMap = this._heightMap;

        /**
         * Returns the elevation by sampling the heightmap at the (u, v) coordinate.
         * Note: the sampling does not perform any interpolation.
         */
        function getElevation(u: number, v: number): number {
            if (!heightMap) {
                return 0;
            }

            return heightMap.getValue(u, v, true) ?? 0;
        }

        let min = +Infinity;
        let max = -Infinity;

        const boundingBox = new Box3().makeEmpty();

        // Those buffers need to be cloned because they are unique per-tile
        const positionBuffer = buffers.positionBuffer.clone();
        const normalBuffer = buffers.normalBuffer.clone();

        // But these one can be reused as they are never modified
        const uvBuffer = buffers.uvBuffer;
        const indexBuffer = buffers.indexBuffer;

        for (let j = 0; j < rowVertices; j++) {
            for (let i = 0; i < rowVertices; i++) {
                const idx = j * rowVertices + i;

                const u = i / this.segments;
                const v = j / this.segments;

                const lon = west + u * width;
                const lat = north - v * height;

                const altitude = usage === Usage.Raycasting ? getElevation(u, 1 - v) : 0;

                min = Math.min(min, altitude);
                max = Math.max(max, altitude);

                const cartesian = this._ellipsoid.toCartesian(lat, lon, altitude, tmpVec3);
                const normal = this._ellipsoid.getNormalFromCartesian(cartesian, tmpNormal);

                const pos = cartesian.sub(origin);

                // Note that the bounding box ignores the skirts, which are purely
                // graphical and should not count towards the actual volume of the tile.
                boundingBox.expandByPoint(pos);

                positionBuffer.set(idx, pos.x, pos.y, pos.z);
                normalBuffer.set(idx, normal.x, normal.y, normal.z);
            }
        }

        if (this._skirtDepth != null) {
            const skirtDepth = this._skirtDepth;
            const skirtStart = rowVertices * rowVertices;

            const nw = this._ellipsoid.toCartesian(north, west, skirtDepth, tmpNW).sub(origin);
            const ne = this._ellipsoid.toCartesian(north, east, skirtDepth, tmpNE).sub(origin);
            const sw = this._ellipsoid.toCartesian(south, west, skirtDepth, tmpSW).sub(origin);
            const se = this._ellipsoid.toCartesian(south, east, skirtDepth, tmpSE).sub(origin);

            positionBuffer.set(skirtStart + 0, nw.x, nw.y, nw.z);
            positionBuffer.set(skirtStart + 1, ne.x, ne.y, ne.z);
            positionBuffer.set(skirtStart + 2, sw.x, sw.y, sw.z);
            positionBuffer.set(skirtStart + 3, se.x, se.y, se.z);

            // Skirt normals are the same as their non-skirt counterpart
            copySkirtValues(normalBuffer, this.segments);
        }

        // Per-tile buffers
        geometry.setAttribute('position', new BufferAttribute(positionBuffer.array, 3));
        geometry.setAttribute('normal', new BufferAttribute(normalBuffer.array, 3));

        // Shared buffers
        geometry.setAttribute('uv', new BufferAttribute(uvBuffer.array, 2));
        geometry.setIndex(new BufferAttribute(indexBuffer, 1));

        this.boundingBox = boundingBox;

        return { min, max };
    }
}

export default EllipsoidTileGeometry;
