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

import type { Binary } from 'copc';
import type { BufferAttribute } from 'three';

import { Las } from 'copc';
import {
    Box3,
    Float32BufferAttribute,
    Int16BufferAttribute,
    Int32BufferAttribute,
    Int8BufferAttribute,
    IntType,
    Uint16BufferAttribute,
    Uint32BufferAttribute,
    Uint8BufferAttribute,
    Uint8ClampedBufferAttribute,
    Vector3,
} from 'three';

import type * as octree from '../core/Octree';
import type {
    GetNodeDataOptions,
    PointCloudAttribute,
    PointCloudMetadata,
    PointCloudNode,
    PointCloudNodeData,
} from './PointCloudSource';
import type { LazPointCloudAttribute } from './potree/attributes';
import type { ParseResult } from './potree/bin';
import type { Metadata } from './potree/Metadata';

import { GlobalCache } from '../core/Cache';
import CoordinateSystem from '../core/geographic/CoordinateSystem';
import OperationCounter from '../core/OperationCounter';
import Fetcher from '../utils/Fetcher';
import { defined, nonNull } from '../utils/tsutils';
import { getLazPerf } from './las/config';
import LASWorkerPool from './las/LASWorkerPool';
import { readColor, readPosition, readScalarAttribute } from './las/readers';
import { createLasView } from './las/worker';
import { PointCloudSourceBase } from './PointCloudSource';
import {
    EXPOSED_ATTRIBUTES,
    processAttributes,
    processLazAttributes,
    type PotreePointCloudAttribute,
} from './potree/attributes';
import { readBinFile } from './potree/bin';
import { toBox3 } from './potree/BoundingBox';
import PotreeWorkerPool from './potree/PotreeWorkerPool';

interface NodeInternalData extends PointCloudNode {
    childrenBitField: number;
    baseUrl: string;
}

type PotreeNode = octree.Octree<NodeInternalData>;

export interface PotreeSourceOptions {
    /**
     * The URL to the dataset.
     */
    url: string;
    /**
     * Enable web workers to perform CPU intensive tasks.
     * @defaultValue true
     */
    enableWorkers?: boolean;
}

// Create an A(xis)A(ligned)B(ounding)B(ox) for the child `childIndex` of one aabb.
// (PotreeConverter protocol builds implicit octree hierarchy by applying the same
// subdivision algo recursively)
function createChildAABB(aabb: Box3, childIndex: number): Box3 {
    // Code taken from potree
    let { min } = aabb;
    let { max } = aabb;
    const dHalfLength = new Vector3().copy(max).sub(min).multiplyScalar(0.5);
    const xHalfLength = new Vector3(dHalfLength.x, 0, 0);
    const yHalfLength = new Vector3(0, dHalfLength.y, 0);
    const zHalfLength = new Vector3(0, 0, dHalfLength.z);

    const cmin = min;
    const cmax = new Vector3().add(min).add(dHalfLength);

    if (childIndex === 1) {
        min = new Vector3().copy(cmin).add(zHalfLength);
        max = new Vector3().copy(cmax).add(zHalfLength);
    } else if (childIndex === 3) {
        min = new Vector3().copy(cmin).add(zHalfLength).add(yHalfLength);
        max = new Vector3().copy(cmax).add(zHalfLength).add(yHalfLength);
    } else if (childIndex === 0) {
        min = cmin;
        max = cmax;
    } else if (childIndex === 2) {
        min = new Vector3().copy(cmin).add(yHalfLength);
        max = new Vector3().copy(cmax).add(yHalfLength);
    } else if (childIndex === 5) {
        min = new Vector3().copy(cmin).add(zHalfLength).add(xHalfLength);
        max = new Vector3().copy(cmax).add(zHalfLength).add(xHalfLength);
    } else if (childIndex === 7) {
        min = new Vector3().copy(cmin).add(dHalfLength);
        max = new Vector3().copy(cmax).add(dHalfLength);
    } else if (childIndex === 4) {
        min = new Vector3().copy(cmin).add(xHalfLength);
        max = new Vector3().copy(cmax).add(xHalfLength);
    } else if (childIndex === 6) {
        min = new Vector3().copy(cmin).add(xHalfLength).add(yHalfLength);
        max = new Vector3().copy(cmax).add(xHalfLength).add(yHalfLength);
    }

    return new Box3(min, max);
}

function createBufferAttribute(buf: ArrayBuffer, attribute: PointCloudAttribute): BufferAttribute {
    if (attribute.interpretation === 'color') {
        return new Uint8ClampedBufferAttribute(new Uint8ClampedArray(buf), 3, true);
    }

    let normalized = false;
    if ('normalized' in attribute) {
        normalized = attribute.normalized as boolean;
    }

    let result: BufferAttribute;

    switch (attribute.size) {
        case 1:
            if (attribute.type === 'signed') {
                result = new Int8BufferAttribute(
                    new Int8Array(buf),
                    attribute.dimension,
                    normalized,
                );
            } else {
                result = new Uint8BufferAttribute(
                    new Uint8Array(buf),
                    attribute.dimension,
                    normalized,
                );
            }
            break;
        case 2:
            if (attribute.type === 'signed') {
                result = new Int16BufferAttribute(
                    new Int16Array(buf),
                    attribute.dimension,
                    normalized,
                );
            } else {
                result = new Uint16BufferAttribute(
                    new Uint16Array(buf),
                    attribute.dimension,
                    normalized,
                );
            }
            break;
        case 4:
            if (attribute.type === 'signed') {
                result = new Int32BufferAttribute(
                    new Int32Array(buf),
                    attribute.dimension,
                    normalized,
                );
            } else if (attribute.type === 'unsigned') {
                result = new Uint32BufferAttribute(
                    new Uint32Array(buf),
                    attribute.dimension,
                    normalized,
                );
            } else {
                result = new Float32BufferAttribute(
                    new Float32Array(buf),
                    attribute.dimension,
                    normalized,
                );
            }
            break;
    }

    if (attribute.type !== 'float') {
        result.gpuType = IntType;
    }

    return result;
}

/**
 * Parse a .hrc file and returns the root node of the hierarchy.
 */
async function parseIndexFile(
    sourceId: string,
    metadata: Metadata,
    node: Partial<PotreeNode>,
): Promise<PotreeNode> {
    const url = `${node.baseUrl}/r${node.id}.hrc`;
    const buf = await Fetcher.arrayBuffer(url);

    const dataView = new DataView(buf);
    const stack: PotreeNode[] = [];
    let offset = 0;

    const id = nonNull(node.id);

    node.childrenBitField = dataView.getUint8(0);
    offset += 1;
    node.pointCount = dataView.getUint32(1, true);
    offset += 4;

    node.children = undefined;

    stack.push(node as PotreeNode);

    while (stack.length && offset < buf.byteLength) {
        const snode = nonNull(stack.shift());
        // look up 8 children
        for (let i = 0; i < 8; i++) {
            // does snode have a #i child ?
            if (snode.childrenBitField & (1 << i) && offset + 5 <= buf.byteLength) {
                const c = dataView.getUint8(offset);
                offset += 1;
                let n = dataView.getUint32(offset, true);
                offset += 4;

                if (n === 0) {
                    n = node.pointCount;
                }

                const childname = snode.id + i;
                const volume: Box3 = createChildAABB(snode.volume, i);

                let url_1 = nonNull(node.baseUrl);

                if (childname.length % metadata.hierarchyStepSize === 0) {
                    const myname = childname.substring(id.length);
                    url_1 = `${node.baseUrl}/${myname}`;
                }

                const depth = snode.depth + 1;

                const item: PotreeNode = {
                    pointCount: n,
                    childrenBitField: c,
                    children: undefined,
                    id: childname,
                    sourceId,
                    baseUrl: url_1,
                    volume,
                    geometricError: metadata.spacing / 2 ** depth,
                    depth,
                    hasData: true,
                    center: volume.getCenter(new Vector3()),
                    parent: snode,
                };
                if (snode.children == null) {
                    snode.children = [
                        undefined,
                        undefined,
                        undefined,
                        undefined,
                        undefined,
                        undefined,
                        undefined,
                        undefined,
                    ];
                }
                nonNull(snode.children)[i] = item;
                stack.push(item);
            }
        }
    }

    return node as PotreeNode;
}

/**
 * Reads Potree datasets.
 *
 * ## Supported formats
 *
 * This source currently reads legacy Potree datasets (a `cloud.js` files and multiple `.hrc` and
 * data files). Data files may either be in the BIN format or LAZ files.
 *
 * LAZ decompression is done in background threads using workers. If you wish to disable workers
 * (for a noticeable cost in performance), you can set {@link PotreeSourceOptions.enableWorkers} to
 * `false` in constructor options.
 *
 * Note: this source uses the **laz-perf** package to perform decoding of point cloud data. This
 * package uses WebAssembly. If you wish to override the path to the required .wasm file, use
 * {@link sources.las.config.setLazPerfPath | setLazPerfPath()} before using this source.
 * The default path is {@link sources.las.config.DEFAULT_LAZPERF_PATH | DEFAULT_LAZPERF_PATH}.
 */
export default class PotreeSource extends PointCloudSourceBase {
    public readonly type = 'PotreeSource' as const;
    public readonly isPotreeSource = true as const;

    private readonly _opCounter = new OperationCounter();
    private readonly _options: Required<PotreeSourceOptions>;

    /**  Available after initialization. */
    private _datasetInfo: {
        pointByteSize: number;
        metadata: Metadata;
        attributes: (LazPointCloudAttribute | PotreePointCloudAttribute)[];
        dataFilesExtension: 'bin' | 'laz';
    } | null = null;

    public get progress(): number {
        return this._opCounter.progress;
    }

    public get loading(): boolean {
        return this._opCounter.loading;
    }

    public constructor(options: PotreeSourceOptions) {
        super();

        this._opCounter.addEventListener('changed', () => this.dispatchEvent({ type: 'progress' }));

        const opts = nonNull(options, 'options is undefined');
        this._options = {
            enableWorkers: opts.enableWorkers ?? true,
            url: defined(opts, 'url'),
        };
    }

    protected async initializeOnce(): Promise<this> {
        this._opCounter.increment();

        const metadata = await Fetcher.json<Metadata>(this._options.url).finally(() =>
            this._opCounter.decrement(),
        );

        const sanitizedMetadata = {
            version: defined(metadata, 'version'),
            octreeDir: defined(metadata, 'octreeDir'),
            points: metadata.points,
            hierarchyStepSize: defined(metadata, 'hierarchyStepSize'),
            boundingBox: defined(metadata, 'boundingBox'),
            tightBoundingBox: metadata.tightBoundingBox,
            pointAttributes: defined(metadata, 'pointAttributes'),
            scale: defined(metadata, 'scale'),
            spacing: defined(metadata, 'spacing'),
            projection: metadata.projection,
        };

        let dataFilesExtension: 'bin' | 'laz';
        let pointByteSize = 0;
        let attributes: (LazPointCloudAttribute | PotreePointCloudAttribute)[] = [];

        if (metadata.pointAttributes === 'LAZ') {
            dataFilesExtension = 'laz';
            attributes = processLazAttributes(metadata.tightBoundingBox ?? metadata.boundingBox);
        } else {
            const result = processAttributes(metadata.pointAttributes);
            dataFilesExtension = 'bin';
            pointByteSize = result.pointByteSize;
            attributes = result.attributes;
        }

        this._datasetInfo = {
            metadata: sanitizedMetadata,
            pointByteSize,
            attributes,
            dataFilesExtension,
        };

        return this;
    }

    private async readLazFile(
        buffer: ArrayBuffer,
        node: PotreeNode,
        attributes: LazPointCloudAttribute[],
    ): Promise<ParseResult> {
        const compressed = new Uint8Array(buffer);
        const header = Las.Header.parse(compressed);

        let decompressed: Binary;
        if (this._options.enableWorkers === false) {
            const lp = await getLazPerf();
            decompressed = await Las.PointData.decompressFile(compressed, lp);
        } else {
            const lazPool = await LASWorkerPool.get();

            const response = await lazPool.queue('DecodeLazFile', { buffer: compressed.buffer }, [
                compressed.buffer,
            ]);

            decompressed = new Uint8Array(response);
        }

        const view = createLasView(decompressed, header);

        const position = readPosition(view, node.volume.min, 1, null);

        const attributeBuffers = attributes.map(attribute => {
            if (attribute.interpretation === 'color') {
                const colorBuffer = readColor(view, 1, true, null);
                return createBufferAttribute(colorBuffer, attribute);
            } else {
                const scalarBuffer = readScalarAttribute(view, attribute, 1, null);
                return createBufferAttribute(scalarBuffer, attribute);
            }
        });

        return {
            positionBuffer: {
                array: position.buffer,
                dimension: 3,
                normalized: false,
            },
            localBoundingBox: position.localBoundingBox,
            attributeBuffers: attributeBuffers.map(attributeBuffer => ({
                array: attributeBuffer.array,
                dimension: attributeBuffer.itemSize,
                normalized: attributeBuffer.normalized,
            })),
        };
    }

    private async readBinFileSync(
        buffer: ArrayBuffer,
        pointByteSize: number,
        positionAttribute: PotreePointCloudAttribute,
        attributes: PotreePointCloudAttribute[],
    ): Promise<ParseResult> {
        return readBinFile(buffer, pointByteSize, positionAttribute, attributes);
    }

    private async readBinFile(
        buffer: ArrayBuffer,
        pointByteSize: number,
        positionAttribute: PotreePointCloudAttribute,
        attributes: PotreePointCloudAttribute[],
    ): Promise<ParseResult> {
        if (this._options.enableWorkers === false) {
            return this.readBinFileSync(buffer, pointByteSize, positionAttribute, attributes);
        } else {
            const potreePool = await PotreeWorkerPool.get();

            return potreePool
                .queue(
                    'ReadBinFile',
                    {
                        buffer,
                        info: {
                            pointByteSize,
                            positionAttribute,
                            attributes,
                        },
                    },
                    [buffer],
                )
                .then(msg => {
                    const parseResult: ParseResult = {
                        positionBuffer: msg.position,
                        attributeBuffers: msg.attributes,
                    };

                    return parseResult;
                });
        }
    }

    private async fetchDataFile(url: string): Promise<ArrayBuffer> {
        let result: ArrayBuffer;

        const cached = GlobalCache.get(url);

        if (cached != null) {
            result = cached as ArrayBuffer;
        } else {
            result = await Fetcher.arrayBuffer(url);
            GlobalCache.set(url, result, { size: result.byteLength });
        }

        return result;
    }

    public async getNodeData(params: GetNodeDataOptions): Promise<PointCloudNodeData> {
        const { metadata, dataFilesExtension, pointByteSize, attributes } = nonNull(
            this._datasetInfo,
            'not initialized',
        );

        const node = params.node as PotreeNode;

        // Query HRC if we don't have children metadata yet.
        if (node.childrenBitField && node.children == null) {
            parseIndexFile(this.id, metadata, node);
        }

        const url = `${node.baseUrl}/r${node.id}.${dataFilesExtension}`;

        const signal = params.signal;

        this._opCounter.increment();

        let buffer = await this.fetchDataFile(url).finally(() => this._opCounter.decrement());

        if (this._options.enableWorkers) {
            // We have to make a copy because this buffer might be returned more than once by the
            // queue. However since it's going to be transferred to workers, the second time
            // we try to transfer it, it will fail as it will already be transferred.
            buffer = buffer.slice(0);
        }

        signal?.throwIfAborted();

        let result: ParseResult;
        let scale: Vector3 | undefined = undefined;

        this._opCounter.increment();

        const paramsAttributes = params.attributes ?? [];

        switch (dataFilesExtension) {
            case 'bin':
                {
                    scale = new Vector3(metadata.scale, metadata.scale, metadata.scale);
                    const potreeAttrs = attributes as PotreePointCloudAttribute[];
                    result = await this.readBinFile(
                        buffer,
                        pointByteSize,
                        nonNull(potreeAttrs.find(a => a.name === 'POSITION_CARTESIAN')),
                        paramsAttributes.map(attribute =>
                            nonNull(potreeAttrs.find(a => a.name === attribute.name)),
                        ),
                    ).finally(() => this._opCounter.decrement());
                }
                break;
            case 'laz':
                {
                    const lazAttrs = attributes as LazPointCloudAttribute[];
                    result = await this.readLazFile(
                        buffer,
                        node,
                        paramsAttributes.map(attribute =>
                            nonNull(lazAttrs.find(a => a.name === attribute.name)),
                        ),
                    ).finally(() => this._opCounter.decrement());
                }
                break;
            default:
                throw new Error('not supported data file extension: ' + dataFilesExtension);
        }

        signal?.throwIfAborted();

        const positionBuffer = new Float32BufferAttribute(result.positionBuffer.array, 3, false);
        const attributeBuffers = paramsAttributes.map((paramAttribute, index) => {
            const attributeBuffer = result.attributeBuffers[index];
            if (attributeBuffer != null) {
                return createBufferAttribute(attributeBuffer.array, paramAttribute);
            }
        });

        const localBoundingBox = new Box3().setFromBufferAttribute(positionBuffer);

        return {
            origin: node.volume.min,
            scale,
            localBoundingBox,
            position: positionBuffer,
            pointCount: positionBuffer.count,
            attributes: attributeBuffers,
        };
    }

    public async getHierarchy(): Promise<PointCloudNode> {
        this._opCounter.increment();

        const metadata = nonNull(this._datasetInfo?.metadata, 'not initialized');

        const base = this._options.url.replace('cloud.js', '');
        const baseUrl = `${base}/${metadata.octreeDir}/r`;

        const volume = toBox3(metadata.boundingBox);

        const root = await parseIndexFile(this.id, metadata, {
            sourceId: this.id,
            id: '',
            baseUrl,
            volume,
            hasData: true,
            depth: 0,
            geometricError: metadata.spacing,
            center: volume.getCenter(new Vector3()),
        }).finally(() => this._opCounter.decrement());

        return root;
    }

    public dispose(): void {
        // Nothing to do
    }

    public getMemoryUsage(): void {
        // Nothing to do
    }

    public getMetadata(): Promise<PointCloudMetadata> {
        const { metadata, attributes } = nonNull(this._datasetInfo, 'not initialized');

        const { lx, ly, lz, ux, uy, uz } = metadata.tightBoundingBox ?? metadata.boundingBox;

        const proj = metadata.projection;

        return Promise.resolve({
            volume: new Box3().setFromArray([lx, ly, lz, ux, uy, uz]),
            attributes: attributes.filter(att => EXPOSED_ATTRIBUTES.has(att.name)),
            pointCount: metadata.points,
            crs:
                proj != null && proj.length > 0
                    ? new CoordinateSystem({
                          definition: proj,
                          name: `potree:${this.id}`,
                      })
                    : CoordinateSystem.unknown,
        });
    }
}
