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

import { Vector2, Vector3, Matrix, Scene, Ray, IntersectionInfo, Camera, BoundingInfo } from './babylonjs-import';
import { BimVertexData, Intersection, SimpleBoundingBox, NormalType } from './bim-format-types';
import { setMax, setMin, intersectRayWithAAABBToRef, Vertex2 } from '../math';

import { PickOptionFlags } from './Selectables';
import { decodePackedNormal } from './PackedNormal';

const v1 = Vector3.Zero();
const v2 = Vector3.Zero();
const v3 = Vector3.Zero();

/**
 * Common {@link BimIfcMesh} intersection options
 */
export type BimIfcMeshIntersectionCameraCommonOptions = Omit<
    PickOptionFlags,
    'textureSize' | 'isNearbyRenderedObjectsIntersectionFallbackEnabled' | 'type'
>;

/**
 * {@link BimIfcMesh} intersection options for intersection with a {@link camera} and canvas coordinates.
 */
export interface BimIfcMeshIntersectionCameraOptions extends BimIfcMeshIntersectionCameraCommonOptions {
    /**
     * Camera to use for intersection test.
     */
    readonly camera: Camera;

    /**
     * Canvas coordinate to use when picking. A ray is shot into the scene from the camera passing through
     * the canvas coordinate.
     */
    readonly canvasCoordinate: Vertex2;
}

/**
 * {@link BimIfcMesh} intersection options for intersection with {@link Ray}.
 */
export interface BimIfcMeshIntersectionRayOptions extends BimIfcMeshIntersectionCameraCommonOptions {
    /**
     * Perform intersection with a ray. Ray is specified in BabylonJS world coordinates
     */
    readonly ray: Ray;
}

/** {@link BimIfcMesh} intersection options */
export type BimIfcMeshIntersectionOptions = BimIfcMeshIntersectionCameraOptions | BimIfcMeshIntersectionRayOptions;

/** Representation of an IFC mesh */
export class BimIfcMesh {
    private static readonly _tmp = {
        aabbMin: Vector3.Zero(),
        aabbMax: Vector3.Zero(),
        out: Vector3.Zero(),
        normal: Vector3.Zero(),
        boundingInfo: new BoundingInfo(Vector3.Zero(), Vector3.Zero())
    };

    public constructor(
        public readonly vertexData: BimVertexData,
        public readonly transform: Matrix,
        private readonly toMeterScale: number
    ) {}

    /**
     * Perform intersection testing on a {@link BimIfcMesh}.
     * @param o Intersection options. See {@link BimIfcMeshIntersectionOptions}.
     * @returns Intersection info. Will be empty if no intersection was found.
     */
    public intersect(o: BimIfcMeshIntersectionOptions): Intersection[] {
        const intersections: Intersection[] = [];
        const t = this.transform;
        let ray: Ray;
        if ('canvasCoordinate' in o) {
            ray = Ray.Zero();
            ray = o.camera.getScene().createPickingRay(o.canvasCoordinate.x, o.canvasCoordinate.y, t, o.camera);
        } else {
            ray = Ray.Transform(o.ray, this.transform.clone().invert());
        }

        const aabbMax = setMin(BimIfcMesh._tmp.aabbMax);
        const aabbMin = setMax(BimIfcMesh._tmp.aabbMin);

        const vd = this.vertexData;
        const iLen = vd.indices.length;

        // Intersecting a triangle ie expensive so we only do it if we are required to
        // if not we replace the intersect method with a noop. (gets rid of if statement in tight for loop)
        // We always build a AABB even if no geometry intersection is performed.
        const intersectTriangle =
            o.isGeometryIntersectionEnabled === undefined || o.isGeometryIntersectionEnabled
                ? this.intersectTriangle.bind(this)
                : this.intersectTriangleNoOp.bind(this);
        for (let i = 0; i < iLen; i += 3) {
            const i1 = vd.indices[i];
            const i2 = vd.indices[i + 1];
            const i3 = vd.indices[i + 2];
            v1.set(vd.positions[i1 * 3], vd.positions[i1 * 3 + 1], vd.positions[i1 * 3 + 2]);
            v2.set(vd.positions[i2 * 3], vd.positions[i2 * 3 + 1], vd.positions[i2 * 3 + 2]);
            v3.set(vd.positions[i3 * 3], vd.positions[i3 * 3 + 1], vd.positions[i3 * 3 + 2]);

            aabbMax.maximizeInPlace(v1);
            aabbMax.maximizeInPlace(v2);
            aabbMax.maximizeInPlace(v3);

            aabbMin.minimizeInPlace(v1);
            aabbMin.minimizeInPlace(v2);
            aabbMin.minimizeInPlace(v3);

            intersectTriangle(ray, v1, v2, v3, i / 3, t, intersections);
        }

        if (intersections.length === 0 && o.isAABBIntersectionEnabled) {
            const distance = intersectRayWithAAABBToRef(ray, aabbMin, aabbMax, BimIfcMesh._tmp.out);
            if (distance !== undefined) {
                intersections.push({
                    position: Vector3.TransformCoordinates(BimIfcMesh._tmp.out, t),
                    // TODO Not correct normal!
                    normal: Vector3.TransformNormal(ray.direction.negate(), t).normalize(),
                    distance: distance
                });
            }
        }
        if (intersections.length === 0 && o.isCenterIntersectionFallbackEnabled) {
            BimIfcMesh._tmp.boundingInfo.reConstruct(aabbMin, aabbMax);
            const center = BimIfcMesh._tmp.boundingInfo.boundingSphere.centerWorld;
            const distance = ray.origin.subtract(center).length();
            intersections.push({
                position: Vector3.TransformCoordinates(center, t),
                // TODO Not correct normal! But in this case there is none.
                normal: Vector3.TransformNormal(ray.direction.negate(), t).normalize(),
                distance: distance
            });
        }
        intersections.sort((a, b) => a.distance - b.distance);
        return intersections;
    }

    /**
     * Finds all intersections of a Ray object and a this geometry.
     * If a Vector2 object is provided, a screen-spaced Ray form pixel will be used for intersection test.
     * @param screenCoordinateOrRay Vector2 | Ray. Ray needs to be in babylon-coordinate-system/world-space.
     * @param sceneOrOptions Scene.
     * @returns Intersection info. Will be empty if no intersection was found.
     */
    public getIntersectionPoints(screenCoordinateOrRay: Vector2 | Ray, sceneOrOptions?: Scene): Intersection[] {
        if (screenCoordinateOrRay instanceof Ray) {
            return this.intersect({ ray: screenCoordinateOrRay });
        }
        if (sceneOrOptions === undefined) {
            throw new Error('Not implemented');
        } else if (!sceneOrOptions.activeCamera) {
            throw new Error('Scene has no active camera');
        }
        return this.intersect({ canvasCoordinate: screenCoordinateOrRay, camera: sceneOrOptions.activeCamera });
    }

    /**
     * Checks if bounds of object are inside of geometry.
     * @param bound SimpleBoundingBox in world space.
     * @alpha
     */
    public geometricallyContainsBoundingBox(bound: SimpleBoundingBox): boolean {
        const babylonToLocal = this.transform.clone().invert(); //matrix from babylon space to this geometry object space

        // set bbox coords to v1 and v2
        v1.set(bound[0], bound[1], bound[2]);
        v2.set(bound[3], bound[4], bound[5]);

        // transform from world to local coords
        Vector3.TransformCoordinatesToRef(v1, babylonToLocal, v1);
        Vector3.TransformCoordinatesToRef(v2, babylonToLocal, v2);

        // calculate bbox vector and distance.
        const direction = v2.subtract(v1).normalize();
        const distance = Vector3.Distance(v1, v2);
        const ray = new Ray(v1.clone(), direction);

        const intersections = this.getRayIntersections(ray);

        // if even intersection count = ray got origin outside the geometry
        if (intersections.length % 2 === 0) {
            return false;
        }

        // filter out all intersections closer to origin than the size of the bbox
        const intersectionsBeyondBbox = intersections.filter((i) => i.distance >= distance);
        if (intersectionsBeyondBbox.length % 2 === 0) {
            return false;
        }

        // else there is a odd count, and the bbox is completely on inside of mesh
        return true;
    }
    /**
     * Checks if bounds of object are inside of geometry.
     * @param bound SimpleBoundingBox in world space.
     * @alpha
     */
    public geometricallyIntersectingBoundingBox(bound: SimpleBoundingBox): boolean {
        const babylonToLocal = this.transform.clone().invert(); //matrix from babylon space to this geometry object space

        // set bbox coords to v1 and v2
        v1.set(bound[0], bound[1], bound[2]);
        v2.set(bound[3], bound[4], bound[5]);

        // transform from world to local coords
        Vector3.TransformCoordinatesToRef(v1, babylonToLocal, v1);
        Vector3.TransformCoordinatesToRef(v2, babylonToLocal, v2);

        // calculate bbox vector and distance.
        const direction = v2.subtract(v1).normalize();
        const distance = Vector3.Distance(v1, v2);
        const ray = new Ray(v1.clone(), direction, distance);

        const intersections = this.getRayIntersections(ray);

        // If no intersection, bounding box is not intersecting
        if (intersections.length === 0) {
            return false;
        }

        // else there is a intersection
        return true;
    }
    /**
     * Checks if all vertices of mesh are completely contained by the geometry.
     * @param mesh Mesh of geometry to test.
     * @alpha
     */
    public geometricallyContains(mesh: BimIfcMesh): boolean {
        const ifcToLocal = this.transform.clone().invert(); //matrix from IFC space to this geometry object space
        const meshLocalToIfcToLocal = mesh.transform.multiply(ifcToLocal); // from mesh to ifc and to local

        const vd = mesh.vertexData;
        const iLen = vd.indices.length;

        for (let i = 0; i < iLen; i += 3) {
            const i1 = vd.indices[i];
            const i2 = vd.indices[i + 1];

            v1.set(vd.positions[i1 * 3], vd.positions[i1 * 3 + 1], vd.positions[i1 * 3 + 2]);
            v2.set(vd.positions[i2 * 3], vd.positions[i2 * 3 + 1], vd.positions[i2 * 3 + 2]);
            Vector3.TransformCoordinatesToRef(v1, meshLocalToIfcToLocal, v1);
            Vector3.TransformCoordinatesToRef(v2, meshLocalToIfcToLocal, v2);

            const direction = v2.subtract(v1).normalize();
            const distance = Vector3.Distance(v1, v2);
            const ray = new Ray(v1.clone(), direction, distance);

            let intersections = this.getRayIntersections(ray);

            // if even intersection count = ray got origin outside the geometry
            if (intersections.length % 2 === 0) {
                return false;
            }

            // filter out all intersections closer to origin than the size of the bbox
            let intersectionsBeondBbox = intersections.filter((i) => i.distance >= distance);
            if (intersectionsBeondBbox.length % 2 === 0) {
                return false;
            }

            // to be sure we need to test another edge as well
            const i3 = vd.indices[i + 2];
            v1.set(vd.positions[i1 * 3], vd.positions[i1 * 3 + 1], vd.positions[i1 * 3 + 2]);
            v3.set(vd.positions[i3 * 3], vd.positions[i3 * 3 + 1], vd.positions[i3 * 3 + 2]);
            Vector3.TransformCoordinatesToRef(v1, meshLocalToIfcToLocal, v1);
            Vector3.TransformCoordinatesToRef(v3, meshLocalToIfcToLocal, v3);
            ray.length = Vector3.Distance(v1, v3);
            direction.copyFrom(v3.subtractInPlace(v1).normalize());

            intersections = this.getRayIntersections(ray);

            // if even intersection count = ray got origin outside the geometry
            if (intersections.length % 2 === 0) {
                return false;
            }

            // filter out all intersections closer to origin than the size of the bbox
            intersectionsBeondBbox = intersections.filter((i) => i.distance >= distance);
            if (intersectionsBeondBbox.length % 2 === 0) {
                return false;
            }
        }
        return false;
    }

    /**
     * Checks if there is a vertex from the mesh on the inside and outside of the geometry.
     * @param mesh Mesh of geometry to test.
     * @alpha
     */
    public geometricallyIntersects(mesh: BimIfcMesh): boolean {
        const ifcToLocal = this.transform.clone().invert(); //matrix from IFC space to this geometry object space

        const meshLocalToIfcToLocal = mesh.transform.multiply(ifcToLocal); // from mesh to ifc and to local

        const vd = mesh.vertexData;
        const iLen = vd.indices.length;

        for (let i = 0; i < iLen; i += 3) {
            const i1 = vd.indices[i];
            const i2 = vd.indices[i + 1];

            v1.set(vd.positions[i1 * 3], vd.positions[i1 * 3 + 1], vd.positions[i1 * 3 + 2]);
            v2.set(vd.positions[i2 * 3], vd.positions[i2 * 3 + 1], vd.positions[i2 * 3 + 2]);
            Vector3.TransformCoordinatesToRef(v1, meshLocalToIfcToLocal, v1);
            Vector3.TransformCoordinatesToRef(v2, meshLocalToIfcToLocal, v2);

            const direction = v2.subtract(v1).normalize();
            const distance = Vector3.Distance(v1, v2);
            const ray = new Ray(v1.clone(), direction, distance);

            if (this.rayIntersects(ray)) {
                return true;
            }

            // to be sure we need to test another edge as well
            const i3 = vd.indices[i + 2];
            v1.set(vd.positions[i1 * 3], vd.positions[i1 * 3 + 1], vd.positions[i1 * 3 + 2]);
            v3.set(vd.positions[i3 * 3], vd.positions[i3 * 3 + 1], vd.positions[i3 * 3 + 2]);
            Vector3.TransformCoordinatesToRef(v1, meshLocalToIfcToLocal, v1);
            Vector3.TransformCoordinatesToRef(v3, meshLocalToIfcToLocal, v3);
            ray.length = Vector3.Distance(v1, v3);
            direction.copyFrom(v3.subtractInPlace(v1).normalize());

            if (this.rayIntersects(ray)) {
                return true;
            }
        }
        return false;
    }

    private barycentricToPoint(v1: Vector3, v2: Vector3, v3: Vector3, bu: number, bv: number): Vector3 {
        const bw = 1 - (bu + bv);

        return new Vector3(
            bu * v1.x + bv * v2.x + bw * v3.x,
            bu * v1.y + bv * v2.y + bw * v3.y,
            bu * v1.z + bv * v2.z + bw * v3.z
        );
    }

    private getRayIntersections(ray: Ray): IntersectionInfo[] {
        const vd = this.vertexData;
        const iLen = vd.indices.length;
        const intersections = [];

        for (let i = 0; i < iLen; i += 3) {
            const i1 = vd.indices[i];
            const i2 = vd.indices[i + 1];
            const i3 = vd.indices[i + 2];

            v1.set(vd.positions[i1 * 3], vd.positions[i1 * 3 + 1], vd.positions[i1 * 3 + 2]);
            v2.set(vd.positions[i2 * 3], vd.positions[i2 * 3 + 1], vd.positions[i2 * 3 + 2]);
            v3.set(vd.positions[i3 * 3], vd.positions[i3 * 3 + 1], vd.positions[i3 * 3 + 2]);

            const intersectionInfo = ray.intersectsTriangle(v1, v2, v3);

            if (intersectionInfo && intersectionInfo.distance > 0) {
                intersections.push(intersectionInfo);
            }
        }
        return intersections;
    }

    private rayIntersects(ray: Ray): boolean {
        const vd = this.vertexData;
        const iLen = vd.indices.length;

        for (let i = 0; i < iLen; i += 3) {
            const i1 = vd.indices[i];
            const i2 = vd.indices[i + 1];
            const i3 = vd.indices[i + 2];

            v1.set(vd.positions[i1 * 3], vd.positions[i1 * 3 + 1], vd.positions[i1 * 3 + 2]);
            v2.set(vd.positions[i2 * 3], vd.positions[i2 * 3 + 1], vd.positions[i2 * 3 + 2]);
            v3.set(vd.positions[i3 * 3], vd.positions[i3 * 3 + 1], vd.positions[i3 * 3 + 2]);

            const intersectionInfo = ray.intersectsTriangle(v1, v2, v3);

            if (intersectionInfo && intersectionInfo.distance > 0) {
                return true;
            }
        }
        return false;
    }

    private intersectTriangleNoOp(
        ray: Ray,
        vertex1: Vector3,
        vertex2: Vector3,
        vertex3: Vector3,
        triangleIndex: number,
        t: Matrix,
        dstIntersections: Intersection[]
        // eslint-disable-next-line @typescript-eslint/no-empty-function
    ): void {}

    private intersectTriangle(
        ray: Ray,
        vertex1: Vector3,
        vertex2: Vector3,
        vertex3: Vector3,
        triangleIndex: number,
        t: Matrix,
        dstIntersections: Intersection[]
    ): void {
        const intersectionInfo = ray.intersectsTriangle(vertex1, vertex2, vertex3);
        // Filter out backface/behind-origin hits and self-intersections (distance <= epsilon)
        if (intersectionInfo && intersectionInfo.distance > 1e-6) {
            const intersectionPoint = this.barycentricToPoint(
                vertex1,
                vertex2,
                vertex3,
                intersectionInfo.bu!,
                intersectionInfo.bv!
            );

            const vd = this.vertexData;
            let packedNormal: number;
            if (vd.normalType === NormalType.PerFace) {
                packedNormal = vd.packedNormals[triangleIndex];
            } else {
                const firstVertexIndex = vd.indices[triangleIndex * 3];
                packedNormal = vd.packedNormals[firstVertexIndex];
            }
            const dn = decodePackedNormal[packedNormal];
            const objectSpaceNormal = BimIfcMesh._tmp.normal.set(dn.x, dn.y, dn.z);

            const position = Vector3.TransformCoordinates(intersectionPoint, t);
            const rayOriginBjsSpace = Vector3.TransformCoordinatesToRef(ray.origin, t, v1);

            dstIntersections.push({
                position: position,
                normal: Vector3.TransformNormal(objectSpaceNormal, t).normalize(),
                distance: Vector3.Distance(position, rayOriginBjsSpace)
            });
        }
    }
}
