import { Materials } from './materials';
import { Discipline } from './discipline';
import { BimChangeIfc } from './bim-api-client';
import { BimPropertySetRepository } from './bim-property-set-repository';
import { BimIfcLoaderElement, BimIfcLoaderElementCreateFailure, IBimIfcLoaderElement } from './bim-ifc-loader-element';
import { T8BimIfcLoaderElementCreateFailure } from './T8BimIfcLoaderElement';
import { BimVertexDataRepository } from './bim-vertex-data-repository';
import {
    BimIfcObject,
    BimIfcObjectForEachAction,
    BimIfcSpace,
    BimIfcBuildingStorey,
    DefaultBimIfcObjectForEachPredicate,
    BimIfcObjectForEachPredicate
} from './bim-ifc-object';
import { Vector3, BoundingInfo, BoundingBox, DeepImmutable } from './babylonjs-import';
import { BimIfcClass } from './bim-ifc-class';
import { coordinateSystems } from './babylon-coordinate-system';
import { findAnomalyIndices, setMax, setMin } from '../math';

import { BimCoreApiClient } from './client/BimCoreApiClient';
import { isFailure } from '../fail';
import { GeometryBuilderOptions, IfcGeometryBuilder } from './IfcGeometryBuilder';
import { GeometryBuilderRenderControl } from './GeometryBuilderRenderControl';
import { telemetry } from '../Telemetry';
import { SeverityLevel } from '@microsoft/applicationinsights-web';
import { BimGeometricFeaturesRepository } from './bim-geometric-features-repository';
import type { ListTwinItem, TwinGet } from '@twinfinity/twin';

/**
 * Represents a BIM IFC Loader.
 * Responsible for managing a collection of IFC files and loading data from them.
 */
export interface BimIfcLoaderElementParent {
    /**
     * Gets the floors (IfcBuildingStorey) available in the currently loaded IFC files.
     * @returns An array of {@link BimIfcBuildingStorey} objects representing the available floors (storey levels).
     */
    readonly floors: BimIfcBuildingStorey[];
    /**
     * Gets the spaces (IfcSpace) available in the currently loaded IFC files.
     * @returns An array of {@link BimIfcSpace} objects.
     */
    readonly spaces: BimIfcSpace[];
    /**
     * Gets all the IFC classes used by IFC products in the currently loaded IFC files.
     * @returns An array of {@link BimIfcClass} objects representing the available classes.
     */
    readonly classes: BimIfcClass[];
    /**
     * Gets the available disciplines.
     * @returns An array of Discipline objects.
     */
    readonly disciplines: Discipline[];
    /**
     * Gets the IfcProject root nodes from each loaded IFC file.
     * @remarks
     * These can then be used to traverse the IFC file structure by calling {@link BimIfcObject.foreach}, or {@link BimIfcObject.entries} on each project.
     * @returns An array of BimIfcObject representing the projects.
     */
    readonly projects: BimIfcObject[];

    /**
     * Gets the {@link IBimIfcLoaderElement} for the loaded IFC files.
     * @returns An array of {@link IBimIfcLoaderElement} objects.
     */
    readonly loaderElements: IBimIfcLoaderElement[];

    /**
     * Creates an {@link IfcGeometryBuilder} containing the {@link BimIfcObject} instances matched by the provided predicate.
     * If no predicate is provided, all objects will be included.
     * @remarks
     * The {@link IfcGeometryBuilder} can be used to create {@link Geometry3d} instances for the matched objects. In order to visualize them
     * use {@link TwinfinityViewer.addOrReplaceMesh}.
     *
     * @param options - Optional predicate function to filter the objects.
     * @returns A promise that resolves to an instance of {@link IfcGeometryBuilder}.
     */
    geometryBuilder(
        options?: ((ifcProduct: BimIfcObject) => unknown) | GeometryBuilderOptions
    ): Promise<IfcGeometryBuilder>;
    /**
     * Adds or retrieves the cached set of {@link BimIfcObject} instances based on the provided key and predicate. Cache is cleared when {@link clear} is called.
     * @template T - The type of {@link BimIfcObject} to be stored in the cache.
     * @param key - The key used to identify the cache entry.
     * @param predicate - The predicate function used to filter {@link BimIfcObject} instances. If it returns `truthy` then object is added to cache. Otherwise not
     * @returns The cached set of {@link BimIfcObject} instances.
     */
    addOrGetIfcProductCache<T extends BimIfcObject = BimIfcObject>(
        key: string,
        predicate: BimIfcObjectForEachPredicate
    ): DeepImmutable<Set<T>>;

    /**
     * Deletes a cache created by {@link addOrGetIfcProductCache}-
     * @param key Cache to delete
     * @returns `true` if a cache was deleted, otherwise `false`.
     */
    deleteIfcProductCache(key: string): boolean;

    /**
     * Clears the state of the {@link BimIfcLoader} instance.
     * If {@link geometryBuilder} has been called, and if it has been use to create {@link Geometry3d} instances
     * that have been added to {@link TwinfinityViewer} then those instances are NOT removed from the viewer.
     * Call {@link BimCoreApi.clear} to remove all geometry from the viewer as well.
     */
    clear(): void;
}

/**
 * Represents the data structure for loading an IFC file with additional options.
 */
export interface IfcWithOptions extends BimChangeIfc {
    /**
     * The transformation to be applied to the loaded IFC file.
     * This is useful for positioning the IFC file in the world.
     */
    transform?: BimIfcLoaderElement['transform'];

    /**
     * Extra options for loading the IFC file.
     * Normally used to prevent geometry being loaded for certain IFC objects or to set the visibility of IFC objects.
     */
    loadOptions?: BimIfcLoaderElement['loadOptions'];
}

/**
 * Input for loading a twin into the IFC loader.
 * When passed to {@link BimIfcLoader.add}, the twin is resolved into an {@link IfcWithOptions}
 * via the configured {@link TwinInputResolver}.
 */
export class TwinInput {
    constructor(
        public readonly twinId: string,
        public readonly version?: number,
        public readonly transform?: IfcWithOptions['transform']
    ) {}

    static create(
        twin: TwinGet | ListTwinItem,
        options?: { transform?: IfcWithOptions['transform']; latestVersion?: boolean }
    ): TwinInput {
        const { transform, latestVersion = false } = options || {};
        return new TwinInput(twin.id, latestVersion ? undefined : twin.version, transform);
    }
}

/**
 * Resolves a {@link TwinInput} into an {@link IfcWithOptions} by fetching twin metadata
 * and constructing the necessary resource URLs.
 */
export type TwinInputResolver = (input: TwinInput) => Promise<IfcWithOptions>;

/** Input accepted by {@link BimIfcLoader.add}. Either a direct IFC resource or a twin reference. */
export type IfcAddInput = IfcWithOptions | TwinInput;

/** Type guard to check if an {@link IfcAddInput} is a {@link TwinInput}. */
export function isTwinInput(input: IfcAddInput): input is TwinInput {
    return 'twinId' in input;
}

/**
 * Represents a BIM IFC Loader.
 * Responsible for managing a collection of IFC files and loading data from them.
 */
export class BimIfcLoader implements BimIfcLoaderElementParent {
    private readonly _availableClasses = new Set<BimIfcClass>();
    private readonly _availableFloors = new Set<BimIfcBuildingStorey>();
    private readonly _availableSpaces = new Set<BimIfcSpace>();
    private readonly _availableDisciplines = new Set<Discipline>();

    private readonly _ifcLoaderElements: IBimIfcLoaderElement[] = [];
    private readonly _projects: BimIfcObject[] = [];
    private readonly _ifcProductCache = new Map<string, Set<BimIfcObject>>();
    private readonly _regionBoundingInfo = new BoundingInfo(Vector3.Zero(), Vector3.Zero());
    private _ifcProductsWithGeometryCount = 0;
    private readonly _loadedIfcChanges = new Set<string>();

    /**
     * Creates a instance of {@link BimIfcLoader} class.
     * Responsible for managing a collection of IFC files and loading data from them.
     */
    public constructor(
        private readonly _bimApi: BimCoreApiClient,
        private readonly _propertySetRepository: BimPropertySetRepository,
        private readonly _geometricFeaturesRepository: BimGeometricFeaturesRepository,
        public readonly vertexDataRepository: BimVertexDataRepository,
        private readonly _materials: Materials,
        private readonly _twinInputResolver?: TwinInputResolver,
        private readonly _renderControl?: GeometryBuilderRenderControl
    ) {}

    /**
     * Gets the floors (IfcBuildingStorey) available in the currently loaded IFC files.
     * @returns An array of {@link BimIfcBuildingStorey} objects representing the available floors (storey levels).
     */
    public get floors(): BimIfcBuildingStorey[] {
        return [...this._availableFloors];
    }
    /**
     * Gets the spaces (IfcSpace) available in the currently loaded IFC files..
     * @returns An array of {@link BimIfcSpace} objects.
     */
    public get spaces(): BimIfcSpace[] {
        return [...this._availableSpaces];
    }
    /**
     * Gets all the IFC classes used by IFC products in the currently loaded IFC files.
     * @returns An array of {@link BimIfcClass} objects representing the available classes.
     */
    public get classes(): BimIfcClass[] {
        return [...this._availableClasses];
    }
    /**
     * Gets the available disciplines.
     * @returns An array of Discipline objects.
     */
    public get disciplines(): Discipline[] {
        return [...this._availableDisciplines];
    }

    /**
     * Gets the IfcProject root nodes from each loaded IFC file.
     * @remarks
     * These can then be used to traverse the IFC file structure by calling {@link BimIfcObject.foreach}, or {@link BimIfcObject.entries} on each project.
     * @returns An array of BimIfcObject representing the projects.
     */
    public get projects(): BimIfcObject[] {
        return this._projects;
    }

    /**
     * Gets the array of {@link BimChangeIfc} objects representing the IFC files that are loaded.
     *
     * @returns An array of {@link BimChangeIfc} objects.
     */
    public get ifcHttpResources(): BimChangeIfc[] {
        return this.loaderElements.map((i) => i.ifc);
    }

    /**
     * Gets the count of IFC products with geometry.
     * @returns The number of IFC products with geometry.
     */
    public get ifcProductsWithGeometryCount(): number {
        return this._ifcProductsWithGeometryCount;
    }

    /**
     * Gets the {@link IBimIfcLoaderElement} for the loaded IFC files.
     * @returns An array of {@link IBimIfcLoaderElement} objects.
     */
    public get loaderElements(): IBimIfcLoaderElement[] {
        return this._ifcLoaderElements;
    }

    /**
     * Get the bounding info for loaded IFC files based on the most populated regions of each IFC file, excluding outliers and anomalous regions.
     * The bounding info is derived from the regions with the highest IFC product count in each IFC file. Regions distant from the majority are excluded.
     * The `regionBoundingInfo` is critical for camera positioning to ensure the entire `regionBoundingInfo` is within the camera's frustum.
     * Overly large `regionBoundingInfo` can result in the camera being too distant, causing visibility issues.
     * This can occur if anomalies are undetectable, e.g., when 50% of regions are proximate while the remaining 50% are outliers but also proximate to each other.
     * This is common when IFC files are modeled in different coordinate systems, leading to disparate global positions for files intended to be co-located.
     * There have been cases where IFC files, that should been co-located, are actually
     * located in widely different places on the globe.
     * Whenever {@link add} is called (before {@link clear}) this property is updated.
     */
    public get regionBoundingInfo(): BoundingInfo {
        return this._regionBoundingInfo;
    }

    /** @inheritDoc */
    public addOrGetIfcProductCache<T extends BimIfcObject = BimIfcObject>(
        key: string,
        predicate: BimIfcObjectForEachPredicate
    ): DeepImmutable<Set<T>> {
        return this._ifcProductCache.getOrAdd(key, (_key) => {
            const set = new Set<BimIfcObject>();
            this.foreach((o, rO) => {
                if (predicate(o, rO)) {
                    set.add(o);
                }
            });
            return Object.freeze(set);
        }) as Set<T>;
    }

    /** @inheritDoc */
    public deleteIfcProductCache(key: string): boolean {
        return this._ifcProductCache.delete(key);
    }

    /**
     * Loads the property sets for currently loaded IFC files {@link add}.
     * If property sets are already loaded then this method does nothing.
     * @returns A promise that resolves when all property sets have been loaded.
     */
    public async loadPropertySets(): Promise<void> {
        const promises = this.loaderElements.map((lE) => lE.loadPropertySets());
        await Promise.all(promises);
        // Clean out property set repository since all properties have been loaded
        // this cuts down on RAM usage in cases where we have not loaded all ifc objects from
        // a .idx file.
        this._propertySetRepository.clear();
    }

    /**
     * Experimental. Do not use directly. This method will be removed.
     *
     * Opt-in to geometric features by using the @twinfinity/experimental package
     *
     * @internal
     * @hidden
     */
    protected async _experimentalLoadGeometricFeatures(): Promise<void> {
        const promises = this.loaderElements.map((lE) => lE.loadGeometricFeatures());
        await Promise.all(promises);
        this._geometricFeaturesRepository.clear();
    }

    /**
     * Iterate all IFC objects with a callback. Its also possible to call {@link products} to get an iterator.
     * @param action Visitor callback. Called for every IFC object.
     */
    public foreach(action: BimIfcObjectForEachAction): void {
        for (const le of this.loaderElements) {
            le.project.foreach(action);
        }
    }

    /**
     * Gets a iterator for all Ifc products currently loaded.
     */
    public *products(): IterableIterator<BimIfcObject> {
        for (const le of this.loaderElements) {
            for (const product of le.project.entries()) {
                yield product;
            }
        }
    }

    /** @deprecated No longer does anything */
    public applyColor(): void {
        console.warn(
            '.applyColor() is deprecated and no longer does anything. It will be removed in a future release.'
        );
    }

    /**
     * @deprecated - No longer does anything.
     */
    public applyAndResetTrackedVisualChanges(): ReadonlySet<BimIfcObject> | undefined {
        console.warn(
            '.applyAndResetTrackedVisualChanges() is deprecated and no longer does anything. It will be removed in a future release.'
        );

        return undefined;
    }

    /**
     * Calculate the bounding info for the currently loaded IFC files based on the most populated regions of each IFC file, excluding outliers and anomalous regions.
     *
     * @remarks
     * The bounding info is derived from the regions with the highest IFC product count in each IFC file. Regions distant from the majority are excluded.
     * The `regionBoundingInfo` is critical for camera positioning to ensure the entire `regionBoundingInfo` is within the camera's frustum.
     * Overly large `regionBoundingInfo` can result in the camera being too distant, causing visibility issues.
     * This can occur if anomalies are undetectable, e.g., when 50% of regions are proximate while the remaining 50% are outliers but also proximate to each other.
     * This is common when IFC files are modeled in different coordinate systems, leading to disparate global positions for files intended to be co-located.
     * There have been cases where IFC files, that should been co-located, are actually
     * located in widely different places on the globe.
     * @param onlyMostPopulatedRegions - Indicates whether to include only the most populated regions.
     * @param useWorldSpace - Indicates whether to use world space coordinates. Default is `true`
     * @returns The bounding information for the regions.
     */
    public getRegionBoundingInfo(onlyMostPopulatedRegions: boolean, useWorldSpace = true): BoundingInfo {
        const bboxes: BoundingBox[] = [];

        const o = { onlyMostPopulatedRegions };
        for (const le of this.loaderElements) {
            const min = setMax(Vector3.Zero());
            const max = setMin(Vector3.Zero());

            // Some IFC's do not have valid AABB's. Just skip them.
            if (!le.tryCreateOrExtendAABBFromRegions(min, max, o)) continue;

            // Almost everybody uses useWorldSpace = true but we have to keep this for
            // backwards compatibility. Previously all AABB's were in IFC world space but everbody wanted them
            // in Babylon world space. But we now change them to Babylon world space during loading. Hence we have
            // to keep this if somebody relied on IFC world space AABB's.
            if (!useWorldSpace) {
                const inverseTransform = le.transform?.invert();
                if (inverseTransform) {
                    Vector3.TransformCoordinatesToRef(min, inverseTransform, min);
                    Vector3.TransformCoordinatesToRef(max, inverseTransform, max);
                }

                coordinateSystems.convertCoordinateBetweenIfcAndBabylon(min.x, min.y, min.z, min);
                coordinateSystems.convertCoordinateBetweenIfcAndBabylon(max.x, max.y, max.z, max);

                min.scaleInPlace(le.index.modelFactors.oneMeter);
                max.scaleInPlace(le.index.modelFactors.oneMeter);
            }

            bboxes.push(new BoundingBox(min, max));
        }

        const defaultBoundingInfo = new BoundingInfo(new Vector3(-100, 0, -100), new Vector3(100, 200, 100));
        if (bboxes.length === 0) {
            return defaultBoundingInfo;
        }

        const indicesForBboxesToRemove = new Set(findAnomalyIndices(bboxes.map((b) => b.minimum.length())));
        const bboxesToUse = bboxes.filter((_, i) => !indicesForBboxesToRemove.has(i));
        if (bboxesToUse.length === 0) {
            return defaultBoundingInfo;
        }

        const bboxMin = setMax(Vector3.Zero());
        const bboxMax = setMin(Vector3.Zero());

        bboxesToUse.forEach((bbox) => {
            bboxMin.minimizeInPlace(bbox.minimum);
            bboxMax.maximizeInPlace(bbox.minimum);
            bboxMin.minimizeInPlace(bbox.maximum);
            bboxMax.maximizeInPlace(bbox.maximum);
        });

        return new BoundingInfo(bboxMin, bboxMax);
    }

    /**
     * Adds the specified IFC files or twins to the loader.
     * @remarks
     * When files are added, the loader will load the IFC files and create {@link IBimIfcLoaderElement} instances for each file.
     * See {@link loaderElements} for the created instances.
     * To read the IFC products, present the loaded files, use {@link foreach} or {@link products}.
     *
     * Twin inputs ({@link TwinInput}) are resolved into {@link IfcWithOptions} via the configured {@link TwinInputResolver}.
     *
     * @param inputs - An array of {@link IfcAddInput} (either {@link IfcWithOptions} or {@link TwinInput}).
     * @returns A promise that resolves to an array of {@link IBimIfcLoaderElement} or failure instances.
     * Use {@link isFailure} to check if a returned instance is a failure or not. Its not required to check for failures but
     * it can be useful to know why a file failed to load.
     */
    public async add(
        inputs: IfcAddInput | IfcAddInput[]
    ): Promise<(IBimIfcLoaderElement | BimIfcLoaderElementCreateFailure | T8BimIfcLoaderElementCreateFailure)[]> {
        if (!Array.isArray(inputs)) {
            inputs = [inputs];
        }

        const ifcFiles = await Promise.all(
            inputs.map((input) => {
                if (!isTwinInput(input)) return input;
                if (!this._twinInputResolver) {
                    throw new Error('Twin loading requires a TwinInputResolver but none was configured');
                }
                return this._twinInputResolver(input);
            })
        );

        // Only load ifc http ifc resources that have not yet been loaded.
        const loaderElementsSuccess: IBimIfcLoaderElement[] = [];

        // Load all idx files in parallel
        const loaderElementResults = await Promise.all(
            ifcFiles.map(async (ifc) => {
                this.trackAddOfSameIfcFile(ifc);

                const ret = await BimIfcLoaderElement.create(
                    this._bimApi,
                    this._materials,
                    this.vertexDataRepository,
                    this._propertySetRepository,
                    this._geometricFeaturesRepository,
                    this,
                    ifc,
                    ifc.loadOptions,
                    ifc?.transform
                );
                if (!isFailure(ret)) {
                    this._ifcProductsWithGeometryCount += ret.ifcProductsWithGeometryCount;
                    loaderElementsSuccess.push(ret);
                }
                return ret;
            })
        );

        // Build _ifcLoaderElements after await to avoid populating _ifcLoaderElements during load. (others could
        // possibly read the values if we do.)
        this._ifcLoaderElements.push(...loaderElementsSuccess);

        // After this its possible to set colors, change visibility etc on ifcproducts.
        this._materials.add(loaderElementsSuccess);

        for (const lE of loaderElementsSuccess) {
            this.projects.push(lE.project);
            lE.classes.forEach((c) => this._availableClasses.add(c));
            lE.floors.forEach((f) => this._availableFloors.add(f));
            lE.spaces.forEach((s) => this._availableSpaces.add(s));
            this._availableDisciplines.add(lE.ifc.discipline);
        }

        const { minimum, maximum } = this.getRegionBoundingInfo(true);
        this._regionBoundingInfo.reConstruct(minimum, maximum);
        return loaderElementResults;
    }

    /**
     * Calculates the `min` and `max` coordinate of the axis-aligned bounding box (AABB)
     * encompassing a collection of {@link BimIfcObject}'s.
     * The AABB is defined by the minimum and maximum coordinates in each axis.
     * The result is written to the provided `min` and `max` vectors.
     *
     * @param min - The minimum coordinates of the AABB will be stored in this vector.
     * @param max - The maximum coordinates of the AABB will be stored in this vector.
     * @param ifcProducts - The collection of {@link BimIfcObject} to calculate the AABB for.
     * @param predicate - The predicate function to filter the BimIfcObject. Defaults to {@link DefaultBimIfcObjectForEachPredicate}.
     */
    public aabb(
        min: Vector3,
        max: Vector3,
        ifcProducts: BimIfcObject[],
        predicate = DefaultBimIfcObjectForEachPredicate
    ): void {
        const referencedLoaderElements = new Set<IBimIfcLoaderElement>();
        for (const rootProduct of ifcProducts) {
            rootProduct.aabb(min, max, predicate);
            referencedLoaderElements.add(rootProduct.ifcLoaderElement);
        }
    }

    /**
     * Creates an {@link IfcGeometryBuilder} containing the {@link BimIfcObject} instances matched by the provided predicate.
     * If no predicate is provided, all objects will be included.
     * @remarks
     * The {@link IfcGeometryBuilder} can be used to create {@link Geometry3d} instances for the matched objects. In order to visualize them
     * use {@link TwinfinityViewer.addOrReplaceMesh}.
     *
     * @param options - Optional predicate function to filter the objects.
     * @returns A promise that resolves to an instance of {@link IfcGeometryBuilder}.
     */
    public geometryBuilder(
        options?: ((ifcProduct: BimIfcObject) => unknown) | GeometryBuilderOptions
    ): Promise<IfcGeometryBuilder> {
        if (typeof options === 'function') {
            options = { predicate: options };
        }

        return IfcGeometryBuilder.create({
            loaderElements: this.loaderElements,
            predicate: options?.predicate ?? (() => true),
            cullOnCameraMovePredicate: options?.cullOnCameraMovePredicate,
            renderControl: this._renderControl
        });
    }

    /**
     * Clears the state of the {@link BimIfcLoader} instance.
     * If {@link geometryBuilder} has been called, and if it has been used to create {@link Geometry3d} instances
     * that have been added to {@link TwinfinityViewer} then those instances are NOT removed from the viewer.
     * Call {@link BimCoreApi.clear} to remove all geometry from the viewer as well.
     */
    public clear(): void {
        BimIfcClass.reset();
        Discipline.reset();
        this._projects.length = 0; // Clear array
        for (const le of this.loaderElements) {
            le.transformsRepository.clear();
        }
        this._loadedIfcChanges.clear();
        this._propertySetRepository.clear();
        this.vertexDataRepository.clear();
        this._materials.clear();

        this._availableClasses.clear();

        this._availableFloors.clear();
        this._availableSpaces.clear();
        this._availableDisciplines.clear();
        this._ifcLoaderElements.length = 0;
        this._ifcProductCache.clear();
    }

    private trackAddOfSameIfcFile(ifc: IfcWithOptions): void {
        // Its not a problem to load the same file multiple times. Usually its done
        // if you want to load the same file with different transforms. However
        // previously we did not allow this and we want to know if this happens since some
        // apps could be relying on this behavior.
        if (!this._loadedIfcChanges.add(`${ifc.id}-${ifc.version}`.toLowerCase())) {
            telemetry.trackTrace(
                {
                    message: `Added same IFC file multiple times`,
                    severityLevel: SeverityLevel.Verbose
                },
                {
                    id: ifc.id,
                    version: ifc.version,
                    serverRelativeUrl: ifc.metadata.serverRelativeUrl
                }
            );
        }
    }
}

export interface BimIfcGeometricFeatureLoader {
    loadGeometricFeatures(): Promise<void>;
}
