import * as THREE from 'three';
import TWEEN from '@tweenjs/tween.js';
import DEMUtils from "./DEMUtils.js";
import { MAIN_LOOP_EVENTS } from "../Core/MainLoop.js";
import Coordinates from "../Core/Geographic/Coordinates.js";
import Ellipsoid from "../Core/Math/Ellipsoid.js";
import OBB from "../Renderer/OBB.js";
import { VIEW_EVENTS } from "../Core/View.js";
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const targetPosition = new THREE.Vector3();
const targetCoord = new Coordinates('EPSG:4326', 0, 0, 0);
const ellipsoid = new Ellipsoid();
const rigs = [];
const obb = new OBB();
const size = new THREE.Vector3();
const deferred = () => {
  let resolve;
  let reject;
  return {
    promise: new Promise((re, rej) => {
      resolve = re;
      reject = rej;
    }),
    resolve,
    reject
  };
};

// Wrap angle in degrees to [-180 180]
function wrapTo180(angle) {
  return angle - Math.floor((angle + 180.0) / 360) * 360;
}
function tileLayer(view) {
  return view.getLayers(l => l.isTiledGeometryLayer)[0];
}
export function getLookAtFromMath(view, camera) {
  const direction = new THREE.Vector3(0, 0, 0.5);
  direction.unproject(camera);
  direction.sub(camera.position).normalize();
  if (view.referenceCrs == 'EPSG:4978') {
    // Intersect Ellispoid
    return ellipsoid.intersection({
      direction,
      origin: camera.position
    });
  } else {
    // Intersect plane
    const distance = camera.position.z / direction.z;
    return direction.multiplyScalar(distance).add(camera.position);
  }
}
function proxyProperty(view, camera, rig, key) {
  rig.proxy.position[key] = camera.position[key];
  Object.defineProperty(camera.position, key, {
    get: () => rig.proxy.position[key],
    set: newValue => {
      rig.removeProxy(view, camera);
      camera.position[key] = newValue;
    }
  });
}

// the rig is used to manipulate the camera
// It consists of a tree of 3D objects, each element is assigned a task
//
//                      Transformation
//
// rig                  position on Coordinate  (for the globe is rotation)
// |
// └── sealevel         position on altitude zero
//     |
//     └── target       position on DEM, and rotation (pitch and heading)
//         |
//         └── camera   distance to target
//
// When all transformations are calculated,
// this.camera's transformation is applied to view.camera.camera
class CameraRig extends THREE.Object3D {
  constructor() {
    super();
    // seaLevel is on rig's z axis, it's at altitude zero
    this.seaLevel = new THREE.Object3D();
    // target is on seaLevel's z axis and target.position.z is the DEM altitude
    this.target = new THREE.Object3D();
    this.target.rotation.order = 'ZXY';
    // camera look at target
    this.camera = new THREE.Camera();
    this.add(this.seaLevel);
    this.seaLevel.add(this.target);
    this.target.add(this.camera);
    // target's geographic coordinate
    this.coord = new Coordinates('EPSG:4978', 0, 0);
    // sea level's worldPoistion
    this.targetWorldPosition = new THREE.Vector3();
    this.removeAll = () => {};
    this._onChangeCallback = null;
  }

  // apply rig.camera's transformation to camera
  applyTransformToCamera(view, camera) {
    if (this.proxy) {
      camera.quaternion._onChange(this._onChangeCallback);
      this.camera.matrixWorld.decompose(this.proxy.position, camera.quaternion, camera.scale);
      camera.quaternion._onChange(() => this.removeProxy(view, camera));
    } else {
      this.camera.matrixWorld.decompose(camera.position, camera.quaternion, camera.scale);
    }
    view.dispatchEvent({
      type: VIEW_EVENTS.CAMERA_MOVED,
      coord: this.coord,
      range: this.range,
      heading: this.heading,
      tilt: this.tilt
    });
  }
  setProxy(view, camera) {
    if (!this.proxy && view && camera) {
      this.proxy = {
        position: new THREE.Vector3()
      };
      Object.keys(camera.position).forEach(key => proxyProperty(view, camera, this, key));
      this._onChangeCallback = camera.quaternion._onChangeCallback;
      camera.quaternion._onChange(() => this.removeProxy(view, camera));
    }
  }
  removeProxy(view, camera) {
    this.stop(view);
    if (this.proxy && view && camera) {
      Object.keys(camera.position).forEach(key => Object.defineProperty(camera.position, key, {
        value: this.proxy.position[key],
        writable: true
      }));
      camera.quaternion._onChange(this._onChangeCallback);
      this.proxy = null;
    }
  }
  setTargetFromCoordinate(view, coord) {
    // compute precise coordinate (coord) altitude and clamp it above seaLevel
    coord.as(tileLayer(view).extent.crs, this.coord);
    const altitude = Math.max(0, DEMUtils.getElevationValueAt(tileLayer(view), this.coord, DEMUtils.PRECISE_READ_Z) || this.coord.z);
    this.coord.z = altitude;
    // adjust target's position with clamped altitude
    this.coord.as(view.referenceCrs).toVector3(targetPosition);
    if (view.referenceCrs == 'EPSG:4978') {
      // ellipsoid geocentric projection
      this.lookAt(targetPosition);
      this.seaLevel.position.set(0, 0, targetPosition.length() - altitude);
    } else {
      // planar projection
      this.position.set(targetPosition.x, targetPosition.y, 0);
      this.seaLevel.position.set(0, 0, 0);
    }
    // place camera's target
    this.target.position.set(0, 0, altitude);
  }

  // set rig's objects transformation from camera's position and target's position
  setFromPositions(view, cameraPosition) {
    this.setTargetFromCoordinate(view, new Coordinates(view.referenceCrs, targetPosition));
    this.target.rotation.set(0, 0, 0);
    this.updateMatrixWorld(true);
    this.camera.position.copy(cameraPosition);
    this.target.worldToLocal(this.camera.position);
    const range = this.camera.position.length();
    this.target.rotation.x = Math.asin(this.camera.position.z / range);
    const cosPlanXY = THREE.MathUtils.clamp(this.camera.position.y / (Math.cos(this.target.rotation.x) * range), -1, 1);
    this.target.rotation.z = Math.sign(-this.camera.position.x || 1) * Math.acos(cosPlanXY);
    this.camera.position.set(0, range, 0);
  }

  // set from target's coordinate, rotation and range between target and camera
  applyParams(view, params) {
    if (params.coord) {
      this.setTargetFromCoordinate(view, params.coord);
    }
    if (params.tilt != undefined) {
      this.target.rotation.x = THREE.MathUtils.degToRad(params.tilt);
    }
    if (params.heading != undefined) {
      this.target.rotation.z = THREE.MathUtils.degToRad(-wrapTo180(params.heading + 180));
    }
    if (params.range) {
      this.camera.position.set(0, params.range, 0);
    }
    this.camera.rotation.set(-Math.PI * 0.5, 0, Math.PI);
    this.updateMatrixWorld(true);
    this.targetWorldPosition.setFromMatrixPosition(this.seaLevel.matrixWorld);
  }
  getParams() {
    return {
      coord: this.coord.clone(),
      tilt: this.tilt,
      heading: this.heading,
      range: this.range,
      targetWorldPosition: this.targetWorldPosition
    };
  }
  setfromCamera(view, camera, pickedPosition) {
    camera.updateMatrixWorld(true);
    if (pickedPosition == undefined) {
      pickedPosition = view.getPickingPositionFromDepth() || getLookAtFromMath(view, camera);
    }
    const range = pickedPosition && !isNaN(pickedPosition.x) ? camera.position.distanceTo(pickedPosition) : 100;
    camera.localToWorld(targetPosition.set(0, 0, -range));
    this.setFromPositions(view, camera.position);
  }
  copyObject3D(rig) {
    this.copy(rig, false);
    this.seaLevel.copy(rig.seaLevel, false);
    this.target.copy(rig.target, false);
    this.camera.copy(rig.camera);
    return this;
  }
  animateCameraToLookAtTarget(view, camera, params) {
    params.easing = params.easing || TWEEN.Easing.Quartic.InOut;
    this.setfromCamera(view, camera);
    const tweenGroup = new TWEEN.Group();
    this.start = (this.start || new CameraRig()).copyObject3D(this);
    this.end = (this.end || new CameraRig()).copyObject3D(this);
    const time = params.time || 2500;
    const factor = {
      t: 0
    };
    const animations = [];
    const def = deferred();
    this.addPlaceTargetOnGround(view, camera, params.coord, factor);
    this.end.applyParams(view, params);
    // compute the angle along z-axis between the starting position and the end position
    const difference = this.end.target.rotation.z - this.start.target.rotation.z;
    // if that angle is superior to 180°, recompute the rotation as the complementary angle.
    if (Math.abs(difference) > Math.PI) {
      this.end.target.rotation.z = this.start.target.rotation.z + difference - Math.sign(difference) * 2 * Math.PI;
    }
    animations.push(new TWEEN.Tween(factor, tweenGroup).to({
      t: 1
    }, time).easing(params.easing).onUpdate(d => {
      // rotate to coord destination in geocentric projection
      if (view.referenceCrs == 'EPSG:4978') {
        this.quaternion.slerpQuaternions(this.start.quaternion, this.end.quaternion, d.t);
      }
      // camera rotation
      this.camera.quaternion.slerpQuaternions(this.start.camera.quaternion, this.end.camera.quaternion, d.t);
      // camera's target rotation
      this.target.rotation.set(0, 0, 0);
      this.target.rotateZ(THREE.MathUtils.lerp(this.start.target.rotation.z, this.end.target.rotation.z, d.t));
      this.target.rotateX(THREE.MathUtils.lerp(this.start.target.rotation.x, this.end.target.rotation.x, d.t));
    }));

    // translate to coordinate destination in planar projection
    if (view.referenceCrs != 'EPSG:4978') {
      animations.push(new TWEEN.Tween(this.position, tweenGroup).to(this.end.position, time).easing(params.easing));
    }

    // translate to altitude zero
    animations.push(new TWEEN.Tween(this.seaLevel.position, tweenGroup).to(this.end.seaLevel.position, time).easing(params.easing));

    // translate camera position
    animations.push(new TWEEN.Tween(this.camera.position, tweenGroup).to(this.end.camera.position, time).easing(params.easing));

    // update animations, transformation and view
    this.animationFrameRequester = () => {
      tweenGroup.update();
      this.updateMatrixWorld(true);
      this.applyTransformToCamera(view, camera);
      this.targetWorldPosition.setFromMatrixPosition(this.seaLevel.matrixWorld);
      if (params.callback) {
        params.callback(this);
      }
      targetCoord.crs = view.referenceCrs;
      targetCoord.setFromVector3(this.targetWorldPosition).as(tileLayer(view).extent.crs, this.coord);
      view.notifyChange(camera);
    };
    this.removeAll = function (o) {
      this.removeAll = () => {};
      tweenGroup.removeAll();
      if (this.animationFrameRequester) {
        view.removeFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.animationFrameRequester);
      }
      def.resolve(o !== undefined);
      this.animationFrameRequester = null;
    };

    // Waiting last animation complete,
    // we assume that the animation that completes last is the one that was started last
    animations[animations.length - 1].onComplete(this.removeAll);
    animations.forEach(anim => anim.start());
    view.addFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.animationFrameRequester);
    view.notifyChange(camera);
    return def;
  }
  stop(view) {
    this.removePlaceTargetOnGround(view);
    this.removeAll();
  }

  // update target position to coordinate's altitude
  addPlaceTargetOnGround(view, camera, coord) {
    let options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {
      t: 1.0
    };
    this.removePlaceTargetOnGround(view);
    if (view && camera) {
      const startAltitude = this.target.position.z;
      this.placeTargetOnGround = () => {
        const altitude = Math.max(0, DEMUtils.getElevationValueAt(tileLayer(view), coord || this.coord, DEMUtils.PRECISE_READ_Z) || 0);
        this.target.position.z = startAltitude * (1.0 - options.t) + altitude * options.t;
        this.target.updateMatrixWorld(true);
        this.applyTransformToCamera(view, camera);
      };
      this.placeTargetOnGround();
      view.addFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.placeTargetOnGround);
    }
  }
  removePlaceTargetOnGround(view) {
    if (view && this.placeTargetOnGround) {
      view.removeFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.placeTargetOnGround);
      this.placeTargetOnGround = null;
    }
  }
  get tilt() {
    return THREE.MathUtils.radToDeg(this.target.rotation.x);
  }
  get heading() {
    return -wrapTo180(THREE.MathUtils.radToDeg(this.target.rotation.z) + 180);
  }
  get range() {
    return this.camera.position.y;
  }
}
export function getRig(camera) {
  rigs[camera.uuid] = rigs[camera.uuid] || new CameraRig();
  return rigs[camera.uuid];
}

/**
 * @module CameraUtils
 */
export default {
  /**
   * @typedef {Object} CameraTransformOptions
   * @property {Coordinate} [coord=currentCoordinate] Camera look at geographic coordinate
   * @property {Number} [tilt=currentTilt] camera's tilt, in degree
   * @property {Number} [heading=currentHeading] camera's heading, in degree
   * @property {Number} [range=currentRange] camera distance to target coordinate, in meter
   * @property {Number} [time=2500] duration of the animation, in ms
   * @property {boolean} [proxy=true] use proxy to handling camera's transformation. if proxy == true, other camera's transformation stops rig's transformation
   * @property {Number} [easing=TWEEN.Easing.Quartic.InOut] in and out easing animation
   * @property {function} [callback] callback call each animation's frame (params are current cameraTransform and worldTargetPosition)
   * @property {boolean} [stopPlaceOnGroundAtEnd=false] stop place target on the ground at animation ending
   */
  /**
   * Default value for option to stop place target
   * on the ground at animation ending.
   * Default value is false.
   */
  defaultStopPlaceOnGroundAtEnd: false,
  Easing: TWEEN.Easing,
  /**
   * Stop camera's animation
   *
   * @param      {View}  view    The camera view
   * @param      {Camera}  camera  The camera to stop animation
   */
  stop(view, camera) {
    getRig(camera).stop(view);
  },
  /**
   * Gets the current parameters transform camera looking at target.
   *
   * @param      {View}  view    The camera view
   * @param      {Camera}  camera  The camera to get transform
   * @param      {THREE.Vector3} [target] - The optional target
   * @return     {CameraUtils~CameraTransformOptions}  The transform camera looking at target
   */
  getTransformCameraLookingAtTarget(view, camera, target) {
    const rig = getRig(camera);
    rig.setfromCamera(view, camera, target);
    return rig.getParams();
  },
  /**
   * Apply transform to camera
   *
   * @param      {View}  view    The camera view
   * @param      {Camera}  camera  The camera to transform
   * @param      {CameraUtils~CameraTransformOptions|Extent}  params  The parameters
   * @return     {Promise} promise with resolve final CameraUtils~CameraTransformOptions
   */
  transformCameraToLookAtTarget(view, camera) {
    let params = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
    if (params.isExtent) {
      params = this.getCameraTransformOptionsFromExtent(view, camera, params);
    }
    params.proxy = params.proxy === undefined || params.proxy;
    const rig = getRig(camera);
    rig.stop(view);
    rig.setfromCamera(view, camera);
    if (params.proxy) {
      rig.setProxy(view, camera);
    }
    rig.applyParams(view, params);
    rig.addPlaceTargetOnGround(view, camera, params.coord);
    rig.applyTransformToCamera(view, camera);
    view.notifyChange(camera);
    return Promise.resolve(rig.getParams());
  },
  /**
   * Compute the CameraTransformOptions that allow a given camera to display a given extent in its entirety.
   *
   * @param   {View}    view    The camera view
   * @param   {THREE.Camera}  camera  The camera to get the CameraTransformOptions from
   * @param   {Extent}  extent  The extent the camera must display
   *
   * @return  {CameraUtils~CameraTransformOptions}   The CameraTransformOptions allowing camera to display the extent.
   */
  getCameraTransformOptionsFromExtent(view, camera, extent) {
    const cameraTransformOptions = {
      coord: new Coordinates(extent.crs, 0, 0, 0),
      heading: 0,
      tilt: view.isPlanarView ? 90 : 89.9
    };
    let dimensions;
    if (view.isGlobeView) {
      extent = extent.as('EPSG:4326');
      // compute extent's bounding box dimensions
      obb.setFromExtent(extent);
      // /!\ WARNING x and y are inverted, see issue #XXXX
      obb.box3D.getSize(size);
      dimensions = {
        x: size.y,
        y: size.x
      };
    } else {
      extent = extent.as(view.referenceCrs);
      dimensions = extent.planarDimensions();
    }
    extent.center(cameraTransformOptions.coord);
    if (camera.isOrthographicCamera) {
      // setup camera zoom
      if (dimensions.x / dimensions.y > camera.aspect) {
        camera.zoom = (camera.right - camera.left) / dimensions.x;
      } else {
        camera.zoom = (camera.top - camera.bottom) / dimensions.y;
      }
      camera.updateProjectionMatrix();

      // setup camera placement
      cameraTransformOptions.range = 1000;
    } else if (camera.isPerspectiveCamera) {
      // setup range for camera placement
      const verticalFOV = THREE.MathUtils.degToRad(camera.fov);
      if (dimensions.x / dimensions.y > camera.aspect) {
        const focal = view.domElement.clientHeight * 0.5 / Math.tan(verticalFOV * 0.5);
        const horizontalFOV = 2 * Math.atan(view.domElement.clientWidth * 0.5 / focal);
        cameraTransformOptions.range = dimensions.x / (2 * Math.tan(horizontalFOV * 0.5));
      } else {
        cameraTransformOptions.range = dimensions.y / (2 * Math.tan(verticalFOV * 0.5));
      }
    }
    return cameraTransformOptions;
  },
  /**
   * Apply transform to camera with animation
   *
   * @param      {View}  view    The camera view
   * @param      {Camera}  camera  The camera to animate
   * @param      {CameraUtils~CameraTransformOptions}  params  The parameters
   * @return     {Promise} promise with resolve final CameraUtils~CameraTransformOptions
   */
  animateCameraToLookAtTarget(view, camera) {
    let params = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
    params.proxy = params.proxy === undefined || params.proxy;
    const rig = getRig(camera);
    rig.stop(view);
    if (params.proxy) {
      rig.setProxy(view, camera);
    }
    return rig.animateCameraToLookAtTarget(view, camera, params).promise.then(finished => {
      const stopPlaceOnGround = params.stopPlaceOnGroundAtEnd === undefined ? this.defaultStopPlaceOnGroundAtEnd : params.stopPlaceOnGroundAtEnd;
      const newTransformation = rig.getParams();
      if (stopPlaceOnGround) {
        rig.stop(view);
      }
      newTransformation.finished = finished;
      return newTransformation;
    });
  },
  /**
   * chain animation transform to camera
   *
   * @param      {View}  view    The camera view
   * @param      {Camera}  camera  The camera to animate
   * @param      {CameraUtils~CameraTransformOptions[]}  params  array parameters, each parameters transforms are apply to camera, in serial
   * @return     {Promise} promise with resolve final CameraUtils~CameraTransformOptions
   */
  sequenceAnimationsToLookAtTarget(view, camera) {
    let params = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [{}];
    // convert each param to a function
    const funcs = params.map(param => () => this.animateCameraToLookAtTarget(view, camera, param));

    // execute Promises in serial
    return (funcs => funcs.reduce((promise, func) => promise.then(result => {
      const finished = result.length ? result[result.length - 1].finished : true;
      if (finished) {
        return func().then(Array.prototype.concat.bind(result));
      } else {
        return Promise.resolve([{
          finished: false
        }]);
      }
    }), Promise.resolve([])))(funcs);
  },
  /**
   * Gets the difference camera transformation
   *
   * @param      {CameraUtils~CameraTransformOptions}  first  param to compare with the second
   * @param      {CameraUtils~CameraTransformOptions}  second param to compare with the first
   * @return     {object} The difference parameters
   */
  getDiffParams(first, second) {
    if (!first || !second) {
      return;
    }
    let diff;
    if (Math.abs(first.range - second.range) / first.range > 0.001) {
      diff = diff || {};
      diff.range = {
        previous: first.range,
        new: second.range
      };
    }
    if (Math.abs(first.tilt - second.tilt) > 0.1) {
      diff = diff || {};
      diff.tilt = {
        previous: first.tilt,
        new: second.tilt
      };
    }
    if (Math.abs(first.heading - second.heading) > 0.1) {
      diff = diff || {};
      diff.heading = {
        previous: first.heading,
        new: second.heading
      };
    }
    if (Math.abs(first.coord.x - second.coord.x) > 0.000001 || Math.abs(first.coord.y - second.coord.y) > 0.000001) {
      diff = diff || {};
      diff.coord = {
        previous: first.coord,
        new: second.coord
      };
    }
    return diff;
  }
};