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

import type { PerspectiveCamera } from 'three';

import { Euler, MathUtils, Quaternion, Vector2, Vector3 } from 'three';

import type Instance from '../core/Instance';

import { type InstanceEvents } from '../core/Instance';
import { isPerspectiveCamera } from '../utils/predicates';

// Note: we could use existing js controls (like
// https://github.com/mrdoob/js/blob/dev/examples/js/controls/FirstPersonControls.js) but
// including these controls in Giro3D allows use to integrate them tightly with Giro3D.  Especially
// the existing controls are expecting a continuous update loop while we have a pausable one (so our
// controls use .notifyChange when needed)

interface State {
    rotateX: number;
    rotateY: number;
}

const tmpVec2 = new Vector2();

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function limitRotation(camera: PerspectiveCamera, rot: number, verticalFOV: number): number {
    // Limit vertical rotation (look up/down) to make sure the user cannot see
    // outside of the cone defined by verticalFOV
    // const limit = MathUtils.degToRad(verticalFOV - camera.fov * 0.5) * 0.5;
    const limit = Math.PI * 0.5 - 0.01;
    return MathUtils.clamp(rot, -limit, limit);
}

function applyRotation(instance: Instance, camera: PerspectiveCamera, state: State): void {
    camera.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), camera.up);

    camera.rotateY(state.rotateY);
    camera.rotateX(state.rotateX);

    instance.notifyChange(instance.view.camera);
}

type MoveMethod = 'translateX' | 'translateY' | 'translateZ';

const MOVEMENTS: Record<number, { method: MoveMethod; sign: number }> = {
    38: { method: 'translateZ', sign: -1 }, // FORWARD: up key
    40: { method: 'translateZ', sign: 1 }, // BACKWARD: down key
    37: { method: 'translateX', sign: -1 }, // STRAFE_LEFT: left key
    39: { method: 'translateX', sign: 1 }, // STRAFE_RIGHT: right key
    33: { method: 'translateY', sign: 1 }, // UP: PageUp key
    34: { method: 'translateY', sign: -1 }, // DOWN: PageDown key
};

type Movement = (typeof MOVEMENTS)[keyof typeof MOVEMENTS];

export interface FirstPersonControlsOptions {
    /* whether or not to focus the renderer domElement on click */
    focusOnClick: boolean;
    /** whether or not to focus when the mouse is over the domElement */
    focusOnMouseOver: boolean;
    /** if \> 0, pressing the arrow keys will move the camera */
    moveSpeed: number;
    /**
     * define the max visible vertical angle of the scene in degrees
     *
     * @defaultValue  180
     */
    verticalFOV: number;
    /**
     * alternative way to specify the max vertical angle when using a panorama.
     * You can specify the panorama width/height ratio and the verticalFOV
     * will be computed automatically
     */
    panoramaRatio?: number;
    /**
     * if true, the controls will not self listen to mouse/key events.
     * You'll have to manually forward the events to the appropriate
     * functions: onMouseDown, onMouseMove, onMouseUp, onKeyUp, onKeyDown and onMouseWheel.
     */
    disableEventListeners: boolean;
    /** the minimal height of the instance camera */
    minHeight?: number;
    /** the maximal height of the instance camera */
    maxHeight?: number;
}

export class FirstPersonControls {
    public readonly options: FirstPersonControlsOptions = {
        moveSpeed: 10,
        verticalFOV: 180,
        focusOnClick: false,
        focusOnMouseOver: false,
        disableEventListeners: false,
    };

    private readonly _state: State;
    private readonly _instance: Instance;
    private readonly _camera: PerspectiveCamera;
    private readonly _moves: Set<Movement>;

    private _isMouseDown: boolean;
    private _mouseDown = new Vector2();
    private _stateOnMouseDown?: State;

    public enabled: boolean;

    /**
     * @param instance - the Giro3D instance to control
     * @param options - additional options
     */
    public constructor(instance: Instance, options: Partial<FirstPersonControlsOptions> = {}) {
        if (!isPerspectiveCamera(instance.view.camera)) {
            throw new Error('this control only supports perspective cameras');
        }
        this._camera = instance.view.camera;
        this._instance = instance;
        this.enabled = true;
        this._moves = new Set();
        if (options.panoramaRatio != null) {
            const radius = (options.panoramaRatio * 200) / (2 * Math.PI);
            options.verticalFOV =
                options.panoramaRatio === 2
                    ? 180
                    : MathUtils.radToDeg(2 * Math.atan(200 / (2 * radius)));
        }

        this.options.verticalFOV = options.verticalFOV ?? this.options.verticalFOV;
        this.options.minHeight = options.minHeight ?? this.options.minHeight;
        this.options.maxHeight = options.maxHeight ?? this.options.maxHeight;

        // backward or forward move speed in m/s
        this.options.moveSpeed = options.moveSpeed ?? this.options.moveSpeed;

        this._isMouseDown = false;

        this._state = {
            rotateX: 0,
            rotateY: 0,
        };

        this.reset();

        const domElement = instance.domElement;
        if (options.disableEventListeners !== true) {
            domElement.addEventListener('mousedown', this.onMouseDown.bind(this), false);
            domElement.addEventListener('touchstart', this.onTouchStart.bind(this), false);
            domElement.addEventListener('mousemove', this.onMouseMove.bind(this), false);
            domElement.addEventListener('touchmove', this.onTouchMove.bind(this), false);
            domElement.addEventListener('mouseup', this.onMouseUp.bind(this), false);
            domElement.addEventListener('touchend', this.onTouchEnd.bind(this), false);
            domElement.addEventListener('keyup', this.onKeyUp.bind(this), true);
            domElement.addEventListener('keydown', this.onKeyDown.bind(this), true);
            domElement.addEventListener('wheel', this.onMouseWheel.bind(this), false);
        }

        this._instance.addEventListener('after-camera-update', this.update.bind(this));

        // focus policy
        if (options.focusOnMouseOver === true) {
            domElement.addEventListener('mouseover', () => domElement.focus());
        }
        if (options.focusOnClick === true) {
            domElement.addEventListener('click', () => domElement.focus());
        }
    }

    public isUserInteracting(): boolean {
        return this._moves.size !== 0 || this._isMouseDown;
    }

    /**
     * Resets the controls internal state to match the camera' state.
     * This must be called when manually modifying the camera's position or rotation.
     *
     * @param preserveRotationOnX - if true, the look up/down rotation will
     * not be copied from the camera
     */
    public reset(preserveRotationOnX = false): void {
        // Compute the correct init state, given the calculus in applyRotation:
        // cam.quaternion = q * r
        // => r = invert(q) * cam.quaterion
        // q is the quaternion derived from the up vector
        const q = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), this._camera.up);
        q.invert();
        // compute r
        const r = this._camera.quaternion.clone().premultiply(q);
        // tranform it to euler
        const e = new Euler(0, 0, 0, 'YXZ').setFromQuaternion(r);

        if (!preserveRotationOnX) {
            this._state.rotateX = e.x;
        }
        this._state.rotateY = e.y;
    }

    /**
     * Updates the camera position / rotation based on occured input events.
     * This is done automatically when needed but can also be done if needed.
     *
     * @param event - Event
     * @param force - set to true if you want to force the update, even if it
     * appears unneeded.
     */
    public update(event: InstanceEvents['after-camera-update'], force = false): void {
        if (!this.enabled) {
            return;
        }
        // dt will not be relevant when we just started rendering, we consider a 1-frame move in
        // this case
        const dt = event.updateLoopRestarted ? 16 : event.dt;

        for (const move of this._moves) {
            const distance = (move.sign * this.options.moveSpeed * dt) / 1000;
            if (move.method === 'translateY') {
                this._camera.position.addScaledVector(this._camera.up, distance);
            } else {
                this._camera[move.method](distance);
            }
        }

        if (this.options.minHeight != null && this._camera.position.z < this.options.minHeight) {
            this._camera.position.z = this.options.minHeight;
        } else if (
            this.options.maxHeight != null &&
            this._camera.position.z > this.options.maxHeight
        ) {
            this._camera.position.z = this.options.maxHeight;
        }

        if (this._isMouseDown === true || force === true) {
            applyRotation(this._instance, this._camera, this._state);
        }

        if (this._moves.size > 0) {
            this._instance.notifyChange(this._instance.view.camera);
        }
    }

    private onInteractionStart(event: MouseEvent | TouchEvent): void {
        if (!this.enabled) {
            return;
        }

        event.preventDefault();
        this._isMouseDown = true;

        const coords = this._instance.eventToCanvasCoords(event, tmpVec2);
        this._mouseDown.copy(coords);

        this._stateOnMouseDown = this.snapshot();
    }

    private onMouseDown(event: MouseEvent): void {
        if (event.button !== 0) {
            return;
        }

        this.onInteractionStart(event);
    }

    private onTouchStart(event: TouchEvent): void {
        this.onInteractionStart(event);
    }

    private snapshot(): State {
        return {
            ...this._state,
        };
    }

    private onMouseUp(event: MouseEvent): void {
        if (!this.enabled || event.button !== 0) {
            return;
        }
        this._isMouseDown = false;
    }

    private onTouchEnd(): void {
        if (!this.enabled) {
            return;
        }
        this._isMouseDown = false;
    }

    private onInteractionMove(event: MouseEvent | TouchEvent): void {
        if (!this.enabled) {
            return;
        }

        if (this._isMouseDown === true) {
            const cam = this._camera;

            // in rigor we have tan(theta) = tan(cameraFOV) * deltaH / H
            // (where deltaH is the vertical amount we moved, and H the renderer height)
            // we loosely approximate tan(x) by x
            const pxToAngleRatio = MathUtils.degToRad(cam.fov) / this._instance.engine.height;

            const { x, y } = this._instance.eventToCanvasCoords(event, tmpVec2);

            const { rotateX, rotateY } = this._stateOnMouseDown ?? { rotateX: 0, rotateY: 0 };

            const fov = this.options.verticalFOV;
            const mouse = this._mouseDown;

            // update state based on pointer movement
            this._state.rotateX = limitRotation(cam, (y - mouse.y) * pxToAngleRatio + rotateX, fov);
            this._state.rotateY = (x - mouse.x) * pxToAngleRatio + rotateY;

            applyRotation(this._instance, cam, this._state);
        }
    }

    private onMouseMove(event: MouseEvent): void {
        if (event.button !== 0) {
            return;
        }

        this.onInteractionMove(event);
    }

    private onTouchMove(event: TouchEvent): void {
        this.onInteractionMove(event);
    }

    private onMouseWheel(event: WheelEvent): void {
        if (!this.enabled) {
            return;
        }
        let delta = 0;
        if ('wheelDelta' in event && event.wheelDelta != null) {
            delta = -event.wheelDelta;
            // Firefox
        } else if (event.detail !== undefined) {
            delta = event.detail;
        }

        this._camera.fov = MathUtils.clamp(
            this._camera.fov + Math.sign(delta),
            10,
            Math.min(100, this.options.verticalFOV),
        );

        this._camera.updateProjectionMatrix();

        this._state.rotateX = limitRotation(
            this._camera,
            this._state.rotateX,
            this.options.verticalFOV,
        );

        applyRotation(this._instance, this._camera, this._state);
    }

    // Keyboard handling
    private onKeyUp(e: KeyboardEvent): void {
        if (!this.enabled) {
            return;
        }
        const move = MOVEMENTS[e.keyCode];
        if (move != null) {
            this._moves.delete(move);
            this._instance.notifyChange(undefined);
            e.preventDefault();
        }
    }

    private onKeyDown(e: KeyboardEvent): void {
        if (!this.enabled) {
            return;
        }
        const move = MOVEMENTS[e.keyCode];
        if (move != null) {
            this._moves.add(move);
            this._instance.notifyChange(undefined);
            e.preventDefault();
        }
    }
}

export default FirstPersonControls;
