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

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

import { Las } from 'copc';
import { Header } from 'copc/lib/las';
import { Binary as BinaryUtils } from 'copc/lib/utils/binary';
import { Box3, Float32BufferAttribute, Vector3 } from 'three';

import type { GetMemoryUsageContext } from '../core/MemoryUsage';
import type { CommonOptions } from './las/CommonOptions';
import type { DimensionName } from './las/dimension';
import type {
    GetNodeDataOptions,
    PointCloudMetadata,
    PointCloudNode,
    PointCloudNodeData,
} from './PointCloudSource';

import CoordinateSystem from '../core/geographic/CoordinateSystem';
import OperationCounter from '../core/OperationCounter';
import { defer } from '../core/RequestQueue';
import Fetcher from '../utils/Fetcher';
import { nonNull } from '../utils/tsutils';
import { getLazPerf } from './las/config';
import { extractAttributes, getDimensionsToRead } from './las/dimension';
import { getPerPointFilters, type DimensionFilter } from './las/filter';
import LASWorkerPool from './las/LASWorkerPool';
import { createBufferAttribute, readColor, readPosition, readScalarAttribute } from './las/readers';
import { createLasView } from './las/worker';
import { PointCloudSourceBase } from './PointCloudSource';

export type Getter = () => Promise<Uint8Array>;

/**
 * Inject Fetcher into copc.js to perform range requests.
 */
const getter: (url: string) => Getter = url => {
    return async () => {
        const blob = await Fetcher.blob(url);

        const arrayBuffer = await blob.arrayBuffer();

        return new Uint8Array(arrayBuffer);
    };
};

async function decodeLazFileSync(data: Uint8Array): Promise<Uint8Array> {
    const lazPerf = await getLazPerf();
    return Las.PointData.decompressFile(data, lazPerf);
}

async function decodeLazFileUsingWorker(data: Uint8Array): Promise<Uint8Array> {
    const pool = await LASWorkerPool.get();

    return pool
        .queue('DecodeLazFile', { buffer: data.buffer }, [data.buffer])
        .then(res => new Uint8Array(res));
}

export interface LASSourceOptions extends CommonOptions {
    /**
     * The URL to the remote LAS file, or a function to retrieve the remote file.
     */
    url: string | Getter;
}

interface PerfOptions {
    decimate: number;
    enableWorkers: boolean;
    compressColorsToUint8: boolean;
}

/**
 * A source that reads from a LAS or LAZ file.
 *
 * **Note**: if you wish to read Cloud Optimized Point Cloud (COPC) LAZ files, use the COPCSource
 * instead.
 *
 * 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 LASSourceOptions.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
 * > [setLazPerfPath()](../functions/sources_las_config.setLazPerfPath.html) before using this source.
 * > The default path is [DEFAULT_LAZPERF_PATH](../variables/sources_las_config.DEFAULT_LAZPERF_PATH.html).
 *
 * ### Supported LAS version
 *
 * This source supports LAS 1.2 and 1.4 only.
 *
 * ### Decimation
 *
 * This source supports decimation. By passing the {@link LASSourceOptions.decimate} argument to
 * a value other than 1, every Nth point will be kept and other points will be discarded during
 * read operations.
 *
 * ### Dimensions filtering
 *
 * This source supports filtering over dimensions (also known as attributes). By providing filters
 * in the form of callback functions to apply to various dimensions, it is possible to eliminate
 * points during reads. For example, it is possible to remove unwanted classifications such as noise
 * from the output points.
 *
 * Note that dimension filtering is independent from the selected attribute. In other words, it is
 * possible to select the dimension `"Intensity"`, while filtering on dimensions `"Classification"`
 * and `"ReturnNumber"` for example.
 *
 * For example, if we wish to remove all points that have the dimension "High noise" (dimension 18
 * in the ASPRS classification list), as well as removing all points whose intensity is lower than
 * 1000:
 *
 * ```ts
 * const source = new LASSource(...);
 *
 * source.filters = [
 *  { dimension: 'Classification', filter: (val) => val !== 18 },
 *  { dimension: 'Intensity', filter: (val) => val >= 1000  },
 * ];
 * ```
 */
export default class LASSource extends PointCloudSourceBase {
    public readonly isLASSource = true as const;
    public readonly type = 'LASSource' as const;

    private readonly _getter: Getter;
    private readonly _opCounter = new OperationCounter();
    private readonly _filters: DimensionFilter[] = [];
    private readonly _options: PerfOptions = {
        decimate: 1,
        enableWorkers: true,
        compressColorsToUint8: true,
    };

    // Available after initialization
    private _header: Header | null = null;
    private _volume: Box3 | null = null;
    /** The buffer that stores the entire LAS/LAZ file (in compressed form for LAZ files). */
    private _buffer: Uint8Array | null = null;

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

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

    /**
     * Gets or sets the dimension filters.
     * @defaultValue `[]`
     */
    public get filters(): Readonly<DimensionFilter[]> {
        return this._filters;
    }

    public set filters(v: Readonly<DimensionFilter[]>) {
        this._filters.length = 0;
        this._filters.push(...v);
        this.dispatchEvent({ type: 'updated' });
    }

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

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

        this._options.compressColorsToUint8 =
            options.compressColorsTo8Bit ?? this._options.compressColorsToUint8;

        this._options.decimate = options.decimate ?? 1;
        if (this._options.decimate < 1) {
            throw new Error('decimate should be at least 1');
        }

        this._options.enableWorkers = options.enableWorkers ?? true;

        if (options.filters != null && options.filters.length > 0) {
            this._filters.push(...options.filters);
        }

        this._getter = typeof options.url === 'string' ? getter(options.url) : options.url;
    }

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

        this._buffer = await this._getter().finally(() => this._opCounter.decrement());

        this._header = Header.parse(new Uint8Array(this._buffer));

        const { min, max } = this._header;

        this._volume = new Box3().set(
            new Vector3(min[0], min[1], min[2]),
            new Vector3(max[0], max[1], max[2]),
        );

        return this;
    }

    private async getView(include?: DimensionName[]): Promise<View> {
        this._opCounter.increment();

        const data = new Uint8Array(nonNull(this._buffer));
        const header = nonNull(this._header);

        let decompressed: Binary;

        if (this._options.enableWorkers === false) {
            decompressed = await decodeLazFileSync(data).finally(() => this._opCounter.decrement());
        } else {
            decompressed = await decodeLazFileUsingWorker(data).finally(() =>
                this._opCounter.decrement(),
            );
        }

        const view = createLasView(decompressed, header, undefined, include);

        return view;
    }

    public async getMetadata(): Promise<PointCloudMetadata> {
        const header = nonNull(this._header, 'not initialized');

        const view = await this.getView();

        const result: PointCloudMetadata = {
            pointCount: header.pointCount,
            volume: nonNull(this._volume),
            attributes: extractAttributes(
                view.dimensions,
                nonNull(this._volume),
                this._options.compressColorsToUint8,
                null,
            ),
        };

        const buffer = nonNull(this._buffer, 'not initialized');

        const getBufferChunk = (begin: number, end: number): Uint8Array => {
            if (end >= buffer.byteLength) {
                throw new Error();
            }
            return new Uint8Array(buffer.slice(begin, end));
        };
        const getBufferChunkAsync = (begin: number, end: number): Promise<Uint8Array> => {
            return Promise.resolve(getBufferChunk(begin, end));
        };

        const vlrs = await Las.Vlr.walk(getBufferChunkAsync, header);
        const wktVlr = Las.Vlr.find(vlrs, 'LASF_Projection', 2112);
        // There are a few corner-case possibilities here.  Although the LAS 1.4 spec
        // says that this must be a null-terminated string, some files in the wild
        // exist with a zero content-length.  We also want to consider the case of an
        // empty string which *does* include null-termination as a missing SRS.
        if (wktVlr && wktVlr.contentLength) {
            const wktVlrBegin = wktVlr.contentOffset;
            const wktVlrEnd = wktVlrBegin + wktVlr.contentLength;

            const wkt = BinaryUtils.toCString(getBufferChunk(wktVlrBegin, wktVlrEnd));
            if (wkt !== null && wkt) {
                try {
                    result.crs = CoordinateSystem.fromWkt(wkt);
                } catch (error: unknown) {
                    console.error(`Failed to parse WKT for LAS "${this.id}": `, error);
                }
            }
        }

        return Promise.resolve(result);
    }

    public async getHierarchy(): Promise<PointCloudNode> {
        const { min, max, pointCount } = nonNull(this._header, 'not initialized');

        const volume = new Box3().set(
            new Vector3(min[0], min[1], min[2]),
            new Vector3(max[0], max[1], max[2]),
        );

        const uniqueNode: PointCloudNode = {
            depth: 0,
            volume,
            id: 'root',
            hasData: true,
            geometricError: 0,
            center: volume.getCenter(new Vector3()),
            sourceId: this.id,
            pointCount,
        };

        return uniqueNode;
    }

    public async getNodeData(params: GetNodeDataOptions): Promise<PointCloudNodeData> {
        const requestedAttributes = params.attributes ?? [];

        const dimensions = getDimensionsToRead(requestedAttributes, params.position, this._filters);

        const view = await this.getView(dimensions);

        const { min } = nonNull(this._volume);

        const origin = min.clone();

        const stride = this._options.decimate ?? 1;

        const signal = params.signal;

        const filters = getPerPointFilters(this._filters, view);

        const compressColors = this._options.compressColorsToUint8;

        this._opCounter.increment(requestedAttributes.length);

        let positionBuffer: BufferAttribute | undefined = undefined;
        let localBoundingBox: Box3 | undefined = undefined;

        if (params.position) {
            this._opCounter.increment();
            const result = await defer(() => readPosition(view, origin, stride, filters)).finally(
                () => this._opCounter.decrement(),
            );

            positionBuffer = new Float32BufferAttribute(new Float32Array(result.buffer), 3);
            localBoundingBox = result.localBoundingBox;
        }

        const attributes = await Promise.all(
            requestedAttributes.map(async requestedAttribute => {
                let action: () => ArrayBuffer;

                switch (requestedAttribute.interpretation) {
                    case 'color':
                        action = (): ArrayBuffer =>
                            readColor(view, stride, compressColors, filters);
                        break;
                    default:
                        action = (): ArrayBuffer =>
                            readScalarAttribute(view, requestedAttribute, stride, filters);
                        break;
                }

                const buffer = await defer(action, signal).finally(() =>
                    this._opCounter.decrement(),
                );

                return createBufferAttribute(buffer, requestedAttribute, compressColors);
            }),
        );

        return Promise.resolve({
            origin,
            pointCount: positionBuffer?.count ?? attributes[0]?.count,
            localBoundingBox,
            position: positionBuffer,
            attributes,
        });
    }

    public getMemoryUsage(context: GetMemoryUsageContext): void {
        // We have to store the whole file in memory, since there is no guarantee that the
        // remote server supports range requests (which is a requirement for COPC files for example)
        if (this._buffer != null) {
            context.objects.set(this.id, { cpuMemory: this._buffer.byteLength, gpuMemory: 0 });
        }
    }

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