/**
 * @author qiao / https://github.com/qiao
 * @author mrdoob / http://mrdoob.com
 * @author alteredq / http://alteredqualia.com/
 * @author WestLangley / http://github.com/WestLangley
 * @author erich666 / http://erichaines.com
 * @author jcoffland / https://buildbotics.com/
 */

"use strict";

// This set of controls performs orbiting, dollying (zooming), and panning.
// Unlike TrackballControls, it maintains the "up" direction object.up
// (+Y by default).
//
//    Orbit - left mouse / touch: one-finger move
//    Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish
//    Pan - right mouse, or arrow keys / touch: two-finger move

const OrbitControls = function(object, domElement) {
    // internals
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const scope = this;

    const changeEvent = { type: "change" };
    const startEvent = { type: "start" };
    const endEvent = { type: "end" };

    const STATE = {
        NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY_PAN: 4
    };

    let state = STATE.NONE;
    const EPS = 0.000001;

    // current position in spherical coordinates
    const spherical = new THREE.Spherical();
    const sphericalDelta = new THREE.Spherical();

    let scale = 1;
    const panOffset = new THREE.Vector3();
    let zoomChanged = false;

    const rotateStart = new THREE.Vector2();
    const rotateEnd = new THREE.Vector2();
    const rotateDelta = new THREE.Vector2();

    const panStart = new THREE.Vector2();
    const panEnd = new THREE.Vector2();
    const panDelta = new THREE.Vector2();

    const dollyStart = new THREE.Vector2();
    const dollyEnd = new THREE.Vector2();
    const dollyDelta = new THREE.Vector2();

    this.object = object;
    this.domElement = domElement != undefined ? domElement : document;

    // Set to false to disable this control
    this.enabled = true;

    // "target" sets the location of focus, where the object orbits around
    this.target = new THREE.Vector3();

    // How far you can zoom in and out (OrthographicCamera only)
    this.minZoom = 0;
    this.maxZoom = Infinity;

    // How far you can orbit vertically, upper and lower limits.
    // Range is 0 to Math.PI radians.
    this.minPolarAngle = 0; // radians
    this.maxPolarAngle = Math.PI; // radians

    // How far you can orbit horizontally, upper and lower limits.
    // If set, must be a sub-interval of the interval [- Math.PI, Math.PI].
    this.minAzimuthAngle = -Infinity; // radians
    this.maxAzimuthAngle = Infinity; // radians

    // Set to true to enable damping (inertia)
    // If damping is enabled, call controls.update() in your animation loop
    this.enableDamping = false;
    this.dampingFactor = 0.25;

    // This option enables dollying in and out;
    // left as "zoom" for backwards compatibility.
    // Set to false to disable zooming
    this.enableZoom = true;
    this.zoomSpeed = 1.0;

    // Set to false to disable rotating
    this.enableRotate = true;
    this.rotateSpeed = 1.0;

    // Set to false to disable panning
    this.enablePan = true;
    this.panSpeed = 1.0;
    this.screenSpacePanning = false; // if true, pan in screen-space
    this.keyPanSpeed = 7.0; // pixels moved per arrow key push

    // Set to true to automatically rotate around the target
    // If auto-rotate is enabled, call controls.update() in your animation loop
    this.autoRotate = false;
    this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60

    // Set to false to disable use of the keys
    this.enableKeys = true;

    // The four arrow keys
    this.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 };

    // Mouse buttons
    this.mouseButtons = {
        ORBIT: THREE.MOUSE.LEFT, ZOOM: THREE.MOUSE.MIDDLE, PAN: THREE.MOUSE.RIGHT
    };

    // for reset
    this.target0 = this.target.clone();
    this.position0 = this.object.position.clone();
    this.zoom0 = this.object.zoom;

    // public methods
    this.getPolarAngle = function() {
        return spherical.phi;
    };

    this.getAzimuthalAngle = function() {
        return spherical.theta;
    };

    this.saveState = function() {
        scope.target0.copy(scope.target);
        scope.position0.copy(scope.object.position);
        scope.zoom0 = scope.object.zoom;
    };

    this.reset = function() {
        scope.target.copy(scope.target0);
        scope.object.position.copy(scope.position0);
        scope.object.zoom = scope.zoom0;
        scope.object.updateProjectionMatrix();

        scope.dispatchEvent(changeEvent);
        scope.update();

        state = STATE.NONE;
    };

    this.update = function() {
        const offset = new THREE.Vector3();

        // so camera.up is the orbit axis
        const quat = new THREE.Quaternion().setFromUnitVectors(object.up, new THREE.Vector3(0, 1, 0));
        const quatInverse = quat.clone().inverse();

        const lastPosition = new THREE.Vector3();
        const lastQuaternion = new THREE.Quaternion();

        return function update() {
            const position = scope.object.position;

            offset.copy(position).sub(scope.target);

            // rotate offset to "y-axis-is-up" space
            offset.applyQuaternion(quat);

            // angle from z-axis around y-axis
            spherical.setFromVector3(offset);

            if (scope.autoRotate && state == STATE.NONE) {
                rotateLeft(getAutoRotationAngle());
            }

            spherical.theta += sphericalDelta.theta;
            spherical.phi += sphericalDelta.phi;

            // restrict theta to be between desired limits
            spherical.theta = Math.max(scope.minAzimuthAngle, Math.min(scope.maxAzimuthAngle, spherical.theta));

            // restrict phi to be between desired limits
            spherical.phi = Math.max(scope.minPolarAngle, Math.min(scope.maxPolarAngle, spherical.phi));

            spherical.makeSafe();
            spherical.radius *= scale;

            // restrict radius to be between desired limits
            spherical.radius = Math.max(10, Math.min(scope.object.far * 0.8, spherical.radius));

            // move target to panned location
            scope.target.add(panOffset);

            offset.setFromSpherical(spherical);

            // rotate offset back to "camera-up-vector-is-up" space
            offset.applyQuaternion(quatInverse);

            position.copy(scope.target).add(offset);
            scope.object.lookAt(scope.target);

            if (scope.enableDamping) {
                sphericalDelta.theta *= (1 - scope.dampingFactor);
                sphericalDelta.phi *= (1 - scope.dampingFactor);
                panOffset.multiplyScalar(1 - scope.dampingFactor);

            } else {
                sphericalDelta.set(0, 0, 0);
                panOffset.set(0, 0, 0);
            }

            // update condition is:
            // min(camera displacement, camera rotation in radians)^2 > EPS
            // using small-angle approximation cos(x/2) = 1 - x^2 / 8
            if (zoomChanged
                || scale != 1
                || lastPosition.distanceToSquared(scope.object.position) > EPS
                || 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS) {

                scope.dispatchEvent(changeEvent);

                lastPosition.copy(scope.object.position);
                lastQuaternion.copy(scope.object.quaternion);
                zoomChanged = false;
                scale = 1;

                return true;
            }

            return false;
        };
    }();

    this.dispose = function() {
        scope.domElement.removeEventListener("contextmenu", onContextMenu, false);
        scope.domElement.removeEventListener("mousedown", onMouseDown, false);
        scope.domElement.removeEventListener("wheel", onMouseWheel, false);
        scope.domElement.removeEventListener("touchstart", onTouchStart, false);
        scope.domElement.removeEventListener("touchend", onTouchEnd, false);
        scope.domElement.removeEventListener("touchmove", onTouchMove, false);
        document.removeEventListener("mousemove", onMouseMove, false);
        document.removeEventListener("mouseup", onMouseUp, false);
        window.removeEventListener("keydown", onKeyDown, false);
    };

    function getAutoRotationAngle() {
        return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed;
    }

    function getZoomScale() {
        return Math.pow(0.95, scope.zoomSpeed);
    }

    function rotateLeft(angle) {
        sphericalDelta.theta -= angle;
    }

    function rotateUp(angle) {
        sphericalDelta.phi -= angle;
    }

    const panLeft = function() {
        const v = new THREE.Vector3();

        return function panLeft(distance, objectMatrix) {
            v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix
            v.multiplyScalar(-distance);
            panOffset.add(v);
        };
    }();

    const panUp = function() {
        const v = new THREE.Vector3();

        return function panUp(distance, objectMatrix) {
            if (scope.screenSpacePanning) {
                v.setFromMatrixColumn(objectMatrix, 1);
            } else {
                v.setFromMatrixColumn(objectMatrix, 0);
                v.crossVectors(scope.object.up, v);
            }

            v.multiplyScalar(distance);
            panOffset.add(v);
        };
    }();

    function unknownCamera() {
        console.warn("WARNING: OrbitControls.js encountered an unknown camera type - pan & zoom disabled.");
        scope.enablePan = false;
        scope.enableZoom = false;
    }

    // deltaX and deltaY are in pixels; right and down are positive
    const pan = function() {
        const offset = new THREE.Vector3();

        return function pan(deltaX, deltaY) {
            const element = scope.domElement === document
                ? scope.domElement.body
                : scope.domElement;

            if (scope.object.isPerspectiveCamera) {
                // perspective
                offset.copy(scope.object.position).sub(scope.target);
                let targetDistance = offset.length();

                // half of the fov is center to top of screen
                targetDistance *= Math.tan((scope.object.fov / 2) * Math.PI / 180.0);

                // we use only clientHeight here so aspect ratio does not distort speed
                panLeft(2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix);
                panUp(2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix);

            } else if (scope.object.isOrthographicCamera) {
                // orthographic
                panLeft(deltaX * (scope.object.right - scope.object.left) / scope.object.zoom / element.clientWidth, scope.object.matrix);
                panUp(deltaY * (scope.object.top - scope.object.bottom) / scope.object.zoom / element.clientHeight, scope.object.matrix);

            } else {
                unknownCamera();
            }
        };
    }();

    function dollyIn(dollyScale) {
        if (scope.object.isPerspectiveCamera) {
            scale /= dollyScale;
        } else if (scope.object.isOrthographicCamera) {
            scope.object.zoom =
        Math.max(scope.minZoom,
            Math.min(scope.maxZoom, scope.object.zoom * dollyScale));
            scope.object.updateProjectionMatrix();
            zoomChanged = true;

        } else {
            unknownCamera();
        }
    }

    function dollyOut(dollyScale) {
        if (scope.object.isPerspectiveCamera) {
            scale *= dollyScale;
        } else if (scope.object.isOrthographicCamera) {
            scope.object.zoom =
        Math.max(scope.minZoom,
            Math.min(scope.maxZoom, scope.object.zoom / dollyScale));
            scope.object.updateProjectionMatrix();
            zoomChanged = true;

        } else {
            unknownCamera();
        }
    }

    // event callbacks - update the object state
    function handleMouseDownRotate(event) {
        rotateStart.set(event.clientX, event.clientY);
    }

    function handleMouseDownDolly(event) {
        dollyStart.set(event.clientX, event.clientY);
    }

    function handleMouseDownPan(event) {
        panStart.set(event.clientX, event.clientY);
    }

    function handleMouseMoveRotate(event) {
        rotateEnd.set(event.clientX, event.clientY);
        rotateDelta.subVectors(rotateEnd, rotateStart)
            .multiplyScalar(scope.rotateSpeed);

        const element = scope.domElement === document ?
            scope.domElement.body : scope.domElement;

        // yes, height
        rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight);
        rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight);

        rotateStart.copy(rotateEnd);

        scope.update();
    }

    function handleMouseMoveDolly(event) {
        dollyEnd.set(event.clientX, event.clientY);
        dollyDelta.subVectors(dollyEnd, dollyStart);

        if (dollyDelta.y > 0) {
            dollyIn(getZoomScale());
        } else if (dollyDelta.y < 0) {
            dollyOut(getZoomScale());
        }

        dollyStart.copy(dollyEnd);
        scope.update();
    }

    function handleMouseMovePan(event) {
        panEnd.set(event.clientX, event.clientY);
        panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed);
        pan(panDelta.x, panDelta.y);
        panStart.copy(panEnd);
        scope.update();
    }

    function handleMouseWheel(event) {
        if (event.deltaY < 0) {
            dollyOut(getZoomScale());
        } else if (event.deltaY > 0) {
            dollyIn(getZoomScale());
        }

        scope.update();
    }

    function handleKeyDown(event) {
        switch (event.keyCode) {
            case scope.keys.UP:
                pan(0, scope.keyPanSpeed);
                scope.update();
                break;

            case scope.keys.BOTTOM:
                pan(0, -scope.keyPanSpeed);
                scope.update();
                break;

            case scope.keys.LEFT:
                pan(scope.keyPanSpeed, 0);
                scope.update();
                break;

            case scope.keys.RIGHT:
                pan(-scope.keyPanSpeed, 0);
                scope.update();
                break;
        }
    }

    function handleTouchStartRotate(event) {
        rotateStart.set(event.touches[0].pageX, event.touches[0].pageY);
    }

    function handleTouchStartDollyPan(event) {
        if (scope.enableZoom) {
            const dx = event.touches[0].pageX - event.touches[1].pageX;
            const dy = event.touches[0].pageY - event.touches[1].pageY;
            const distance = Math.sqrt(dx * dx + dy * dy);

            dollyStart.set(0, distance);
        }

        if (scope.enablePan) {
            const x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX);
            const y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY);
            panStart.set(x, y);
        }
    }

    function handleTouchMoveRotate(event) {
        rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY);
        rotateDelta.subVectors(rotateEnd, rotateStart)
            .multiplyScalar(scope.rotateSpeed);

        const element = scope.domElement === document ?
            scope.domElement.body : scope.domElement;

        // yes, height
        rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight);
        rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight);
        rotateStart.copy(rotateEnd);
        scope.update();
    }

    function handleTouchMoveDollyPan(event) {
        if (scope.enableZoom) {
            const dx = event.touches[0].pageX - event.touches[1].pageX;
            const dy = event.touches[0].pageY - event.touches[1].pageY;
            const distance = Math.sqrt(dx * dx + dy * dy);

            dollyEnd.set(0, distance);
            dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed));
            dollyIn(dollyDelta.y);
            dollyStart.copy(dollyEnd);
        }

        if (scope.enablePan) {
            const x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX);
            const y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY);

            panEnd.set(x, y);
            panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed);
            pan(panDelta.x, panDelta.y);
            panStart.copy(panEnd);
        }

        scope.update();
    }

    // event handlers - listen for events and reset state
    function onMouseDown(event) {
        if (!scope.enabled) {
            return;
        }

        event.preventDefault();

        switch (event.button) {
            case scope.mouseButtons.ORBIT:
                if (!scope.enableRotate) {
                    return;
                }
                handleMouseDownRotate(event);
                state = STATE.ROTATE;
                break;

            case scope.mouseButtons.ZOOM:
                if (!scope.enableZoom) {
                    return;
                }
                handleMouseDownDolly(event);
                state = STATE.DOLLY;
                break;

            case scope.mouseButtons.PAN:
                if (!scope.enablePan) {
                    return;
                }
                handleMouseDownPan(event);
                state = STATE.PAN;
                break;
        }

        if (state != STATE.NONE) {
            document.addEventListener("mousemove", onMouseMove, false);
            document.addEventListener("mouseup", onMouseUp, false);
            scope.dispatchEvent(startEvent);
        }
    }

    function onMouseMove(event) {
        if (!scope.enabled) {
            return;
        }

        event.preventDefault();

        switch (state) {
            case STATE.ROTATE:
                if (!scope.enableRotate) {
                    return;
                }
                handleMouseMoveRotate(event);
                break;

            case STATE.DOLLY:
                if (!scope.enableZoom) {
                    return;
                }
                handleMouseMoveDolly(event);
                break;

            case STATE.PAN:
                if (!scope.enablePan) {
                    return;
                }
                handleMouseMovePan(event);
                break;
        }
    }

    function onMouseUp() {
        if (!scope.enabled) {
            return;
        }

        document.removeEventListener("mousemove", onMouseMove, false);
        document.removeEventListener("mouseup", onMouseUp, false);
        scope.dispatchEvent(endEvent);
        state = STATE.NONE;
    }

    function onMouseWheel(event) {
        if (!scope.enabled || !scope.enableZoom ||
        (state != STATE.NONE && state != STATE.ROTATE)) {
            return;
        }

        event.preventDefault();
        event.stopPropagation();
        scope.dispatchEvent(startEvent);
        handleMouseWheel(event);
        scope.dispatchEvent(endEvent);
    }

    function onKeyDown(event) {
        if (!scope.enabled || !scope.enableKeys || !scope.enablePan) {
            return;
        }

        handleKeyDown(event);
    }

    function onTouchStart(event) {
        if (!scope.enabled) {
            return;
        }

        event.preventDefault();

        switch (event.touches.length) {
            case 1: // one-fingered touch: rotate
                if (!scope.enableRotate) {
                    return;
                }
                handleTouchStartRotate(event);
                state = STATE.TOUCH_ROTATE;
                break;

            case 2: // two-fingered touch: dolly-pan
                if (!scope.enableZoom && !scope.enablePan) {
                    return;
                }
                handleTouchStartDollyPan(event);
                state = STATE.TOUCH_DOLLY_PAN;
                break;

            default: state = STATE.NONE;
        }

        if (state != STATE.NONE) {
            scope.dispatchEvent(startEvent);
        }
    }

    function onTouchMove(event) {
        if (!scope.enabled) {
            return;
        }

        event.preventDefault();
        event.stopPropagation();

        switch (event.touches.length) {
            case 1: // one-fingered touch: rotate
                if (!scope.enableRotate) {
                    return;
                }
                if (state != STATE.TOUCH_ROTATE) {
                    return;
                } // is this needed?

                handleTouchMoveRotate(event);
                break;

            case 2: // two-fingered touch: dolly-pan
                if (!scope.enableZoom && !scope.enablePan) {
                    return;
                }
                if (state != STATE.TOUCH_DOLLY_PAN) {
                    return;
                } // is this needed?

                handleTouchMoveDollyPan(event);
                break;

            default: state = STATE.NONE;
        }
    }

    function onTouchEnd() {
        if (!scope.enabled) {
            return;
        }

        scope.dispatchEvent(endEvent);
        state = STATE.NONE;
    }

    function onContextMenu(event) {
        if (!scope.enabled) {
            return;
        }
        event.preventDefault();
    }

    scope.domElement.addEventListener("contextmenu", onContextMenu, false);
    scope.domElement.addEventListener("mousedown", onMouseDown, false);
    scope.domElement.addEventListener("wheel", onMouseWheel, false);
    scope.domElement.addEventListener("touchstart", onTouchStart, false);
    scope.domElement.addEventListener("touchend", onTouchEnd, false);
    scope.domElement.addEventListener("touchmove", onTouchMove, false);
    window.addEventListener("keydown", onKeyDown, false);

    this.update(); // force an update at start
};

OrbitControls.prototype = Object.create(THREE.EventDispatcher.prototype);
OrbitControls.prototype.constructor = OrbitControls;
module.exports = OrbitControls;
