/** @internal NOTE: Internal APIs. Subject to change. Use of these APIs in production applications is not supported. */ /** */

import { SeverityLevel } from '@microsoft/applicationinsights-web';
import { telemetry } from '../Telemetry';
import { BimIfcIndex } from './BimIfcIndex';
import { MemoryBlock, type MemoryBlockOptions } from './MemoryBlock';
import { BimProductMeshDescriptor, BimVertexData, NormalType, TypedArray } from './bim-format-types';
import { BimVertexDataReader } from './bim-vertex-data-reader';
import { VertexDataReaderV2, T8gdReader } from '@twinfinity/geometry';
import { StopWatch, yieldThreadFor } from './stopwatch';

/**
 * Represents a memory block for storing geometry data. That is vertices, normals and indices.
 */
export class GeometryMemoryBlock {
    private static readonly _memoryBlockOptions: MemoryBlockOptions = {
        onAllocate: (id, byteLength) =>
            telemetry.trackEvent(
                { name: 'malloc' },
                { id, byteLength, mbLength: Math.round((byteLength * 100.0) / (1024.0 * 1024.0)) / 100.0 }
            ),
        onOutOfMemory: (id, requested, available) =>
            telemetry.trackTrace(
                { message: 'Out of memory', severityLevel: SeverityLevel.Critical },
                { id, overshoot: requested - available }
            )
    };

    private _vertexDatacache = new Map<string, { refCount: number; data: BimVertexData }>();
    private _byteSize = 0;
    private _importId = -1;
    private _v2MeshSources = new Map<string, { reader: VertexDataReaderV2; offset: number }>();

    /**
     * Gets the size of the memory block in bytes.
     * @returns The size of the memory block in bytes.
     */
    public get byteSize(): number {
        return this._byteSize;
    }

    /**
     * Imports packed vertex data into the geometry memory block.
     *
     * @param reader - The {@link BimVertexDataReader} used to read the vertex data.
     * @param meshDescriptors - The mesh descriptors containing information about the vertex data in {@link BimVertexDataReader}.
     * Mesh descriptors that are already imported are ignored (so we never store duplicated data).
     * @returns A Promise that resolves when the vertex data is imported.
     */
    public async importPackedVertexData(
        reader: BimVertexDataReader,
        meshDescriptors: BimIfcIndex['meshDescriptors']
    ): Promise<void> {
        // To avoid fragmenting the heap too much (and for performance reasons) we allocate
        // large memory blocks for vertices, normals and indices. To size them we have to first
        // look at all meshdescriptors and calculate the size required. Then we allocated
        // the blocks and finally we then allocate smaller "slices" (one per mesh descriptor)
        // ,from the memory blocks, and hand them out as BimVertexData instances.

        const event = telemetry.startTrackEvent('Loaded geometry data into buffer.');

        this._importId++;
        const opts = GeometryMemoryBlock._memoryBlockOptions;
        const vertexMemoryBlock = new MemoryBlock(`vertices_${this._importId}`, Float32Array, opts);
        const packedNormalsMemoryBlock = new MemoryBlock(`packedNormals_${this._importId}`, Uint16Array, opts);
        const uint8IndicesMemoryBlock = new MemoryBlock(`indicesUint8_${this._importId}`, Uint8Array, opts);
        const uint16IndicesMemoryBlock = new MemoryBlock(`indicesUint16_${this._importId}`, Uint16Array, opts);
        const uint32IndicesMemoryBlock = new MemoryBlock(`indicesUint32_${this._importId}`, Uint32Array, opts);
        const deduplicated = { count: 0, byte: 0, primitives: 0, indices: 0 };
        try {
            const deadline = new StopWatch(true);
            let count = 0;
            const meshDescriptorsToAllocate: BimProductMeshDescriptor[] = [];

            // Calculate the number of primitives and indices required so we can size the memory blocks
            // Ignore already imported mesh descriptors
            for (const meshDescriptor of meshDescriptors) {
                if (meshDescriptor === undefined) continue;

                // Check if we already have this mesh descriptor. If so dont import it again.
                const existing = this._vertexDatacache.get(meshDescriptor.h);
                if (existing) {
                    this.updateDeduplicationStats(deduplicated, existing.data);
                    existing.refCount++;
                    continue;
                }

                meshDescriptorsToAllocate.push(meshDescriptor);

                vertexMemoryBlock.length += meshDescriptor.p * 3; // Expand for vertices
                packedNormalsMemoryBlock.length += meshDescriptor.p; // Expand for normals
                indiceBlock(meshDescriptor).length += meshDescriptor.i; // Expand for indices

                // Calling deadline.elapsed is not free so only do it every 100 iterations
                if (count++ % 100 === 0 && deadline.elapsed > 150) {
                    await yieldThreadFor(4);
                    deadline.resetAndStart();
                }
            }

            if (meshDescriptorsToAllocate.length === 0) return;

            // We have meshdescriptors to import. Memory blocks are sized correctly
            // so now we can allocate geometry data from the memory blocks and copy
            // the packed vertex data into them.
            for (const meshDescriptor of meshDescriptorsToAllocate) {
                this._vertexDatacache.set(meshDescriptor.h, {
                    refCount: 1,
                    data: reader.deflateToRef(meshDescriptor, {
                        positions: vertexMemoryBlock.subarray(meshDescriptor.p * 3),
                        packedNormals: packedNormalsMemoryBlock.subarray(meshDescriptor.p),
                        indices: indiceBlock(meshDescriptor).subarray(meshDescriptor.i)
                    })
                });

                if (count++ % 100 === 0 && deadline.elapsed > 150) {
                    await yieldThreadFor(4);
                    deadline.resetAndStart();
                }
            }

            this._byteSize +=
                vertexMemoryBlock.byteLength +
                packedNormalsMemoryBlock.byteLength +
                uint8IndicesMemoryBlock.byteLength +
                uint16IndicesMemoryBlock.byteLength +
                uint32IndicesMemoryBlock.byteLength;
        } finally {
            event.stop({ importId: this._importId, deduplicated });
        }

        // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
        function indiceBlock(md: BimProductMeshDescriptor) {
            if (md.p < 256) return uint8IndicesMemoryBlock;
            if (md.p < 65536) return uint16IndicesMemoryBlock;
            return uint32IndicesMemoryBlock;
        }
    }

    /**
     * Imports packed vertex data from V2 (.t8gd) format into the geometry memory block.
     *
     * @param fileReader - The {@link T8gdReader} for file-level operations (hash lookup).
     * @param meshDescriptors - The mesh descriptors containing information about which meshes to load.
     * Mesh descriptors that are already imported are ignored (so we never store duplicated data).
     * @returns A Promise that resolves when the vertex data is imported.
     */
    public async importPackedVertexDataV2(
        fileReader: T8gdReader,
        meshDescriptors: BimIfcIndex['meshDescriptors']
    ): Promise<void> {
        const event = telemetry.startTrackEvent('Loaded geometry data into buffer (V2).');

        this._importId++;
        const opts = GeometryMemoryBlock._memoryBlockOptions;
        const vertexMemoryBlock = new MemoryBlock(`vertices_${this._importId}`, Float32Array, opts);
        const packedNormalsMemoryBlock = new MemoryBlock(`packedNormals_${this._importId}`, Uint16Array, opts);
        const uint8IndicesMemoryBlock = new MemoryBlock(`indicesUint8_${this._importId}`, Uint8Array, opts);
        const uint16IndicesMemoryBlock = new MemoryBlock(`indicesUint16_${this._importId}`, Uint16Array, opts);
        const uint32IndicesMemoryBlock = new MemoryBlock(`indicesUint32_${this._importId}`, Uint32Array, opts);
        const deduplicated = { count: 0, byte: 0, primitives: 0, indices: 0 };

        const v2Reader = new VertexDataReaderV2(fileReader.buffer);

        try {
            const deadline = new StopWatch(true);
            let count = 0;
            const meshesToAllocate: Array<{
                descriptor: BimProductMeshDescriptor;
                offset: number;
                vertexCount: number;
                indexCount: number;
                faceCount: number;
            }> = [];

            // First pass: determine which meshes to load and their sizes
            for (const meshDescriptor of meshDescriptors) {
                if (meshDescriptor === undefined) continue;

                // Check if we already have this mesh descriptor
                const existing = this._vertexDatacache.get(meshDescriptor.h);
                if (existing) {
                    this.updateDeduplicationStats(deduplicated, existing.data);
                    existing.refCount++;
                    continue;
                }

                // Look up mesh offset by hash
                const offset = fileReader.getMeshOffset(meshDescriptor.h);
                if (offset === undefined) {
                    continue;
                }

                // Get actual vertex and index counts from V2 mesh header
                const { vertexCount, indexCount } = v2Reader.readHeader(offset);
                const faceCount = indexCount / 3;

                meshesToAllocate.push({ descriptor: meshDescriptor, offset, vertexCount, indexCount, faceCount });

                vertexMemoryBlock.length += vertexCount * 3;
                packedNormalsMemoryBlock.length += faceCount;
                indiceBlock(vertexCount).length += indexCount;

                if (count++ % 100 === 0 && deadline.elapsed > 150) {
                    await yieldThreadFor(4);
                    deadline.resetAndStart();
                }
            }

            if (meshesToAllocate.length === 0) return;

            // Second pass: allocate and read mesh data
            for (const { descriptor, offset, vertexCount, indexCount, faceCount } of meshesToAllocate) {
                try {
                    // Update mesh descriptor counts (important for T8 format where these are initially 0)
                    (descriptor as { p: number }).p = vertexCount;
                    (descriptor as { i: number }).i = indexCount;

                    this._vertexDatacache.set(descriptor.h, {
                        refCount: 1,
                        data: {
                            ...v2Reader.deflateToRef(offset, {
                                positions: vertexMemoryBlock.subarray(vertexCount * 3),
                                packedNormals: packedNormalsMemoryBlock.subarray(faceCount),
                                indices: indiceBlock(vertexCount).subarray(indexCount)
                            }),
                            normalType: NormalType.PerFace
                        }
                    });
                    this._v2MeshSources.set(descriptor.h, { reader: v2Reader, offset });
                } catch (e) {
                    console.error(`Failed to deflate mesh ${descriptor.h}:`, e);
                }

                if (count++ % 100 === 0 && deadline.elapsed > 150) {
                    await yieldThreadFor(4);
                    deadline.resetAndStart();
                }
            }

            this._byteSize +=
                vertexMemoryBlock.byteLength +
                packedNormalsMemoryBlock.byteLength +
                uint8IndicesMemoryBlock.byteLength +
                uint16IndicesMemoryBlock.byteLength +
                uint32IndicesMemoryBlock.byteLength;
        } finally {
            event.stop({ importId: this._importId, deduplicated });
        }

        // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
        function indiceBlock(vertexCount: number) {
            if (vertexCount < 256) return uint8IndicesMemoryBlock;
            if (vertexCount < 65536) return uint16IndicesMemoryBlock;
            return uint32IndicesMemoryBlock;
        }
    }

    /**
     * Checks if the given {@link BimProductMeshDescriptor} exists in the geometry memory block.
     * @param meshDescriptor - The {@link BimProductMeshDescriptor} to check.
     * @returns `true` if the {@link BimProductMeshDescriptor} exists, `false` otherwise.
     */
    public has(meshDescriptor: BimProductMeshDescriptor): boolean {
        return this._vertexDatacache.has(meshDescriptor.h);
    }

    /**
     * Retrieves the vertex data for a given BimProductMeshDescriptor.
     *
     * @param meshDescriptor - The BimProductMeshDescriptor object.
     * @returns The BimVertexData associated with the given mesh descriptor. `undefined` if the mesh descriptor is not found.
     */
    public getVertexData(meshDescriptor: BimProductMeshDescriptor): BimVertexData | undefined {
        return this._vertexDatacache.get(meshDescriptor.h)?.data;
    }

    /**
     * Removes a cached vertex data entry by hash so it can be re-imported with better data.
     * The old data remains allocated in the parent MemoryBlock buffer but becomes unreachable
     * through the cache. This only happens during initial loading when welded geometry replaces
     * unwelded geometry for the same hash, so the leaked allocations are bounded and small
     * relative to the total geometry budget. Compacting the MemoryBlock is not worth the cost.
     */
    public removeVertexData(hash: string): boolean {
        return this._vertexDatacache.delete(hash);
    }

    /**
     * Lazily reads edge indices for a mesh from the retained V2 geometry buffer.
     * Returns undefined if the mesh has no edges or was not loaded via V2 format.
     */
    public readEdgeIndices(hash: string): TypedArray | undefined {
        const source = this._v2MeshSources.get(hash);
        if (!source) return undefined;
        return source.reader.readEdgeIndices(source.offset);
    }

    /**
     * Releases retained V2 geometry buffers used for lazy edge reads.
     * Call after all edges have been read to free the underlying ArrayBuffer memory.
     */
    public releaseV2MeshSources(): void {
        this._v2MeshSources.clear();
    }

    /**
     * Releases V2 geometry buffer references for a specific set of mesh hashes only.
     * Use this when several loader elements share one repository: each element should
     * release only the meshes it consumed so concurrent peers can still resolve their edges.
     */
    public releaseV2MeshSourcesFor(hashes: ReadonlySet<string>): void {
        for (const hash of hashes) {
            this._v2MeshSources.delete(hash);
        }
    }

    /**
     * Resets the GeometryMemoryBlock freeing all the used memory.
     * It is safe to call {@link importPackedVertexData} after calling this method.
     */
    public reset(): void {
        this._vertexDatacache.clear();
        this._v2MeshSources.clear();
    }

    private updateDeduplicationStats(
        deduplicated: { count: number; byte: number; primitives: number; indices: number },
        data: BimVertexData
    ): void {
        deduplicated.count++;
        deduplicated.byte +=
            data.indices.length * data.indices.BYTES_PER_ELEMENT +
            data.positions.length * data.positions.BYTES_PER_ELEMENT +
            data.packedNormals.length * data.packedNormals.BYTES_PER_ELEMENT;
        deduplicated.primitives += data.positions.length;
        deduplicated.indices += data.indices.length;
    }
}
