import * as THREE from 'three';
import AnimationPlayer from "../Core/AnimationPlayer.js";
import Coordinates from "../Core/Geographic/Coordinates.js";
import { ellipsoidSizes } from "../Core/Math/Ellipsoid.js";
import CameraUtils from "../Utils/CameraUtils.js";
import StateControl from "./StateControl.js";
import { VIEW_EVENTS } from "../Core/View.js";

// private members
const EPS = 0.000001;
const direction = {
  up: new THREE.Vector2(0, 1),
  bottom: new THREE.Vector2(0, -1),
  left: new THREE.Vector2(1, 0),
  right: new THREE.Vector2(-1, 0)
};

// Orbit
const rotateStart = new THREE.Vector2();
const rotateEnd = new THREE.Vector2();
const rotateDelta = new THREE.Vector2();
const spherical = new THREE.Spherical(1.0, 0.01, 0);
const sphericalDelta = new THREE.Spherical(1.0, 0, 0);
let orbitScale = 1.0;

// Pan
const panStart = new THREE.Vector2();
const panEnd = new THREE.Vector2();
const panDelta = new THREE.Vector2();
const panOffset = new THREE.Vector3();

// Dolly
const dollyStart = new THREE.Vector2();
const dollyEnd = new THREE.Vector2();
const dollyDelta = new THREE.Vector2();
let dollyScale;

// Globe move
const moveAroundGlobe = new THREE.Quaternion();
const cameraTarget = new THREE.Object3D();
const coordCameraTarget = new Coordinates('EPSG:4978');
cameraTarget.matrixWorldInverse = new THREE.Matrix4();
const xyz = new Coordinates('EPSG:4978', 0, 0, 0);
const c = new Coordinates('EPSG:4326', 0, 0, 0);
// Position object on globe
function positionObject(newPosition, object) {
  xyz.setFromVector3(newPosition).as('EPSG:4326', c);
  object.position.copy(newPosition);
  object.lookAt(c.geodesicNormal.add(newPosition));
  object.rotateX(Math.PI * 0.5);
  object.updateMatrixWorld(true);
}

// Save the last time of mouse move for damping
let lastTimeMouseMove = 0;

// Animations and damping
let enableAnimation = true;
const dampingFactorDefault = 0.25;
const dampingMove = new THREE.Quaternion(0, 0, 0, 1);
const durationDampingMove = 120;
const durationDampingOrbital = 60;

// Pan Move
const panVector = new THREE.Vector3();

// Save last transformation
const lastPosition = new THREE.Vector3();
const lastQuaternion = new THREE.Quaternion();

// Tangent sphere to ellipsoid
const pickSphere = new THREE.Sphere();
const pickingPoint = new THREE.Vector3();

// Sphere intersection
const intersection = new THREE.Vector3();

// Set to true to enable target helper
const enableTargetHelper = false;
const helpers = {};
/**
 * Globe control pan event. Fires after camera pan
 * @event GlobeControls#pan-changed
 * @property target {GlobeControls} dispatched on controls
 * @property type {string} orientation-changed
 */
/**
 * Globe control orientation event. Fires when camera's orientation change
 * @event GlobeControls#orientation-changed
 * @property new {object}
 * @property new.tilt {number} the new value of the tilt of the camera
 * @property new.heading {number} the new value of the heading of the camera
 * @property previous {object}
 * @property previous.tilt {number} the previous value of the tilt of the camera
 * @property previous.heading {number} the previous value of the heading of the camera
 * @property target {GlobeControls} dispatched on controls
 * @property type {string} orientation-changed
 */
/**
 * Globe control range event. Fires when camera's range to target change
 * @event GlobeControls#range-changed
 * @property new {number} the new value of the range
 * @property previous {number} the previous value of the range
 * @property target {GlobeControls} dispatched on controls
 * @property type {string} range-changed
 */
/**
 * Globe control camera's target event. Fires when camera's target change
 * @event GlobeControls#camera-target-changed
 * @property new {object}
 * @property new {Coordinates} the new camera's target coordinates
 * @property previous {Coordinates} the previous camera's target coordinates
 * @property target {GlobeControls} dispatched on controls
 * @property type {string} camera-target-changed
 */

/**
 * globe controls events
 * @property PAN_CHANGED {string} Fires after camera pan
 * @property ORIENTATION_CHANGED {string} Fires when camera's orientation change
 * @property RANGE_CHANGED {string} Fires when camera's range to target change
 * @property CAMERA_TARGET_CHANGED {string} Fires when camera's target change
 */

export const CONTROL_EVENTS = {
  PAN_CHANGED: 'pan-changed',
  ORIENTATION_CHANGED: 'orientation-changed',
  RANGE_CHANGED: 'range-changed',
  CAMERA_TARGET_CHANGED: 'camera-target-changed'
};
const quaterPano = new THREE.Quaternion();
const quaterAxis = new THREE.Quaternion();
const axisX = new THREE.Vector3(1, 0, 0);
let minDistanceZ = Infinity;
const lastNormalizedIntersection = new THREE.Vector3();
const normalizedIntersection = new THREE.Vector3();
const raycaster = new THREE.Raycaster();
const targetPosition = new THREE.Vector3();
const pickedPosition = new THREE.Vector3();
const sphereCamera = new THREE.Sphere();
let previous;
/**
 * GlobeControls is a camera controller
 *
 * @class      GlobeControls
 * @param      {GlobeView}  view the view where the control will be used
 * @param      {CameraTransformOptions|Extent} placement   the {@link CameraTransformOptions} to apply to view's camera
 * or the extent it must display at initialisation, see {@link CameraTransformOptions} in {@link CameraUtils}.
 * @param      {object}  [options] An object with one or more configuration properties. Any property of GlobeControls
 * can be passed in this object.
 * @property      {number}  zoomFactor The factor the scale is multiplied by when dollying (zooming) in or
 * divided by when dollying out. Default is 2.
 * @property      {number}  rotateSpeed Speed camera rotation in orbit and panoramic mode. Default is 0.25.
 * @property      {number}  minDistance Minimum distance between ground and camera in meters (Perspective Camera only).
 * Default is 250.
 * @property      {number}  maxDistance Maximum distance between ground and camera in meters
 * (Perspective Camera only). Default is ellipsoid radius * 8.
 * @property      {number}  minZoom How far you can zoom in, in meters (Orthographic Camera only). Default is 0.
 * @property      {number}  maxZoom How far you can zoom out, in meters (Orthographic Camera only). Default
 * is Infinity.
 * @property      {number}  keyPanSpeed Number of pixels moved per push on array key. Default is 7.
 * @property      {number}  minPolarAngle Minimum vertical orbit angle (in degrees). Default is 0.5.
 * @property      {number}  maxPolarAngle Maximum vertical orbit angle (in degrees). Default is 86.
 * @property      {number}  minAzimuthAngle Minimum horizontal orbit angle (in degrees). If modified,
 * should be in [-180,0]. Default is -Infinity.
 * @property      {number}  maxAzimuthAngle Maximum horizontal orbit angle (in degrees). If modified,
 * should be in [0,180]. Default is Infinity.
 * @property      {boolean} handleCollision Handle collision between camera and ground or not, i.e. whether
 * you can zoom underground or not. Default is true.
 * @property      {boolean} enableDamping Enable damping or not (simulates the lag that a real camera
 * operator introduces while operating a heavy physical camera). Default is true.
 * @property      {boolean} dampingMoveFactor the damping move factor. Default is 0.25.
 */
class GlobeControls extends THREE.EventDispatcher {
  constructor(view, placement) {
    let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
    super();
    this.player = new AnimationPlayer();
    this.view = view;
    this.camera = view.camera3D;

    // State control
    this.states = new StateControl(this.view);

    // this.enabled property has moved to StateControl
    Object.defineProperty(this, 'enabled', {
      get: () => this.states.enabled,
      set: value => {
        console.warn('GlobeControls.enabled property is deprecated. Use StateControl.enabled instead ' + '- which you can access with GlobeControls.states.enabled.');
        this.states.enabled = value;
      }
    });

    // These options actually enables dollying in and out; left as "zoom" for
    // backwards compatibility
    if (options.zoomSpeed) {
      console.warn('Controls zoomSpeed parameter is deprecated. Use zoomFactor instead.');
      options.zoomFactor = options.zoomFactor || options.zoomSpeed;
    }
    this.zoomFactor = options.zoomFactor || 1.25;

    // Limits to how far you can dolly in and out ( PerspectiveCamera only )
    this.minDistance = options.minDistance || 250;
    this.maxDistance = options.maxDistance || ellipsoidSizes.x * 8.0;

    // Limits to how far you can zoom in and out ( OrthographicCamera only )
    this.minZoom = options.minZoom || 0;
    this.maxZoom = options.maxZoom || Infinity;

    // Set to true to disable this control
    this.rotateSpeed = options.rotateSpeed || 0.25;

    // Set to true to disable this control
    this.keyPanSpeed = options.keyPanSpeed || 7.0; // pixels moved per arrow key push

    // How far you can orbit vertically, upper and lower limits.
    // Range is 0 to Math.PI radians.
    // TODO Warning minPolarAngle = 0.01 -> it isn't possible to be perpendicular on Globe
    this.minPolarAngle = THREE.MathUtils.degToRad(options.minPolarAngle ?? 0.5);
    this.maxPolarAngle = THREE.MathUtils.degToRad(options.minPolarAngle ?? 86);

    // 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 = options.minAzimuthAngle ? THREE.MathUtils.degToRad(options.minAzimuthAngle) : -Infinity; // radians
    this.maxAzimuthAngle = options.maxAzimuthAngle ? THREE.MathUtils.degToRad(options.maxAzimuthAngle) : Infinity; // radians

    // Set collision options
    this.handleCollision = typeof options.handleCollision !== 'undefined' ? options.handleCollision : true;
    this.minDistanceCollision = 60;

    // this.enableKeys property has moved to StateControl
    Object.defineProperty(this, 'enableKeys', {
      get: () => this.states.enableKeys,
      set: value => {
        console.warn('GlobeControls.enableKeys property is deprecated. Use StateControl.enableKeys instead ' + '- which you can access with GlobeControls.states.enableKeys.');
        this.states.enableKeys = value;
      }
    });

    // Enable Damping
    this.enableDamping = options.enableDamping !== false;
    this.dampingMoveFactor = options.dampingMoveFactor != undefined ? options.dampingMoveFactor : dampingFactorDefault;
    this.startEvent = {
      type: 'start'
    };
    this.endEvent = {
      type: 'end'
    };
    // Update helper
    this.updateHelper = enableTargetHelper ? (position, helper) => {
      positionObject(position, helper);
      view.notifyChange(this.camera);
    } : function () {};
    this._onEndingMove = null;
    this._onTravel = this.travel.bind(this);
    this._onTouchStart = this.onTouchStart.bind(this);
    this._onTouchEnd = this.onTouchEnd.bind(this);
    this._onTouchMove = this.onTouchMove.bind(this);
    this._onStateChange = this.onStateChange.bind(this);
    this._onRotation = this.handleRotation.bind(this);
    this._onDrag = this.handleDrag.bind(this);
    this._onDolly = this.handleDolly.bind(this);
    this._onPan = this.handlePan.bind(this);
    this._onPanoramic = this.handlePanoramic.bind(this);
    this._onZoom = this.handleZoom.bind(this);
    this.states.addEventListener('state-changed', this._onStateChange, false);
    this.states.addEventListener(this.states.ORBIT._event, this._onRotation, false);
    this.states.addEventListener(this.states.MOVE_GLOBE._event, this._onDrag, false);
    this.states.addEventListener(this.states.DOLLY._event, this._onDolly, false);
    this.states.addEventListener(this.states.PAN._event, this._onPan, false);
    this.states.addEventListener(this.states.PANORAMIC._event, this._onPanoramic, false);
    this.states.addEventListener('zoom', this._onZoom, false);
    this.view.domElement.addEventListener('touchstart', this._onTouchStart, false);
    this.view.domElement.addEventListener('touchend', this._onTouchEnd, false);
    this.view.domElement.addEventListener('touchmove', this._onTouchMove, false);
    this.states.addEventListener(this.states.TRAVEL_IN._event, this._onTravel, false);
    this.states.addEventListener(this.states.TRAVEL_OUT._event, this._onTravel, false);
    view.scene.add(cameraTarget);
    if (enableTargetHelper) {
      cameraTarget.add(helpers.target);
      view.scene.add(helpers.picking);
    }
    if (placement.isExtent) {
      placement.center().as('EPSG:4978', xyz);
    } else {
      placement.coord.as('EPSG:4978', xyz);
      placement.tilt = placement.tilt || 89.5;
      placement.heading = placement.heading || 0;
    }
    positionObject(xyz, cameraTarget);
    this.lookAtCoordinate(placement, false);
    coordCameraTarget.crs = this.view.referenceCrs;
  }
  get dollyInScale() {
    return this.zoomFactor;
  }
  get dollyOutScale() {
    return 1 / this.zoomFactor;
  }
  get isPaused() {
    // TODO : also check if CameraUtils is performing an animation
    return this.states.currentState === this.states.NONE && !this.player.isPlaying();
  }
  onEndingMove(current) {
    if (this._onEndingMove) {
      this.player.removeEventListener('animation-stopped', this._onEndingMove);
      this._onEndingMove = null;
    }
    this.handlingEvent(current);
  }
  rotateLeft() {
    let angle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
    sphericalDelta.theta -= angle;
  }
  rotateUp() {
    let angle = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
    sphericalDelta.phi -= angle;
  }

  // pass in distance in world space to move left
  panLeft(distance) {
    const te = this.camera.matrix.elements;
    // get X column of matrix
    panOffset.fromArray(te);
    panOffset.multiplyScalar(-distance);
    panVector.add(panOffset);
  }

  // pass in distance in world space to move up
  panUp(distance) {
    const te = this.camera.matrix.elements;
    // get Y column of matrix
    panOffset.fromArray(te, 4);
    panOffset.multiplyScalar(distance);
    panVector.add(panOffset);
  }

  // pass in x,y of change desired in pixel space,
  // right and down are positive
  mouseToPan(deltaX, deltaY) {
    const gfx = this.view.mainLoop.gfxEngine;
    if (this.camera.isPerspectiveCamera) {
      let targetDistance = this.camera.position.distanceTo(this.getCameraTargetPosition());
      // half of the fov is center to top of screen
      targetDistance *= 2 * Math.tan(THREE.MathUtils.degToRad(this.camera.fov * 0.5));

      // we actually don't use screenWidth, since perspective camera is fixed to screen height
      this.panLeft(deltaX * targetDistance / gfx.width * this.camera.aspect);
      this.panUp(deltaY * targetDistance / gfx.height);
    } else if (this.camera.isOrthographicCamera) {
      // orthographic
      this.panLeft(deltaX * (this.camera.right - this.camera.left) / gfx.width);
      this.panUp(deltaY * (this.camera.top - this.camera.bottom) / gfx.height);
    }
  }
  dolly(delta) {
    if (delta === 0) {
      return;
    }
    dollyScale = delta > 0 ? this.dollyInScale : this.dollyOutScale;
    if (this.camera.isPerspectiveCamera) {
      orbitScale /= dollyScale;
    } else if (this.camera.isOrthographicCamera) {
      this.camera.zoom = THREE.MathUtils.clamp(this.camera.zoom * dollyScale, this.minZoom, this.maxZoom);
      this.camera.updateProjectionMatrix();
      this.view.notifyChange(this.camera);
    }
  }
  getMinDistanceCameraBoundingSphereObbsUp(tile) {
    if (tile.level > 10 && tile.children.length == 1 && tile.geometry) {
      const obb = tile.obb;
      sphereCamera.center.copy(this.camera.position);
      sphereCamera.radius = this.minDistanceCollision;
      if (obb.isSphereAboveXYBox(sphereCamera)) {
        minDistanceZ = Math.min(sphereCamera.center.z - obb.box3D.max.z, minDistanceZ);
      }
    }
  }
  update() {
    let state = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.states.currentState;
    // We compute distance between camera's bounding sphere and geometry's obb up face
    minDistanceZ = Infinity;
    if (this.handleCollision) {
      // We check distance to the ground/surface geometry
      // add minDistanceZ between camera's bounding and tiles's oriented bounding box (up face only)
      // Depending on the distance of the camera with obbs, we add a slowdown or constrain to the movement.
      // this constraint or deceleration is suitable for two types of movement MOVE_GLOBE and ORBIT.
      // This constraint or deceleration inversely proportional to the camera/obb distance
      if (this.view.tileLayer) {
        for (const tile of this.view.tileLayer.level0Nodes) {
          tile.traverse(this.getMinDistanceCameraBoundingSphereObbsUp.bind(this));
        }
      }
    }
    switch (state) {
      // MOVE_GLOBE Rotate globe with mouse
      case this.states.MOVE_GLOBE:
        if (minDistanceZ < 0) {
          cameraTarget.translateY(-minDistanceZ);
          this.camera.position.setLength(this.camera.position.length() - minDistanceZ);
        } else if (minDistanceZ < this.minDistanceCollision) {
          const translate = this.minDistanceCollision * (1.0 - minDistanceZ / this.minDistanceCollision);
          cameraTarget.translateY(translate);
          this.camera.position.setLength(this.camera.position.length() + translate);
        }
        lastNormalizedIntersection.copy(normalizedIntersection).applyQuaternion(moveAroundGlobe);
        cameraTarget.position.applyQuaternion(moveAroundGlobe);
        this.camera.position.applyQuaternion(moveAroundGlobe);
        break;
      // PAN Move camera in projection plan
      case this.states.PAN:
        this.camera.position.add(panVector);
        cameraTarget.position.add(panVector);
        break;
      // PANORAMIC Move target camera
      case this.states.PANORAMIC:
        {
          this.camera.worldToLocal(cameraTarget.position);
          const normal = this.camera.position.clone().normalize().applyQuaternion(this.camera.quaternion.clone().invert());
          quaterPano.setFromAxisAngle(normal, sphericalDelta.theta).multiply(quaterAxis.setFromAxisAngle(axisX, sphericalDelta.phi));
          cameraTarget.position.applyQuaternion(quaterPano);
          this.camera.localToWorld(cameraTarget.position);
          break;
        }
      // ZOOM/ORBIT Move Camera around the target camera
      default:
        {
          // get camera position in local space of target
          this.camera.position.applyMatrix4(cameraTarget.matrixWorldInverse);

          // angle from z-axis around y-axis
          if (sphericalDelta.theta || sphericalDelta.phi) {
            spherical.setFromVector3(this.camera.position);
          }
          // far underground
          const dynamicRadius = spherical.radius * Math.sin(this.minPolarAngle);
          const slowdownLimit = dynamicRadius * 8;
          const contraryLimit = dynamicRadius * 2;
          const minContraintPhi = -0.01;
          if (this.handleCollision) {
            if (minDistanceZ < slowdownLimit && minDistanceZ > contraryLimit && sphericalDelta.phi > 0) {
              // slowdown zone : slowdown sphericalDelta.phi
              const slowdownZone = slowdownLimit - contraryLimit;
              // the deeper the camera is in this zone, the bigger the factor is
              const slowdownFactor = 1 - (slowdownZone - (minDistanceZ - contraryLimit)) / slowdownZone;
              // apply slowdown factor on tilt mouvement
              sphericalDelta.phi *= slowdownFactor * slowdownFactor;
            } else if (minDistanceZ < contraryLimit && minDistanceZ > -contraryLimit && sphericalDelta.phi > minContraintPhi) {
              // contraint zone : contraint sphericalDelta.phi

              // calculation of the angle of rotation which allows to leave this zone
              let contraryPhi = -Math.asin((contraryLimit - minDistanceZ) * 0.25 / spherical.radius);
              // clamp contraryPhi to make a less brutal exit
              contraryPhi = THREE.MathUtils.clamp(contraryPhi, minContraintPhi, 0);
              // the deeper the camera is in this zone, the bigger the factor is
              const contraryFactor = 1 - (contraryLimit - minDistanceZ) / (2 * contraryLimit);
              sphericalDelta.phi = THREE.MathUtils.lerp(sphericalDelta.phi, contraryPhi, contraryFactor);
              minDistanceZ -= Math.sin(sphericalDelta.phi) * spherical.radius;
            }
          }
          spherical.theta += sphericalDelta.theta;
          spherical.phi += sphericalDelta.phi;

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

          // restrict spherical.phi to be between desired limits
          spherical.phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, spherical.phi));
          spherical.radius = this.camera.position.length() * orbitScale;

          // restrict spherical.phi to be betwee EPS and PI-EPS
          spherical.makeSafe();

          // restrict radius to be between desired limits
          spherical.radius = Math.max(this.minDistance, Math.min(this.maxDistance, spherical.radius));
          this.camera.position.setFromSpherical(spherical);

          // if camera is underground, so move up camera
          if (minDistanceZ < 0) {
            this.camera.position.y -= minDistanceZ;
            spherical.setFromVector3(this.camera.position);
            sphericalDelta.phi = 0;
          }
          cameraTarget.localToWorld(this.camera.position);
        }
    }
    this.camera.up.copy(cameraTarget.position).normalize();
    this.camera.lookAt(cameraTarget.position);
    if (!this.enableDamping) {
      sphericalDelta.theta = 0;
      sphericalDelta.phi = 0;
      moveAroundGlobe.set(0, 0, 0, 1);
    } else {
      sphericalDelta.theta *= 1 - dampingFactorDefault;
      sphericalDelta.phi *= 1 - dampingFactorDefault;
      moveAroundGlobe.slerp(dampingMove, this.dampingMoveFactor * 0.2);
    }
    orbitScale = 1;
    panVector.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 (lastPosition.distanceToSquared(this.camera.position) > EPS || 8 * (1 - lastQuaternion.dot(this.camera.quaternion)) > EPS) {
      this.view.notifyChange(this.camera);
      lastPosition.copy(this.camera.position);
      lastQuaternion.copy(this.camera.quaternion);
    }
    // Launch animationdamping if mouse stops these movements
    if (this.enableDamping && state === this.states.ORBIT && this.player.isStopped() && (sphericalDelta.theta > EPS || sphericalDelta.phi > EPS)) {
      this.player.setCallback(() => {
        this.update(this.states.ORBIT);
      });
      this.player.playLater(durationDampingOrbital, 2);
    }
    this.view.dispatchEvent({
      type: VIEW_EVENTS.CAMERA_MOVED,
      coord: coordCameraTarget.setFromVector3(cameraTarget.position),
      range: spherical.radius,
      heading: -THREE.MathUtils.radToDeg(spherical.theta),
      tilt: 90 - THREE.MathUtils.radToDeg(spherical.phi)
    });
  }
  onStateChange(event) {
    // If the state changed to NONE, end the movement associated to the previous state.
    if (this.states.currentState === this.states.NONE) {
      this.handleEndMovement(event);
      return;
    }

    // Stop CameraUtils ongoing animations, which can for instance be triggered with `this.travel` or
    // `this.lookAtCoordinate` methods.
    CameraUtils.stop(this.view, this.camera);

    // Dispatch events which specify if changes occurred in camera transform options.
    this.onEndingMove();

    // Stop eventual damping movement.
    this.player.stop();

    // Update camera transform options.
    this.updateTarget();
    previous = CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, pickedPosition);

    // Initialize rotation and panoramic movements.
    rotateStart.copy(event.viewCoords);

    // Initialize drag movement.
    if (this.view.getPickingPositionFromDepth(event.viewCoords, pickingPoint)) {
      pickSphere.radius = pickingPoint.length();
      lastNormalizedIntersection.copy(pickingPoint).normalize();
      this.updateHelper(pickingPoint, helpers.picking);
    }

    // Initialize dolly movement.
    dollyStart.copy(event.viewCoords);

    // Initialize pan movement.
    panStart.copy(event.viewCoords);
  }
  handleRotation(event) {
    // Stop player if needed. Player can be playing while moving mouse in the case of rotation. This is due to the
    // fact that a damping move can occur while rotating (without the need of releasing the mouse button)
    this.player.stop();
    this.handlePanoramic(event);
  }
  handleDrag(event) {
    const normalized = this.view.viewToNormalizedCoords(event.viewCoords);

    // An updateMatrixWorld on the camera prevents camera jittering when moving globe on a zoomed out view, with
    // devtools open in web browser.
    this.camera.updateMatrixWorld();
    raycaster.setFromCamera(normalized, this.camera);

    // If there's intersection then move globe else we stop the move
    if (raycaster.ray.intersectSphere(pickSphere, intersection)) {
      normalizedIntersection.copy(intersection).normalize();
      moveAroundGlobe.setFromUnitVectors(normalizedIntersection, lastNormalizedIntersection);
      lastTimeMouseMove = Date.now();
      this.update();
    } else {
      this.states.onPointerUp();
    }
  }
  handleDolly(event) {
    dollyEnd.copy(event.viewCoords);
    dollyDelta.subVectors(dollyEnd, dollyStart);
    this.dolly(-dollyDelta.y);
    dollyStart.copy(dollyEnd);
    this.update();
  }
  handlePan(event) {
    if (event.viewCoords) {
      panEnd.copy(event.viewCoords);
      panDelta.subVectors(panEnd, panStart);
      panStart.copy(panEnd);
    } else if (event.direction) {
      panDelta.copy(direction[event.direction]).multiplyScalar(this.keyPanSpeed);
    }
    this.mouseToPan(panDelta.x, panDelta.y);
    this.update(this.states.PAN);
  }
  handlePanoramic(event) {
    rotateEnd.copy(event.viewCoords);
    rotateDelta.subVectors(rotateEnd, rotateStart);
    const gfx = this.view.mainLoop.gfxEngine;
    sphericalDelta.theta -= 2 * Math.PI * rotateDelta.x / gfx.width * this.rotateSpeed;
    // rotating up and down along whole screen attempts to go 360, but limited to 180
    sphericalDelta.phi -= 2 * Math.PI * rotateDelta.y / gfx.height * this.rotateSpeed;
    rotateStart.copy(rotateEnd);
    this.update();
  }
  handleEndMovement() {
    let event = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
    this.dispatchEvent(this.endEvent);
    this.player.stop();

    // Launch damping movement for :
    //      * this.states.ORBIT
    //      * this.states.MOVE_GLOBE
    if (this.enableDamping) {
      if (event.previous === this.states.ORBIT && (sphericalDelta.theta > EPS || sphericalDelta.phi > EPS)) {
        this.player.setCallback(() => {
          this.update(this.states.ORBIT);
        });
        this.player.play(durationDampingOrbital);
        this._onEndingMove = () => this.onEndingMove();
        this.player.addEventListener('animation-stopped', this._onEndingMove);
      } else if (event.previous === this.states.MOVE_GLOBE && Date.now() - lastTimeMouseMove < 50) {
        this.player.setCallback(() => {
          this.update(this.states.MOVE_GLOBE);
        });
        // animation since mouse up event occurs less than 50ms after the last mouse move
        this.player.play(durationDampingMove);
        this._onEndingMove = () => this.onEndingMove();
        this.player.addEventListener('animation-stopped', this._onEndingMove);
      } else {
        this.onEndingMove();
      }
    } else {
      this.onEndingMove();
    }
  }
  updateTarget() {
    // Check if the middle of the screen is on the globe (to prevent having a dark-screen bug if outside the globe)
    if (this.view.getPickingPositionFromDepth(null, pickedPosition)) {
      // Update camera's target position
      const distance = !isNaN(pickedPosition.x) ? this.camera.position.distanceTo(pickedPosition) : 100;
      targetPosition.set(0, 0, -distance);
      this.camera.localToWorld(targetPosition);

      // set new camera target on globe
      positionObject(targetPosition, cameraTarget);
      cameraTarget.matrixWorldInverse.copy(cameraTarget.matrixWorld).invert();
      targetPosition.copy(this.camera.position);
      targetPosition.applyMatrix4(cameraTarget.matrixWorldInverse);
      spherical.setFromVector3(targetPosition);
    }
  }
  handlingEvent(current) {
    current = current || CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera);
    const diff = CameraUtils.getDiffParams(previous, current);
    if (diff) {
      if (diff.range) {
        this.dispatchEvent({
          type: CONTROL_EVENTS.RANGE_CHANGED,
          previous: diff.range.previous,
          new: diff.range.new
        });
      }
      if (diff.coord) {
        this.dispatchEvent({
          type: CONTROL_EVENTS.CAMERA_TARGET_CHANGED,
          previous: diff.coord.previous,
          new: diff.coord.new
        });
      }
      if (diff.tilt || diff.heading) {
        const event = {
          type: CONTROL_EVENTS.ORIENTATION_CHANGED
        };
        if (diff.tilt) {
          event.previous = {
            tilt: diff.tilt.previous
          };
          event.new = {
            tilt: diff.tilt.new
          };
        }
        if (diff.heading) {
          event.previous = event.previous || {};
          event.new = event.new || {};
          event.new.heading = diff.heading.new;
          event.previous.heading = diff.heading.previous;
        }
        this.dispatchEvent(event);
      }
    }
  }
  travel(event) {
    this.player.stop();
    const point = this.view.getPickingPositionFromDepth(event.viewCoords);
    const range = this.getRange(point);
    if (point && range > this.minDistance) {
      return this.lookAtCoordinate({
        coord: new Coordinates('EPSG:4978', point),
        range: range * (event.direction === 'out' ? 1 / 0.6 : 0.6),
        time: 1500
      });
    }
  }
  handleZoom(event) {
    this.player.stop();
    CameraUtils.stop(this.view, this.camera);
    this.updateTarget();
    const delta = -event.delta;
    this.dolly(delta);
    const previousRange = this.getRange(pickedPosition);
    this.update();
    const newRange = this.getRange(pickedPosition);
    if (Math.abs(newRange - previousRange) / previousRange > 0.001) {
      this.dispatchEvent({
        type: CONTROL_EVENTS.RANGE_CHANGED,
        previous: previousRange,
        new: newRange
      });
    }
    this.dispatchEvent(this.startEvent);
    this.dispatchEvent(this.endEvent);
  }
  onTouchStart(event) {
    // CameraUtils.stop(view);
    this.player.stop();
    // TODO : this.states.enabled check should be removed when moving touch events management to StateControl
    if (this.states.enabled === false) {
      return;
    }
    this.state = this.states.touchToState(event.touches.length);
    this.updateTarget();
    if (this.state !== this.states.NONE) {
      switch (this.state) {
        case this.states.MOVE_GLOBE:
          {
            const coords = this.view.eventToViewCoords(event);
            if (this.view.getPickingPositionFromDepth(coords, pickingPoint)) {
              pickSphere.radius = pickingPoint.length();
              lastNormalizedIntersection.copy(pickingPoint).normalize();
              this.updateHelper(pickingPoint, helpers.picking);
            } else {
              this.state = this.states.NONE;
            }
            break;
          }
        case this.states.ORBIT:
        case this.states.DOLLY:
          {
            const x = event.touches[0].pageX;
            const y = event.touches[0].pageY;
            const dx = x - event.touches[1].pageX;
            const dy = y - event.touches[1].pageY;
            const distance = Math.sqrt(dx * dx + dy * dy);
            dollyStart.set(0, distance);
            rotateStart.set(x, y);
            break;
          }
        case this.states.PAN:
          panStart.set(event.touches[0].pageX, event.touches[0].pageY);
          break;
        default:
      }
      this.dispatchEvent(this.startEvent);
    }
  }
  onTouchMove(event) {
    if (this.player.isPlaying()) {
      this.player.stop();
    }
    // TODO : this.states.enabled check should be removed when moving touch events management to StateControl
    if (this.states.enabled === false) {
      return;
    }
    event.preventDefault();
    event.stopPropagation();
    switch (event.touches.length) {
      case this.states.MOVE_GLOBE.finger:
        {
          const coords = this.view.eventToViewCoords(event);
          const normalized = this.view.viewToNormalizedCoords(coords);
          // An updateMatrixWorld on the camera prevents camera jittering when moving globe on a zoomed out view, with
          // devtools open in web browser.
          this.camera.updateMatrixWorld();
          raycaster.setFromCamera(normalized, this.camera);
          // If there's intersection then move globe else we stop the move
          if (raycaster.ray.intersectSphere(pickSphere, intersection)) {
            normalizedIntersection.copy(intersection).normalize();
            moveAroundGlobe.setFromUnitVectors(normalizedIntersection, lastNormalizedIntersection);
            lastTimeMouseMove = Date.now();
          } else {
            this.onTouchEnd();
          }
          break;
        }
      case this.states.ORBIT.finger:
      case this.states.DOLLY.finger:
        {
          const gfx = this.view.mainLoop.gfxEngine;
          rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY);
          rotateDelta.subVectors(rotateEnd, rotateStart);

          // rotating across whole screen goes 360 degrees around
          this.rotateLeft(2 * Math.PI * rotateDelta.x / gfx.width * this.rotateSpeed);
          // rotating up and down along whole screen attempts to go 360, but limited to 180
          this.rotateUp(2 * Math.PI * rotateDelta.y / gfx.height * this.rotateSpeed);
          rotateStart.copy(rotateEnd);
          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.subVectors(dollyEnd, dollyStart);
          this.dolly(dollyDelta.y);
          dollyStart.copy(dollyEnd);
          break;
        }
      case this.states.PAN.finger:
        panEnd.set(event.touches[0].pageX, event.touches[0].pageY);
        panDelta.subVectors(panEnd, panStart);
        this.mouseToPan(panDelta.x, panDelta.y);
        panStart.copy(panEnd);
        break;
      default:
        this.state = this.states.NONE;
    }
    if (this.state !== this.states.NONE) {
      this.update(this.state);
    }
  }
  onTouchEnd() {
    this.handleEndMovement({
      previous: this.state
    });
    this.state = this.states.NONE;
  }
  dispose() {
    this.view.domElement.removeEventListener('touchstart', this._onTouchStart, false);
    this.view.domElement.removeEventListener('touchend', this._onTouchEnd, false);
    this.view.domElement.removeEventListener('touchmove', this._onTouchMove, false);
    this.states.dispose();
    this.states.removeEventListener('state-changed', this._onStateChange, false);
    this.states.removeEventListener(this.states.ORBIT._event, this._onRotation, false);
    this.states.removeEventListener(this.states.MOVE_GLOBE._event, this._onDrag, false);
    this.states.removeEventListener(this.states.DOLLY._event, this._onDolly, false);
    this.states.removeEventListener(this.states.PAN._event, this._onPan, false);
    this.states.removeEventListener(this.states.PANORAMIC._event, this._onPanoramic, false);
    this.states.removeEventListener('zoom', this._onZoom, false);
    this.states.removeEventListener(this.states.TRAVEL_IN._event, this._onTravel, false);
    this.states.removeEventListener(this.states.TRAVEL_OUT._event, this._onTravel, false);
    this.dispatchEvent({
      type: 'dispose'
    });
  }
  /**
   * Changes the tilt of the current camera, in degrees.
   * @param {number}  tilt
   * @param {boolean} isAnimated
   * @return {Promise<void>}
   */
  setTilt(tilt, isAnimated) {
    return this.lookAtCoordinate({
      tilt
    }, isAnimated);
  }

  /**
   * Changes the heading of the current camera, in degrees.
   * @param {number} heading
   * @param {boolean} isAnimated
   * @return {Promise<void>}
   */
  setHeading(heading, isAnimated) {
    return this.lookAtCoordinate({
      heading
    }, isAnimated);
  }

  /**
   * Sets the "range": the distance in meters between the camera and the current central point on the screen.
   * @param {number} range
   * @param {boolean} isAnimated
   * @return {Promise<void>}
   */
  setRange(range, isAnimated) {
    return this.lookAtCoordinate({
      range
    }, isAnimated);
  }

  /**
   * Returns the {@linkcode Coordinates} of the globe point targeted by the camera in EPSG:4978 projection. See {@linkcode Coordinates} for conversion
   * @return {THREE.Vector3} position
   */
  getCameraTargetPosition() {
    return cameraTarget.position;
  }

  /**
   * Returns the "range": the distance in meters between the camera and the current central point on the screen.
   * @param {THREE.Vector3} [position] - The position to consider as picked on
   * the ground.
   * @return {number} number
   */
  getRange(position) {
    return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, position).range;
  }

  /**
   * Returns the tilt of the current camera in degrees.
   * @param {THREE.Vector3} [position] - The position to consider as picked on
   * the ground.
   * @return {number} The angle of the rotation in degrees.
   */
  getTilt(position) {
    return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, position).tilt;
  }

  /**
   * Returns the heading of the current camera in degrees.
   * @param {THREE.Vector3} [position] - The position to consider as picked on
   * the ground.
   * @return {number} The angle of the rotation in degrees.
   */
  getHeading(position) {
    return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera, position).heading;
  }

  /**
   * Displaces the central point to a specific amount of pixels from its current position.
   * The view flies to the desired coordinate, i.e.is not teleported instantly. Note : The results can be strange in some cases, if ever possible, when e.g.the camera looks horizontally or if the displaced center would not pick the ground once displaced.
   * @param      {vector}  pVector  The vector
   * @return {Promise}
   */
  pan(pVector) {
    this.mouseToPan(pVector.x, pVector.y);
    this.update(this.states.PAN);
    return Promise.resolve();
  }

  /**
   * Returns the orientation angles of the current camera, in degrees.
   * @return {Array<number>}
   */
  getCameraOrientation() {
    this.view.getPickingPositionFromDepth(null, pickedPosition);
    return [this.getTilt(pickedPosition), this.getHeading(pickedPosition)];
  }

  /**
   * Returns the camera location projected on the ground in lat,lon. See {@linkcode Coordinates} for conversion.
   * @return {Coordinates} position
   */

  getCameraCoordinate() {
    return new Coordinates('EPSG:4978', this.camera.position).as('EPSG:4326');
  }

  /**
   * Returns the {@linkcode Coordinates} of the central point on screen in lat,lon. See {@linkcode Coordinates} for conversion.
   * @return {Coordinates} coordinate
   */
  getLookAtCoordinate() {
    return CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera).coord;
  }

  /**
   * Sets the animation enabled.
   * @param      {boolean}  enable  enable
   */
  setAnimationEnabled(enable) {
    enableAnimation = enable;
  }

  /**
   * Determines if animation enabled.
   * @return     {boolean}  True if animation enabled, False otherwise.
   */
  isAnimationEnabled() {
    return enableAnimation;
  }

  /**
   * Returns the actual zoom. The zoom will always be between the [getMinZoom(), getMaxZoom()].
   * @return     {number}  The zoom .
   */
  getZoom() {
    return this.view.tileLayer.computeTileZoomFromDistanceCamera(this.getRange(), this.view.camera);
  }

  /**
   * Sets the current zoom, which is an index in the logical scales predefined for the application.
   * The higher the zoom, the closer to the ground.
   * The zoom is always in the [getMinZoom(), getMaxZoom()] range.
   * @param      {number}  zoom    The zoom
   * @param      {boolean}  isAnimated  Indicates if animated
   * @return     {Promise}
   */
  setZoom(zoom, isAnimated) {
    return this.lookAtCoordinate({
      zoom
    }, isAnimated);
  }

  /**
   * Return the current zoom scale at the central point of the view.
   * This function compute the scale of a map
   * @param      {number}  pitch   Screen pitch, in millimeters ; 0.28 by default
   * @return     {number}  The zoom scale.
   *
   * @deprecated Use View#getScale instead.
   */
  getScale(pitch) /* istanbul ignore next */{
    console.warn('Deprecated, use View#getScale instead.');
    return this.view.getScale(pitch);
  }

  /**
   * To convert the projection in meters on the globe of a number of pixels of screen
   * @param      {number} pixels count pixels to project
   * @param      {number} pixelPitch Screen pixel pitch, in millimeters (default = 0.28 mm / standard pixel size of 0.28 millimeters as defined by the OGC)
   * @return     {number} projection in meters on globe
   *
   * @deprecated Use `View#getPixelsToMeters` instead.
   */
  pixelsToMeters(pixels) /* istanbul ignore next */{
    let pixelPitch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.28;
    console.warn('Deprecated use View#getPixelsToMeters instead.');
    const scaled = this.getScale(pixelPitch);
    return pixels * pixelPitch / scaled / 1000;
  }

  /**
   * To convert the projection a number of horizontal pixels of screen to longitude degree WGS84 on the globe
   * @param      {number} pixels count pixels to project
   * @param      {number} pixelPitch Screen pixel pitch, in millimeters (default = 0.28 mm / standard pixel size of 0.28 millimeters as defined by the OGC)
   * @return     {number} projection in degree on globe
   *
   * @deprecated Use `View#getPixelsToMeters` and `GlobeControls#metersToDegrees`
   * instead.
   */
  pixelsToDegrees(pixels) /* istanbul ignore next */{
    let pixelPitch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.28;
    console.warn('Deprecated, use View#getPixelsToMeters and GlobeControls#getMetersToDegrees instead.');
    const chord = this.pixelsToMeters(pixels, pixelPitch);
    return THREE.MathUtils.radToDeg(2 * Math.asin(chord / (2 * ellipsoidSizes.x)));
  }

  /**
   * Projection on screen in pixels of length in meter on globe
   * @param      {number}  value Length in meter on globe
   * @param      {number}  pixelPitch Screen pixel pitch, in millimeters (default = 0.28 mm / standard pixel size of 0.28 millimeters as defined by the OGC)
   * @return     {number}  projection in pixels on screen
   *
   * @deprecated Use `View#getMetersToPixels` instead.
   */
  metersToPixels(value) /* istanbul ignore next */{
    let pixelPitch = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.28;
    console.warn('Deprecated, use View#getMetersToPixels instead.');
    const scaled = this.getScale(pixelPitch);
    pixelPitch /= 1000;
    return value * scaled / pixelPitch;
  }

  /**
   * Changes the zoom of the central point of screen so that screen acts as a map with a specified scale.
   *  The view flies to the desired zoom scale;
   * @param      {number}  scale  The scale
   * @param      {number}  pitch  The pitch
   * @param      {boolean}  isAnimated  Indicates if animated
   * @return     {Promise}
   */
  setScale(scale, pitch, isAnimated) {
    return this.lookAtCoordinate({
      scale,
      pitch
    }, isAnimated);
  }

  /**
   * Changes the center of the scene on screen to the specified in lat, lon. See {@linkcode Coordinates} for conversion.
   * This function allows to change the central position, the zoom, the range, the scale and the camera orientation at the same time.
   * The zoom has to be between the [getMinZoom(), getMaxZoom()].
   * Zoom parameter is ignored if range is set
   * The tilt's interval is between 4 and 89.5 degree
   *
   * @param      {CameraUtils~CameraTransformOptions|Extent}   params camera transformation to apply
   * @param      {number}   [params.zoom]   zoom
   * @param      {number}   [params.scale]   scale
   * @param      {boolean}  isAnimated  Indicates if animated
   * @return     {Promise}  A promise that resolves when transformation is complete
   */
  lookAtCoordinate() {
    let params = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
    let isAnimated = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.isAnimationEnabled();
    this.player.stop();
    if (!params.isExtent) {
      if (params.zoom) {
        params.range = this.view.tileLayer.computeDistanceCameraFromTileZoom(params.zoom, this.view.camera);
      } else if (params.scale) {
        params.range = this.view.getScaleFromDistance(params.pitch, params.scale);
        if (params.range < this.minDistance || params.range > this.maxDistance) {
          // eslint-disable-next-line no-console
          console.warn(`This scale ${params.scale} can not be reached`);
          params.range = THREE.MathUtils.clamp(params.range, this.minDistance, this.maxDistance);
        }
      }
      if (params.tilt !== undefined) {
        const minTilt = 90 - THREE.MathUtils.radToDeg(this.maxPolarAngle);
        const maxTilt = 90 - THREE.MathUtils.radToDeg(this.minPolarAngle);
        if (params.tilt < minTilt || params.tilt > maxTilt) {
          params.tilt = THREE.MathUtils.clamp(params.tilt, minTilt, maxTilt);
          // eslint-disable-next-line no-console
          console.warn('Tilt was clamped to ', params.tilt, ` the interval is between ${minTilt} and ${maxTilt} degree`);
        }
      }
    }
    previous = CameraUtils.getTransformCameraLookingAtTarget(this.view, this.camera);
    if (isAnimated) {
      params.callback = r => cameraTarget.position.copy(r.targetWorldPosition);
      this.dispatchEvent({
        type: 'animation-started'
      });
      return CameraUtils.animateCameraToLookAtTarget(this.view, this.camera, params).then(result => {
        this.dispatchEvent({
          type: 'animation-ended'
        });
        this.handlingEvent(result);
        return result;
      });
    } else {
      return CameraUtils.transformCameraToLookAtTarget(this.view, this.camera, params).then(result => {
        cameraTarget.position.copy(result.targetWorldPosition);
        this.handlingEvent(result);
        return result;
      });
    }
  }

  /**
   * Pick a position on the globe at the given position in lat,lon. See {@linkcode Coordinates} for conversion.
   * @param {Vector2} windowCoords - window coordinates
   * @param {number=} y - The y-position inside the Globe element.
   * @return {Coordinates} position
   */
  pickGeoPosition(windowCoords) {
    const pickedPosition = this.view.getPickingPositionFromDepth(windowCoords);
    if (!pickedPosition) {
      return;
    }
    return new Coordinates('EPSG:4978', pickedPosition).as('EPSG:4326');
  }
}
export default GlobeControls;