import { GeometryTools } from './create-geometry';
import {
    Mesh,
    Ray,
    Vector3,
    LinesMesh,
    Color4,
    Observer,
    Nullable,
    Camera,
    TransformNode,
    PointerEventTypes,
    PointerInfo
} from '../loader/babylonjs-import';
import { BimApi } from '../BimApi';
import { Intersection } from '../loader/bim-format-types';
import { CoordinateTracker, TrackCoordinate2D, TrackCoordinate3D } from '../tools/CoordinateTracker';

import {
    PickOptionType,
    PredefinedCanvasPosition,
    PickOptionCanvas,
    PickOptionRay,
    PickOption
} from '../loader/Selectables';
import { TwinfinityCameraStateSnapshot } from '../camera/TwinfinityCameraStateSnapshot';

export interface LabelUpdateEventData {
    readonly start: Vector3;
    readonly end: Vector3;
    readonly length: number;
}

export interface LabelUpdateHandler {
    (screenTrackCoordinate: TrackCoordinate2D, eventData: LabelUpdateEventData): void;
}

/** Type of laser tool */
enum LaserToolType {
    Preview = 0,
    Instance = 1
}

class LaserTool implements TrackCoordinate3D, LabelUpdateEventData {
    private _originMesh: Mesh;
    private _laserMesh: LinesMesh;

    /**
     * X Coordinate of laser label position
     */
    public x: number;

    /**
     * Y Coordinate of laser label position
     */
    public y: number;

    /**
     * Z Coordinate of laser label position
     */
    public z: number;

    /**
     * If `undefined` or `true` and if {@link onLabelUpdate} is not undefined
     * then coordinate tracker will report 2d coordinates for this instance.
     */
    public isEnabled = false;

    private _length: number;

    public constructor(
        private readonly _geometryTool: GeometryTools,
        private readonly _parent: TransformNode,
        public readonly type: LaserToolType,
        public readonly id: string,
        public readonly start: Vector3,
        public readonly end: Vector3,
        private readonly _color: Color4,
        public readonly onLabelUpdate?: LabelUpdateHandler,
        private readonly _labelDistanceFromOrigin = 0.75
    ) {
        this.updateLabelPosition(this.start, this.end);

        // TODO Size is not useful in orthocamera!!!
        // TODO We should probably use hardware instancing for disc!! (or thin instances)
        this._length = Vector3.Distance(start, end);
        const size = Math.max(0.1, length / 100);

        const direction = end.subtract(start).normalize();

        if (type === LaserToolType.Instance) {
            this._originMesh = _geometryTool.createStartDisc(`${id}_origin`, start, direction, size, 8);
            this._originMesh.setParent(this._parent);
        } else if (type === LaserToolType.Preview) {
            this._originMesh = _geometryTool.createSphere(`${id}_origin`, start, 0.1);
            this._originMesh.useVertexColors = true;
            this._originMesh.setParent(this._parent);
        } else {
            throw new Error('Not implemented');
        }
        this._originMesh.isPickable = false;
        this._originMesh.setEnabled(false);
        this._laserMesh = _geometryTool.createLine(`${id}_laser`, this.start, this.end, _color);
        this._laserMesh.alwaysSelectAsActiveMesh = true;
        this._laserMesh.setEnabled(false);
        this._laserMesh.isPickable = false;
        this._laserMesh.setParent(this._parent);

        // If we dont have a onLabelUpdate method then we are not
        // subscribing to 2D coordinates.
        this.isEnabled = onLabelUpdate !== undefined;
    }

    /**
     * Ray representing the laser
     */
    public get length(): number {
        return this._length;
    }

    /**
     * If `true` then laser tool is enabled.
     */
    public get isVisible(): boolean {
        return this._originMesh.isEnabled();
    }

    /**
     * If `false` then laser tool is not visible.
     */
    public set isVisible(val: boolean) {
        this._originMesh.setEnabled(val);
        this._laserMesh.setEnabled(val);
    }

    /**
     * `false` when laser is not visible.
     */
    public get isLaserVisible(): boolean {
        return this._laserMesh.isEnabled();
    }

    public update(camera: Camera, pO: PickOption): void {
        this.isVisible = false; // Nothing starts out as visible.

        const laserOrigin = camera.twinfinity.pick(pO).hitInfo;
        const isLaserOriginVisible = laserOrigin.length > 0;
        let laserIntersection: Intersection[] = [];
        let doesLaserIntersectOtherObject = false;

        // When we perform a update we assume we will not get a valid
        // laser.
        this._length = 0;
        this.start.setAll(0);
        this.end.setAll(0);
        this.updateLabelPosition(this.start, this.end);
        this.isEnabled = false; // do not report 2d coordinates for "incomplete" laser

        if (isLaserOriginVisible) {
            const pickInfo = camera.twinfinity.pick({
                type: PickOptionType.Intersection,
                intersection: laserOrigin[0]
            });
            laserIntersection = pickInfo.hitInfo;
            doesLaserIntersectOtherObject = laserIntersection.length > 0;
            this.start.copyFrom(laserOrigin[0].position);
            this._originMesh.position.copyFrom(this.start);
            this._originMesh.overlayColor.g = 1;
            this._originMesh.overlayColor.b = 0;

            this._originMesh.setEnabled(true); // Show at least part of the laser

            // Only show laser part if it is actually a complet laser
            this._laserMesh.setEnabled(doesLaserIntersectOtherObject);
            if (doesLaserIntersectOtherObject) {
                this._length = laserIntersection[0].distance;
                this.updateLabelPosition(this.start, this.end);
                this.end.copyFrom(laserIntersection[0].position);

                this._geometryTool.updateLine(this._laserMesh, this.start, this.end, this._color);
                this.isEnabled = true; // Complete laser. Ok to report 2D coordinates for it.
            }
        } else {
            this._originMesh.overlayColor.g = 0;
            this._originMesh.overlayColor.b = 1;
            this.isEnabled = false; // Do not report coordinates for a "incomplete" laser
            this.isVisible = false; // Do not show visualization for incomplet laser.
        }
    }

    public dispose(): void {
        this._originMesh.dispose();
        this._laserMesh.dispose();
    }

    private updateLabelPosition(start: Vector3, end: Vector3): void {
        const labelPosition = Vector3.Lerp(start, end, this._labelDistanceFromOrigin);
        this.x = labelPosition.x;
        this.y = labelPosition.y;
        this.z = labelPosition.z;
    }
}

/**
 * Contains methods to add, remove and preview distance measurement.
 */
export class MeasureHandler {
    private static _currentId = 0;

    private static readonly _tmpPickOptionMousePosition: PickOptionCanvas = {
        type: PickOptionType.Canvas,
        position: PredefinedCanvasPosition.Mouse
    };

    private static readonly _tmpPickOptionRay: PickOptionRay = {
        type: PickOptionType.Ray,
        ray: new Ray(Vector3.Zero(), Vector3.Zero(), 0)
    };

    private static readonly _tmp = {
        defaultLaserColor: new Color4(1, 0, 0, 1)
    };

    private _coordinateTracker: CoordinateTracker<LaserTool>;
    private _geomTool: GeometryTools;
    private _pointerMoveObserver: Nullable<Observer<PointerInfo>> = null;
    private _laserToolPreview: LaserTool;
    private _laserToolRootNode: TransformNode;
    private _id: number;

    /**
     * Constructor
     * @param _bimApi BIM Core API.
     */
    constructor(private _bimApi: BimApi) {
        this._geomTool = new GeometryTools(_bimApi.viewer.scene);
        this._coordinateTracker = this._bimApi.createCoordinateTracker<LaserTool>();

        this._coordinateTracker.onUpdateObservable.add((tracked2D, eS) => {
            const laserToolInstance = tracked2D.trackedCoordinate;
            if (laserToolInstance.onLabelUpdate) {
                // Mark 2d coordinate as not visible if laser is not visible.
                (tracked2D.visible as boolean) = tracked2D.visible && laserToolInstance.isLaserVisible;
                laserToolInstance.onLabelUpdate(tracked2D, laserToolInstance);
            }
        });

        this._id = MeasureHandler._currentId++;
        this._laserToolRootNode = new TransformNode(`measureHandler_${this._id}`, _bimApi.viewer.scene);

        this._laserToolPreview = new LaserTool(
            this._geomTool,
            this._laserToolRootNode,
            LaserToolType.Preview,
            `preview_${this._id}`,
            Vector3.Zero(),
            Vector3.Zero(),
            MeasureHandler._tmp.defaultLaserColor
        );

        this._laserToolPreview.isVisible = false;
    }

    /**
     * `true` if {@link activateLaserPreview} has been called.
     * `false` if not called or if either {@link deactivateLaserPreview} or {@link dispose}
     * is called.
     */
    public get isLaserPreviewActive(): boolean {
        return !!this._pointerMoveObserver;
    }

    /**
     * This method adds a visual measurement to viewer from a intersection on the geometry.
     * @param id String for identifying a unique measurement.
     * @param laserOrigin The origin position of the measurement, including surface normal to project ray for finding endpoint of distance to measure.
     * @param labelDistanceFromOrigin Value between 0 and 1 to describe in percent distance from origin along the ray to use as label coordinate anchor. Default 0.75 (75%).
     * @param onLabelUpdate If this optional callback is provided it tracks the position calculated from labelDistanceFromOrigin and tells where the coordinate is in screen space.
     */
    public createWallLaser(
        id: unknown,
        laserOrigin: Intersection,
        labelDistanceFromOrigin = 0.75,
        onLabelUpdate?: LabelUpdateHandler
    ): Ray | undefined {
        // intersection test from picked point along geometry normal
        const laserIntersections = this._bimApi.selectables.pick({
            type: PickOptionType.Intersection,
            intersection: laserOrigin
        }).hitInfo;

        if (laserIntersections.length === 0) {
            return;
        }

        // get target position
        const laserIntersection = laserIntersections[0];
        const ray = new Ray(laserOrigin.position, laserOrigin.normal, laserIntersection.distance);
        const laserTool = new LaserTool(
            this._geomTool,
            this._laserToolRootNode,
            LaserToolType.Instance,
            `${this._id}_${id}`,
            laserOrigin.position,
            laserIntersection.position,
            MeasureHandler._tmp.defaultLaserColor,
            onLabelUpdate,
            labelDistanceFromOrigin
        );
        laserTool.isVisible = true;
        laserTool.isEnabled = true;
        this._coordinateTracker.track(laserTool, id); // Will trigger onLabelUpdate immediatlythis
        this._bimApi.viewer.wakeRenderLoop();
        return ray;
    }

    /**
     * Removes a measurement by its ID.
     * @param id of measurement to be removed.
     * @returns `true` if wall laser was removed. ´false´ if it did not exist.
     */
    public removeWallLaser(id: unknown): boolean {
        const lt = this._coordinateTracker.untrack(id); // Will trigger onLabelUpdate with state = Deleted
        lt?.dispose();
        return lt !== undefined;
    }

    /**
     * Gets the pointer positions on screen and finds where the geometry is hit.
     * From that intersection on the geometry it creates a preview laser measurement.
     * This method can be run for every frame or activated using {@link activateLaserPreview}.
     */
    public createOrUpdateLaserPreview(): Ray | undefined {
        // intersection test if object is under cursor
        const camera = this._bimApi.viewer.scene.activeCamera;
        if (!camera) {
            this._laserToolPreview.isEnabled = false;
            this._laserToolPreview.isVisible = false;
            return undefined;
        }

        this._laserToolPreview.update(camera, MeasureHandler._tmpPickOptionMousePosition);
    }

    /**
     * Creates and updates laser preview on canvas upon mouse move,
     * by means of {@link createOrUpdateLaserPreview}.
     */
    public activateLaserPreview(): void {
        this._laserToolPreview.isEnabled = true;
        this._laserToolPreview.isVisible = false;
        let cameraStateSnapShot: TwinfinityCameraStateSnapshot | undefined = undefined;
        this._pointerMoveObserver = this._bimApi.viewer.scene.onPointerObservable.add((eD, eS) => {
            const camera = this._bimApi.viewer.scene.activeCamera;
            const mouseRay = eD.pickInfo?.ray;
            if (!camera || !mouseRay) {
                return;
            }

            if (!cameraStateSnapShot) {
                cameraStateSnapShot = camera.twinfinity.stateSnapshot();
            }

            // Only perform lasertoolPreview.update() when camera is stationary
            // internal pickoperations creates laggy fps if executed while camera is actually
            // moving.
            if (cameraStateSnapShot.refresh()) {
                return;
            }

            const pickOption = MeasureHandler._tmpPickOptionRay;
            pickOption.ray = mouseRay;
            this._laserToolPreview.update(camera, pickOption);
        }, PointerEventTypes.POINTERMOVE);
        this._bimApi.viewer.wakeRenderLoop();
    }

    /**
     * Deactivates and removes laser preview on canvas upon mouse move.
     */
    public deactivateLaserPreview(): void {
        this._laserToolPreview.isEnabled = false;
        this._laserToolPreview.isVisible = false;
        if (this._pointerMoveObserver) {
            this._bimApi.viewer.scene.onPointerObservable.remove(this._pointerMoveObserver);
            this._pointerMoveObserver = null;
            this._bimApi.viewer.wakeRenderLoop();
        }
    }
    /**
     * Removes all measurements and deactivates laser preview.
     * Also removes coordinate trackers for labels if present.
     */
    public clear(): void {
        this.deactivateLaserPreview();
        if (this._coordinateTracker.size > 0) {
            for (const [, lt] of this._coordinateTracker.entries()) {
                lt.dispose();
            }
            this._coordinateTracker.clear();
            // Force a render in case render loop currently is stopped
            this._bimApi.viewer.wakeRenderLoop();
        }
    }

    /**
     * Removes all measurements and deactivates laser preview.
     * Also removes coordinate trackers for labels if present.
     * @deprecated Use {@link clear} instead.
     * The naming convention for this method is incorrect as it does not
     * actually dispose the object but clears it.
     */
    public dispose(): void {
        this.clear();
    }
}
