import BoundingRectangle from "../Core/BoundingRectangle.js";
import Cartesian2 from "../Core/Cartesian2.js";
import Cartesian3 from "../Core/Cartesian3.js";
import Cartesian4 from "../Core/Cartesian4.js";
import Cartographic from "../Core/Cartographic.js";
import defined from "../Core/defined.js";
import DeveloperError from "../Core/DeveloperError.js";
import CesiumMath from "../Core/Math.js";
import Matrix4 from "../Core/Matrix4.js";
import OrthographicFrustum from "../Core/OrthographicFrustum.js";
import OrthographicOffCenterFrustum from "../Core/OrthographicOffCenterFrustum.js";
import Transforms from "../Core/Transforms.js";
import SceneMode from "./SceneMode.js";

/**
 * Functions that do scene-dependent transforms between rendering-related coordinate systems.
 *
 * @namespace SceneTransforms
 */
const SceneTransforms = {};

const actualPositionScratch = new Cartesian4(0, 0, 0, 1);
let positionCC = new Cartesian4();
const scratchViewport = new BoundingRectangle();

const scratchWindowCoord0 = new Cartesian2();
const scratchWindowCoord1 = new Cartesian2();

/**
 * Transforms a position in world (WGS84 or alternative ellipsoid) coordinates to window coordinates.  This is commonly used to place an
 * HTML element at the same screen position as an object in the scene.
 *
 * @param {Scene} scene The scene.
 * @param {Cartesian3} position The position in world (WGS84 or alternative ellipsoid) coordinates.
 * @param {Cartesian2} [result] An optional object to return the input position transformed to window coordinates.
 * @returns {Cartesian2|undefined} The modified result parameter or a new Cartesian2 instance if one was not provided.  This may be <code>undefined</code> if the input position is near the center of the ellipsoid.
 *
 * @example
 * // Output the window position of longitude/latitude (0, 0) every time the mouse moves.
 * const position = Cesium.Cartesian3.fromDegrees(0.0, 0.0);
 * const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas);
 * handler.setInputAction(function(movement) {
 *     console.log(Cesium.SceneTransforms.worldToWindowCoordinates(scene, position));
 * }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
 */
SceneTransforms.worldToWindowCoordinates = function (scene, position, result) {
  return SceneTransforms.worldWithEyeOffsetToWindowCoordinates(
    scene,
    position,
    Cartesian3.ZERO,
    result,
  );
};

const scratchCartesian4 = new Cartesian4();
const scratchEyeOffset = new Cartesian3();

function worldToClip(position, eyeOffset, camera, result) {
  const viewMatrix = camera.viewMatrix;

  const positionEC = Matrix4.multiplyByVector(
    viewMatrix,
    Cartesian4.fromElements(
      position.x,
      position.y,
      position.z,
      1,
      scratchCartesian4,
    ),
    scratchCartesian4,
  );

  const zEyeOffset = Cartesian3.multiplyComponents(
    eyeOffset,
    Cartesian3.normalize(positionEC, scratchEyeOffset),
    scratchEyeOffset,
  );
  positionEC.x += eyeOffset.x + zEyeOffset.x;
  positionEC.y += eyeOffset.y + zEyeOffset.y;
  positionEC.z += zEyeOffset.z;

  return Matrix4.multiplyByVector(
    camera.frustum.projectionMatrix,
    positionEC,
    result,
  );
}

const scratchMaxCartographic = new Cartographic(
  Math.PI,
  CesiumMath.PI_OVER_TWO,
);
const scratchProjectedCartesian = new Cartesian3();
const scratchCameraPosition = new Cartesian3();

/**
 * @private
 */
SceneTransforms.worldWithEyeOffsetToWindowCoordinates = function (
  scene,
  position,
  eyeOffset,
  result,
) {
  //>>includeStart('debug', pragmas.debug);
  if (!defined(scene)) {
    throw new DeveloperError("scene is required.");
  }
  if (!defined(position)) {
    throw new DeveloperError("position is required.");
  }
  //>>includeEnd('debug');

  // Transform for 3D, 2D, or Columbus view
  const frameState = scene.frameState;
  const actualPosition = SceneTransforms.computeActualEllipsoidPosition(
    frameState,
    position,
    actualPositionScratch,
  );

  if (!defined(actualPosition)) {
    return undefined;
  }

  // Assuming viewport takes up the entire canvas...
  const canvas = scene.canvas;
  const viewport = scratchViewport;
  viewport.x = 0;
  viewport.y = 0;
  viewport.width = canvas.clientWidth;
  viewport.height = canvas.clientHeight;

  const camera = scene.camera;
  let cameraCentered = false;

  if (frameState.mode === SceneMode.SCENE2D) {
    const projection = scene.mapProjection;
    const maxCartographic = scratchMaxCartographic;
    const maxCoord = projection.project(
      maxCartographic,
      scratchProjectedCartesian,
    );

    const cameraPosition = Cartesian3.clone(
      camera.position,
      scratchCameraPosition,
    );
    const frustum = camera.frustum.clone();

    const viewportTransformation = Matrix4.computeViewportTransformation(
      viewport,
      0.0,
      1.0,
      new Matrix4(),
    );
    const projectionMatrix = camera.frustum.projectionMatrix;

    const x = camera.positionWC.y;
    const eyePoint = Cartesian3.fromElements(
      CesiumMath.sign(x) * maxCoord.x - x,
      0.0,
      -camera.positionWC.x,
    );
    const windowCoordinates = Transforms.pointToGLWindowCoordinates(
      projectionMatrix,
      viewportTransformation,
      eyePoint,
    );

    if (
      x === 0.0 ||
      windowCoordinates.x <= 0.0 ||
      windowCoordinates.x >= canvas.clientWidth
    ) {
      cameraCentered = true;
    } else {
      if (windowCoordinates.x > canvas.clientWidth * 0.5) {
        viewport.width = windowCoordinates.x;

        camera.frustum.right = maxCoord.x - x;

        positionCC = worldToClip(actualPosition, eyeOffset, camera, positionCC);
        SceneTransforms.clipToGLWindowCoordinates(
          viewport,
          positionCC,
          scratchWindowCoord0,
        );

        viewport.x += windowCoordinates.x;

        camera.position.x = -camera.position.x;

        const right = camera.frustum.right;
        camera.frustum.right = -camera.frustum.left;
        camera.frustum.left = -right;

        positionCC = worldToClip(actualPosition, eyeOffset, camera, positionCC);
        SceneTransforms.clipToGLWindowCoordinates(
          viewport,
          positionCC,
          scratchWindowCoord1,
        );
      } else {
        viewport.x += windowCoordinates.x;
        viewport.width -= windowCoordinates.x;

        camera.frustum.left = -maxCoord.x - x;

        positionCC = worldToClip(actualPosition, eyeOffset, camera, positionCC);
        SceneTransforms.clipToGLWindowCoordinates(
          viewport,
          positionCC,
          scratchWindowCoord0,
        );

        viewport.x = viewport.x - viewport.width;

        camera.position.x = -camera.position.x;

        const left = camera.frustum.left;
        camera.frustum.left = -camera.frustum.right;
        camera.frustum.right = -left;

        positionCC = worldToClip(actualPosition, eyeOffset, camera, positionCC);
        SceneTransforms.clipToGLWindowCoordinates(
          viewport,
          positionCC,
          scratchWindowCoord1,
        );
      }

      Cartesian3.clone(cameraPosition, camera.position);
      camera.frustum = frustum.clone();

      result = Cartesian2.clone(scratchWindowCoord0, result);
      if (result.x < 0.0 || result.x > canvas.clientWidth) {
        result.x = scratchWindowCoord1.x;
      }
    }
  }

  if (frameState.mode !== SceneMode.SCENE2D || cameraCentered) {
    // View-projection matrix to transform from world coordinates to clip coordinates
    positionCC = worldToClip(actualPosition, eyeOffset, camera, positionCC);
    if (
      positionCC.z < 0 &&
      !(camera.frustum instanceof OrthographicFrustum) &&
      !(camera.frustum instanceof OrthographicOffCenterFrustum)
    ) {
      return undefined;
    }

    result = SceneTransforms.clipToGLWindowCoordinates(
      viewport,
      positionCC,
      result,
    );
  }

  result.y = canvas.clientHeight - result.y;
  return result;
};

/**
 * Transforms a position in world coordinates to drawing buffer coordinates.  This may produce different
 * results from SceneTransforms.worldToWindowCoordinates when the browser zoom is not 100%, or on high-DPI displays.
 *
 * @param {Scene} scene The scene.
 * @param {Cartesian3} position The position in world (WGS84 or alternative ellipsoid) coordinates.
 * @param {Cartesian2} [result] An optional object to return the input position transformed to window coordinates.
 * @returns {Cartesian2|undefined} The modified result parameter or a new Cartesian2 instance if one was not provided.  This may be <code>undefined</code> if the input position is near the center of the ellipsoid.
 *
 * @example
 * // Output the window position of longitude/latitude (0, 0) every time the mouse moves.
 * const position = Cesium.Cartesian3.fromDegrees(0.0, 0.0);
 * const handler = new Cesium.ScreenSpaceEventHandler(scene.canvas);
 * handler.setInputAction(function(movement) {
 *     console.log(Cesium.SceneTransforms.worldToDrawingBufferCoordinates(scene, position));
 * }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
 */
SceneTransforms.worldToDrawingBufferCoordinates = function (
  scene,
  position,
  result,
) {
  result = SceneTransforms.worldToWindowCoordinates(scene, position, result);
  if (!defined(result)) {
    return undefined;
  }

  return SceneTransforms.transformWindowToDrawingBuffer(scene, result, result);
};

const projectedPosition = new Cartesian3();
const positionInCartographic = new Cartographic();

/**
 * @private
 */
SceneTransforms.computeActualEllipsoidPosition = function (
  frameState,
  position,
  result,
) {
  const mode = frameState.mode;

  if (mode === SceneMode.SCENE3D) {
    return Cartesian3.clone(position, result);
  }

  const projection = frameState.mapProjection;
  const cartographic = projection.ellipsoid.cartesianToCartographic(
    position,
    positionInCartographic,
  );
  if (!defined(cartographic)) {
    return undefined;
  }

  projection.project(cartographic, projectedPosition);

  if (mode === SceneMode.COLUMBUS_VIEW) {
    return Cartesian3.fromElements(
      projectedPosition.z,
      projectedPosition.x,
      projectedPosition.y,
      result,
    );
  }

  if (mode === SceneMode.SCENE2D) {
    return Cartesian3.fromElements(
      0.0,
      projectedPosition.x,
      projectedPosition.y,
      result,
    );
  }

  // mode === SceneMode.MORPHING
  const morphTime = frameState.morphTime;
  return Cartesian3.fromElements(
    CesiumMath.lerp(projectedPosition.z, position.x, morphTime),
    CesiumMath.lerp(projectedPosition.x, position.y, morphTime),
    CesiumMath.lerp(projectedPosition.y, position.z, morphTime),
    result,
  );
};

const positionNDC = new Cartesian3();
const positionWC = new Cartesian3();
const viewportTransform = new Matrix4();

/**
 * @private
 */
SceneTransforms.clipToGLWindowCoordinates = function (
  viewport,
  position,
  result,
) {
  // Perspective divide to transform from clip coordinates to normalized device coordinates
  Cartesian3.divideByScalar(position, position.w, positionNDC);

  // Viewport transform to transform from clip coordinates to window coordinates
  Matrix4.computeViewportTransformation(viewport, 0.0, 1.0, viewportTransform);
  Matrix4.multiplyByPoint(viewportTransform, positionNDC, positionWC);

  return Cartesian2.fromCartesian3(positionWC, result);
};

/**
 * @private
 */
SceneTransforms.transformWindowToDrawingBuffer = function (
  scene,
  windowPosition,
  result,
) {
  const canvas = scene.canvas;
  const xScale = scene.drawingBufferWidth / canvas.clientWidth;
  const yScale = scene.drawingBufferHeight / canvas.clientHeight;
  return Cartesian2.fromElements(
    windowPosition.x * xScale,
    windowPosition.y * yScale,
    result,
  );
};

const scratchNDC = new Cartesian4();
const scratchWorldCoords = new Cartesian4();

/**
 * @private
 */
SceneTransforms.drawingBufferToWorldCoordinates = function (
  scene,
  drawingBufferPosition,
  depth,
  result,
) {
  const context = scene.context;
  const uniformState = context.uniformState;

  const currentFrustum = uniformState.currentFrustum;
  const near = currentFrustum.x;
  const far = currentFrustum.y;

  if (scene.frameState.useLogDepth) {
    // transforming logarithmic depth of form
    // log2(z + 1) / log2( far + 1);
    // to perspective form
    // (far - far * near / z) / (far - near)
    const log2Depth = depth * uniformState.log2FarDepthFromNearPlusOne;
    const depthFromNear = Math.pow(2.0, log2Depth) - 1.0;
    depth = (far * (1.0 - near / (depthFromNear + near))) / (far - near);
  }

  const viewport = scene.view.passState.viewport;
  const ndc = Cartesian4.clone(Cartesian4.UNIT_W, scratchNDC);
  ndc.x = ((drawingBufferPosition.x - viewport.x) / viewport.width) * 2.0 - 1.0;
  ndc.y =
    ((drawingBufferPosition.y - viewport.y) / viewport.height) * 2.0 - 1.0;
  ndc.z = depth * 2.0 - 1.0;
  ndc.w = 1.0;

  let worldCoords;
  let frustum = scene.camera.frustum;
  if (!defined(frustum.fovy)) {
    const offCenterFrustum = frustum.offCenterFrustum;
    if (defined(offCenterFrustum)) {
      frustum = offCenterFrustum;
    }
    worldCoords = scratchWorldCoords;
    worldCoords.x =
      (ndc.x * (frustum.right - frustum.left) + frustum.left + frustum.right) *
      0.5;
    worldCoords.y =
      (ndc.y * (frustum.top - frustum.bottom) + frustum.bottom + frustum.top) *
      0.5;
    worldCoords.z = (ndc.z * (near - far) - near - far) * 0.5;
    worldCoords.w = 1.0;

    worldCoords = Matrix4.multiplyByVector(
      uniformState.inverseView,
      worldCoords,
      worldCoords,
    );
  } else {
    worldCoords = Matrix4.multiplyByVector(
      uniformState.inverseViewProjection,
      ndc,
      scratchWorldCoords,
    );

    // Reverse perspective divide
    const w = 1.0 / worldCoords.w;
    Cartesian3.multiplyByScalar(worldCoords, w, worldCoords);
  }
  return Cartesian3.fromCartesian4(worldCoords, result);
};
export default SceneTransforms;
