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

import { Box3, Vector3 } from 'three';

import type { GetMemoryUsageContext } from '../core/MemoryUsage';

import { nonEmpty, nonNull } from '../utils/tsutils';
import {
    PointCloudSourceBase,
    type GetNodeDataOptions,
    type PointCloudAttribute,
    type PointCloudMetadata,
    type PointCloudNode,
    type PointCloudNodeData,
    type PointCloudSource,
} from './PointCloudSource';

export interface AggregatePointCloudSourceOptions {
    /**
     * The sources to aggregate. Cannot be empty.
     */
    sources: PointCloudSource[];
}

function getAttributeKey(attr: PointCloudAttribute): string {
    return `${attr.name}-${attr.dimension}-${attr.type}-${attr.interpretation}-${attr.size}`;
}

function aggregateAttributes(attr: PointCloudAttribute[]): PointCloudAttribute {
    const { name, dimension, type, interpretation, size } = attr[0];

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

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

        if (att.min != null) {
            min = Math.min(att.min, min);
        }
        if (att.max != null) {
            max = Math.max(att.max, max);
        }
    }

    return {
        name,
        dimension,
        type,
        size,
        interpretation,
        min: isFinite(min) ? min : undefined,
        max: isFinite(max) ? max : undefined,
    };
}

/**
 * A {@link PointCloudSource} that combines multiple sources.
 *
 * All aspects of the underlying sources are combined in the following way:
 * - volumes are union'ed
 * - point counts are summed
 * - only attributes that are found in *all* sources are exposed.
 */
export default class AggregatePointCloudSource extends PointCloudSourceBase {
    public readonly isAggregatePointCloudSource = true as const;
    public readonly type = 'AggregatePointCloudSource' as const;

    private readonly _sourceMap: Map<string, PointCloudSource> = new Map();
    private readonly _sources: PointCloudSource[];

    /**
     * The sources in this source.
     */
    public get sources(): Readonly<PointCloudSource[]> {
        return this._sources;
    }

    public constructor(params: AggregatePointCloudSourceOptions) {
        super();

        const sources = nonEmpty(params.sources, 'sources is required');
        this._sources = sources;

        sources.forEach(s => {
            this._sourceMap.set(s.id, s);
            s.addEventListener('progress', () => this.dispatchEvent({ type: 'progress' }));
        });
    }

    protected async initializeOnce(): Promise<this> {
        const promises = this._sources.map(s => {
            const promise = s.initialize();
            return promise;
        });

        const results = await Promise.allSettled(promises);

        const actualSources: PointCloudSource[] = [];

        let notifyWarning = false;

        for (const result of results) {
            if (result.status === 'fulfilled') {
                actualSources.push(result.value);
            } else {
                notifyWarning = true;
            }
        }

        this._sources.length = 0;
        this._sources.push(...actualSources);

        Object.freeze(this._sources);

        if (notifyWarning) {
            console.warn('one or more sources could not be initialized.');
        }

        return this;
    }

    public get loading(): boolean {
        return this._sources.some(s => s.loading);
    }

    public get progress(): number {
        let sum = 0;
        let count = 0;
        this._sources.forEach(s => {
            if (s.progress < 1) {
                sum += s.progress;
                count++;
            }
        });

        if (count > 0) {
            return sum / count;
        }

        return 1;
    }

    public async getHierarchy(): Promise<PointCloudNode> {
        const allRootNodes = await Promise.all(this._sources.map(s => s.getHierarchy()));

        const volume = new Box3().makeEmpty();
        for (const node of allRootNodes) {
            volume.union(node.volume);
        }

        const pseudoRoot: PointCloudNode = {
            hasData: false,
            volume,
            sourceId: this.id,
            center: volume.getCenter(new Vector3()),
            id: '__pseudoRoot',
            depth: -1,
            children: allRootNodes,
            geometricError: +Infinity,
        };

        return pseudoRoot;
    }

    public async getMetadata(): Promise<PointCloudMetadata> {
        const sourceCount = this._sources.length;
        const promises = this._sources.map(s => s.getMetadata());

        const all = await Promise.all(promises);

        let pointCount = 0;
        const volume: Box3 = new Box3().makeEmpty();

        const attributeMap: Map<string, { count: number; attributes: PointCloudAttribute[] }> =
            new Map();

        for (const metadata of all) {
            pointCount += metadata.pointCount ?? 0;

            // Create the union of all volumes
            if (metadata.volume) {
                volume.union(metadata.volume);
            }

            // Create the *intersection* of attributes.
            for (const attribute of metadata.attributes) {
                const key = getAttributeKey(attribute);

                const existing = attributeMap.get(key);

                if (!existing) {
                    attributeMap.set(key, { count: 1, attributes: [attribute] });
                } else {
                    existing.count += 1;
                    existing.attributes.push(attribute);
                }
            }
        }

        // FIXME when Set.prototype.intersection() becomes widely available,
        // use this instead.
        const attributes: PointCloudAttribute[] = [];
        attributeMap.forEach(attr => {
            if (attr.count === sourceCount) {
                attributes.push(aggregateAttributes(attr.attributes));
            }
        });

        return {
            pointCount,
            volume: volume.isEmpty() ? undefined : volume,
            attributes,
        };
    }

    public getNodeData(params: GetNodeDataOptions): Promise<PointCloudNodeData> {
        const { node } = params;
        const targetSource = nonNull(this._sourceMap.get(node.sourceId));

        return targetSource.getNodeData(params);
    }

    /**
     * Disposes this source and all underlying sources.
     */
    public dispose(): void {
        this._sources.forEach(s => s.dispose());
    }

    public getMemoryUsage(context: GetMemoryUsageContext): void {
        this._sources.forEach(s => s.getMemoryUsage(context));
    }
}

export function isAggregatePointCloudSource(obj: unknown): obj is AggregatePointCloudSource {
    return (obj as AggregatePointCloudSource).isAggregatePointCloudSource === true;
}
