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

import {
    Vector3,
    Vector2,
    Matrix,
    Quaternion,
    Epsilon,
    Tools,
    Scene,
    TargetCamera,
    DeepImmutable,
    Observable
} from '../loader/babylonjs-import';

import { TwinfinityTargetCameraInputsManager } from './TwinfinityTargetCameraInputsManager';

/**
 * This represents a pivot target type of camera. It can be useful for navigating using only mouse and about a pivot position from mouse cursor pick input.
 */
export class PivotTargetCamera extends TargetCamera {
    private static readonly _tmp = {
        vectorA: Vector3.Zero(),
        vectorB: Vector3.Zero(),
        quaternionA: Quaternion.Zero(),
        quaternionB: Quaternion.Zero()
    };

    private static readonly _tmpVector3 = Vector3.Zero();

    private static readonly _maxLocalCameraSpeed = 0.15;

    private _rotationMatrix = Matrix.Identity();

    public readonly onMode = new Observable<PivotTargetCamera>();

    /**
     * Instantiates a Pivot Target Camera.
     * This represents a Pivot Target type of camera. It can be useful for navigating using only mouse and about a pivot position from mouse cursor pick input.
     * @param name Define the name of the camera in the scene
     * @param position Define the start position of the camera in the scene
     * @param scene Define the scene the camera belongs to
     */
    public constructor(name: string, position: Vector3, scene: Scene) {
        super(name, position, scene);

        const i = new TwinfinityTargetCameraInputsManager(this);
        i.addMouseRotation();
        i.addMousePan();
        i.addMouseZoom();
        i.addTouch();
        i.addKeyboard();
        this.inputs = i;

        // According to babylonjs docs camera.rotationQuaternion overrides
        // camera.rotation. Since we need camera to be free of gimbal lock
        // we must use quaternions for all camera rotation calculations.
        this.rotationQuaternion = Quaternion.Identity();
    }

    /**
     * Define the input manager associated with the camera.
     */
    public inputs: TwinfinityTargetCameraInputsManager;

    /**
     * Indicates whether camera is in topdown mode or not.
     */
    public topDownMode = false;

    /**
     * Attach the input controls to a specific dom element to get the input from.
     * @param noPreventDefault Defines whether event caught by the controls should call preventdefault() (https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault)
     */
    public attachControl(noPreventDefault?: boolean): void;
    /**
     * Attach the input controls to a specific dom element to get the input from.
     * @param ignored defines an ignored parameter kept for backward compatibility. If you want to define the source input element, you can set engine.inputElement before calling camera.attachControl
     * @param noPreventDefault Defines whether event caught by the controls should call preventdefault() (https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault)
     * BACK COMPAT SIGNATURE ONLY.
     */
    // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
    public attachControl(ignored: any, noPreventDefault?: boolean): void;
    /**
     * Attached controls to the current camera.
     * @param ignored defines an ignored parameter kept for backward compatibility. If you want to define the source input element, you can set engine.inputElement before calling camera.attachControl
     * @param noPreventDefault Defines whether event caught by the controls should call preventdefault() (https://developer.mozilla.org/en-US/docs/Web/API/Event/preventDefault)
     */
    // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
    public attachControl(ignored?: any, noPreventDefault?: boolean): void {
        // eslint-disable-next-line prefer-rest-params
        noPreventDefault = Tools.BackCompatCameraNoPreventDefault(arguments);
        this.inputs.attachElement(noPreventDefault);
    }

    /**
     * Detach the current controls from the specified dom element.
     */
    public detachControl(): void;
    /**
     * Detach the current controls from the specified dom element.
     * @param ignored defines an ignored parameter kept for backward compatibility. If you want to define the source input element, you can set engine.inputElement before calling camera.attachControl
     */
    // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
    public detachControl(ignored: any): void;
    /**
     * Detach the current controls from the specified dom element.
     * @param ignored defines an ignored parameter kept for backward compatibility. If you want to define the source input element, you can set engine.inputElement before calling camera.attachControl
     */
    // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
    public detachControl(ignored?: any): void {
        this.inputs.detachElement();

        this.cameraDirection = new Vector3(0, 0, 0);
        this.cameraRotation = new Vector2(0, 0);
    }

    /**
     * Destroy the camera and release the current resources hold by it.
     */
    public dispose(): void {
        this.inputs.clear();
        super.dispose();
    }

    /**
     * Gets the current object class name.
     * @return the class name
     */
    public getClassName(): string {
        return 'PivotTargetCamera';
    }

    /**
     * Override _checkInputs in targetCamera.
     * This function calculates movement, rotation and zoom and apply it to camera position and target.
     */
    public _checkInputs(): void {
        this.inputs.checkInputs();
        const needToRotate = this._decideIfCameraNeedsToRotate();
        const cameraPosition = this.position.clone();

        // Rotate (calculates the movements needed to preform rotation about pivot position from camera rotation delta)
        if (needToRotate) {
            const deltaCameraRotation = this.cameraRotation.clone();
            const inertia = Math.min(1, Math.max(0, this.inertia));
            if (inertia > 0) {
                // Keep N % of rotation for next frame. Ex: inertaion = 0.9 would keep 90 % of rotation for next frame
                this.cameraRotation.scaleInPlace(inertia);
                deltaCameraRotation.x -= this.cameraRotation.x;
                deltaCameraRotation.y -= this.cameraRotation.y;
                if (Math.abs(this.cameraRotation.x) < this.speed * Epsilon) {
                    this.cameraRotation.x = 0;
                }
                if (Math.abs(this.cameraRotation.y) < this.speed * Epsilon) {
                    this.cameraRotation.y = 0;
                }
            } else {
                this.cameraRotation.x = 0;
                this.cameraRotation.y = 0;
            }
            const deltaYaw = deltaCameraRotation.y;
            const deltaPitch = deltaCameraRotation.x;

            const deltaYawQuaternion = Quaternion.FromEulerAnglesToRef(
                0,
                deltaYaw,
                0,
                PivotTargetCamera._tmp.quaternionA
            );
            const deltaPitchQuaternion = Quaternion.FromEulerAnglesToRef(
                deltaPitch,
                0,
                0,
                PivotTargetCamera._tmp.quaternionB
            );
            if (this.twinfinity.isFreeLook) {
                // Freelook means first person perspective. Camera rotates around its own axes.
                // We must therefore also rotate the pivot target otherwise the pivot target and
                // pivot plane will no longer have same relative position to the camera forward (look) vector

                // First ensure that the pivot target target is rotated back to its original position
                // (by using inverse rotation matrix)
                this.invertedRotationMatrixToRef(this._rotationMatrix);
                const pivoTargetAboutCamera = this.twinfinity.pivot.target.subtractToRef(
                    this.position,
                    PivotTargetCamera._tmp.vectorA
                );
                this.transformVectorInPlace(this._rotationMatrix, pivoTargetAboutCamera);

                this.yawPitchToRef(deltaYawQuaternion, deltaPitchQuaternion, this.rotation, this._rotationMatrix);
                this.transformVectorInPlace(this._rotationMatrix, pivoTargetAboutCamera);
                pivoTargetAboutCamera.addInPlace(this.position);

                this.twinfinity.pivot.update({ target: pivoTargetAboutCamera });
            } else {
                const cameraPositionAboutPivot = cameraPosition.subtractToRef(
                    this.twinfinity.pivot.target,
                    PivotTargetCamera._tmp.vectorA
                );
                const targetPositionAboutPivot = this.getTarget().subtractToRef(
                    this.twinfinity.pivot.target,
                    PivotTargetCamera._tmp.vectorB
                );

                // Convert rotation quaternion to rotation Matrix
                this.invertedRotationMatrixToRef(this._rotationMatrix);

                this.transformVectorInPlace(this._rotationMatrix, cameraPositionAboutPivot);
                this.transformVectorInPlace(this._rotationMatrix, targetPositionAboutPivot);

                this.yawPitchToRef(deltaYawQuaternion, deltaPitchQuaternion, this.rotation, this._rotationMatrix);

                this.transformVectorInPlace(this._rotationMatrix, cameraPositionAboutPivot);
                this.transformVectorInPlace(this._rotationMatrix, targetPositionAboutPivot);

                const newPosition = cameraPositionAboutPivot.add(this.twinfinity.pivot.target);
                this.position.copyFrom(newPosition);
            }
        }

        this.onAfterCheckInputsObservable.notifyObservers(this);
    }

    private _decideIfCameraNeedsToRotate(): boolean {
        return (
            (this.inputs.isRotationEnabled && Math.abs(this.cameraRotation.x) > 0) ||
            Math.abs(this.cameraRotation.y) > 0
        );
    }

    private invertedRotationMatrixToRef(m: Matrix): Matrix {
        this.rotationQuaternion.toRotationMatrix(m);
        m.invert();
        return m;
    }

    private transformVectorInPlace(m: DeepImmutable<Matrix>, dst: Vector3): Vector3 {
        Vector3.TransformNormalFromFloatsToRef(dst.x, dst.y, dst.z, this._rotationMatrix, dst);
        return dst;
    }

    private yawPitchToRef(
        deltaYawQuaternion: Quaternion,
        deltaPitchQuaternion: Quaternion,
        dstEulerAngles: Vector3,
        dstRotationMatrix: Matrix
    ): Matrix {
        this.rotationQuaternion.copyFrom(deltaYawQuaternion.multiplyInPlace(this.rotationQuaternion));
        this.rotationQuaternion.multiplyInPlace(deltaPitchQuaternion);
        this.rotationQuaternion.toEulerAnglesToRef(dstEulerAngles);

        // Now rotate pivotarget to its new position and finally update the pivotplane
        // with the new pivot target.
        this.rotationQuaternion.toRotationMatrix(dstRotationMatrix);
        return dstRotationMatrix;
    }
}
