// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {clamp} from '@math.gl/core';
import Controller, {ControllerProps, InteractionState} from './controller';
import ViewState from './view-state';
import {worldToLngLat, lngLatToWorld as _lngLatToWorld} from '@math.gl/web-mercator';
import assert from '../utils/assert';
import {mod} from '../utils/math-utils';

import LinearInterpolator from '../transitions/linear-interpolator';
import type Viewport from '../viewports/viewport';

const PITCH_MOUSE_THRESHOLD = 5;
const PITCH_ACCEL = 1.2;
const WEB_MERCATOR_TILE_SIZE = 512;
const WEB_MERCATOR_MAX_BOUNDS = [
  [-Infinity, -90],
  [Infinity, 90]
] satisfies ControllerProps['maxBounds'];

/** The web mercator utility `lngLatToWorld` throws if invalid coordinates are provided.
 * This wrapper clamps user input to calculate common positions safely. */
function lngLatToWorld([lng, lat]: number[]): number[] {
  if (Math.abs(lat) > 90) {
    lat = Math.sign(lat) * 90;
  }
  if (Number.isFinite(lng)) {
    const [x, y] = _lngLatToWorld([lng, lat]);
    return [x, clamp(y, 0, WEB_MERCATOR_TILE_SIZE)];
  }
  const [, y] = _lngLatToWorld([0, lat]);
  return [lng, clamp(y, 0, WEB_MERCATOR_TILE_SIZE)];
}

export type MapStateProps = {
  /** Mapbox viewport properties */
  /** The width of the viewport */
  width: number;
  /** The height of the viewport */
  height: number;
  /** The latitude at the center of the viewport */
  latitude: number;
  /** The longitude at the center of the viewport */
  longitude: number;
  /** The tile zoom level of the map. */
  zoom: number;
  /** The bearing of the viewport in degrees */
  bearing?: number;
  /** The pitch of the viewport in degrees */
  pitch?: number;
  /**
   * Specify the altitude of the viewport camera
   * Unit: map heights, default 1.5
   * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137
   */
  altitude?: number;
  /** Viewport position */
  position?: [number, number, number];

  /** Viewport constraints */
  maxZoom?: number;
  minZoom?: number;
  maxPitch?: number;
  minPitch?: number;

  /** Normalize viewport props to fit map height into viewport. Default `true` */
  normalize?: boolean;

  maxBounds?: ControllerProps['maxBounds'];
};

export type MapStateInternal = {
  /** Interaction states, required to calculate change during transform */
  /* The point on map being grabbed when the operation first started */
  startPanLngLat?: [number, number];
  /* Center of the zoom when the operation first started */
  startZoomLngLat?: [number, number];
  /* Pointer position when rotation started */
  startRotatePos?: [number, number];
  /* The lng/lat/altitude point at the rotation pivot (where rotation started) */
  startRotateLngLat?: [number, number, number];
  /** Bearing when current perspective rotate operation started */
  startBearing?: number;
  /** Pitch when current perspective rotate operation started */
  startPitch?: number;
  /** Zoom when current zoom operation started */
  startZoom?: number;
};

/* Utils */

export class MapState extends ViewState<MapState, MapStateProps, MapStateInternal> {
  /* get optional altitude for rotation pivot
   *   - undefined: rotate around viewport center (no pivot point)
   *   - 0: rotate around pointer position at ground level
   *   - other value: rotate around pointer position at specified altitude
   */
  getAltitude?: (pos: [number, number]) => number | undefined;

  constructor(
    options: MapStateProps &
      MapStateInternal & {
        makeViewport: (props: Record<string, any>) => Viewport;
        getAltitude?: (pos: [number, number]) => number | undefined;
      }
  ) {
    const {
      /** Mapbox viewport properties */
      /** The width of the viewport */
      width,
      /** The height of the viewport */
      height,
      /** The latitude at the center of the viewport */
      latitude,
      /** The longitude at the center of the viewport */
      longitude,
      /** The tile zoom level of the map. */
      zoom,
      /** The bearing of the viewport in degrees */
      bearing = 0,
      /** The pitch of the viewport in degrees */
      pitch = 0,
      /**
       * Specify the altitude of the viewport camera
       * Unit: map heights, default 1.5
       * Non-public API, see https://github.com/mapbox/mapbox-gl-js/issues/1137
       */
      altitude = 1.5,
      /** Viewport position */
      position = [0, 0, 0],

      /** Viewport constraints */
      maxZoom = 20,
      minZoom = 0,
      maxPitch = 60,
      minPitch = 0,

      /** Interaction states, required to calculate change during transform */
      /* The point on map being grabbed when the operation first started */
      startPanLngLat,
      /* Center of the zoom when the operation first started */
      startZoomLngLat,
      /* Pointer position when rotation started */
      startRotatePos,
      /* The lng/lat point at the rotation pivot (where rotation started) */
      startRotateLngLat,
      /** Bearing when current perspective rotate operation started */
      startBearing,
      /** Pitch when current perspective rotate operation started */
      startPitch,
      /** Zoom when current zoom operation started */
      startZoom,

      /** Normalize viewport props to fit map height into viewport */
      normalize = true
    } = options;

    assert(Number.isFinite(longitude)); // `longitude` must be supplied
    assert(Number.isFinite(latitude)); // `latitude` must be supplied
    assert(Number.isFinite(zoom)); // `zoom` must be supplied

    const maxBounds = options.maxBounds || (normalize ? WEB_MERCATOR_MAX_BOUNDS : null);

    super(
      {
        width,
        height,
        latitude,
        longitude,
        zoom,
        bearing,
        pitch,
        altitude,
        maxZoom,
        minZoom,
        maxPitch,
        minPitch,
        normalize,
        position,
        maxBounds
      },
      {
        startPanLngLat,
        startZoomLngLat,
        startRotatePos,
        startRotateLngLat,
        startBearing,
        startPitch,
        startZoom
      },
      options.makeViewport
    );

    this.getAltitude = options.getAltitude;
  }

  /**
   * Start panning
   * @param {[Number, Number]} pos - position on screen where the pointer grabs
   */
  panStart({pos}: {pos: [number, number]}): MapState {
    return this._getUpdatedState({
      startPanLngLat: this._unproject(pos)
    });
  }

  /**
   * Pan
   * @param {[Number, Number]} pos - position on screen where the pointer is
   * @param {[Number, Number], optional} startPos - where the pointer grabbed at
   *   the start of the operation. Must be supplied of `panStart()` was not called
   */
  pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): MapState {
    const startPanLngLat = this.getState().startPanLngLat || this._unproject(startPos);

    if (!startPanLngLat) {
      return this;
    }

    const viewport = this.makeViewport(this.getViewportProps());
    const newProps = viewport.panByPosition(startPanLngLat, pos);

    return this._getUpdatedState(newProps);
  }

  /**
   * End panning
   * Must call if `panStart()` was called
   */
  panEnd(): MapState {
    return this._getUpdatedState({
      startPanLngLat: null
    });
  }

  /**
   * Start rotating
   * @param {[Number, Number]} pos - position on screen where the center is
   */
  rotateStart({pos}: {pos: [number, number]}): MapState {
    const altitude = this.getAltitude?.(pos);

    return this._getUpdatedState({
      startRotatePos: pos,
      startRotateLngLat: altitude !== undefined ? this._unproject3D(pos, altitude) : undefined,
      startBearing: this.getViewportProps().bearing,
      startPitch: this.getViewportProps().pitch
    });
  }

  /**
   * Rotate
   * @param {[Number, Number]} pos - position on screen where the center is
   */
  rotate({
    pos,
    deltaAngleX = 0,
    deltaAngleY = 0
  }: {
    pos?: [number, number];
    deltaAngleX?: number;
    deltaAngleY?: number;
  }): MapState {
    const {startRotatePos, startRotateLngLat, startBearing, startPitch} = this.getState();

    if (!startRotatePos || startBearing === undefined || startPitch === undefined) {
      return this;
    }
    let newRotation;
    if (pos) {
      newRotation = this._getNewRotation(pos, startRotatePos, startPitch, startBearing);
    } else {
      newRotation = {
        bearing: startBearing + deltaAngleX,
        pitch: startPitch + deltaAngleY
      };
    }

    // If we have a pivot point, adjust the camera position to keep the pivot point fixed
    if (startRotateLngLat) {
      const rotatedViewport = this.makeViewport({
        ...this.getViewportProps(),
        ...newRotation
      });
      // Use panByPosition3D if available (WebMercatorViewport), otherwise fall back to panByPosition
      const panMethod = 'panByPosition3D' in rotatedViewport ? 'panByPosition3D' : 'panByPosition';
      return this._getUpdatedState({
        ...newRotation,
        ...rotatedViewport[panMethod](startRotateLngLat, startRotatePos)
      });
    }

    return this._getUpdatedState(newRotation);
  }

  /**
   * End rotating
   * Must call if `rotateStart()` was called
   */
  rotateEnd(): MapState {
    return this._getUpdatedState({
      startRotatePos: null,
      startRotateLngLat: null,
      startBearing: null,
      startPitch: null
    });
  }

  /**
   * Start zooming
   * @param {[Number, Number]} pos - position on screen where the center is
   */
  zoomStart({pos}: {pos: [number, number]}): MapState {
    return this._getUpdatedState({
      startZoomLngLat: this._unproject(pos),
      startZoom: this.getViewportProps().zoom
    });
  }

  /**
   * Zoom
   * @param {[Number, Number]} pos - position on screen where the current center is
   * @param {[Number, Number]} startPos - the center position at
   *   the start of the operation. Must be supplied of `zoomStart()` was not called
   * @param {Number} scale - a number between [0, 1] specifying the accumulated
   *   relative scale.
   */
  zoom({
    pos,
    startPos,
    scale
  }: {
    pos: [number, number];
    startPos?: [number, number];
    scale: number;
  }): MapState {
    // Make sure we zoom around the current mouse position rather than map center
    let {startZoom, startZoomLngLat} = this.getState();

    if (!startZoomLngLat) {
      // We have two modes of zoom:
      // scroll zoom that are discrete events (transform from the current zoom level),
      // and pinch zoom that are continuous events (transform from the zoom level when
      // pinch started).
      // If startZoom state is defined, then use the startZoom state;
      // otherwise assume discrete zooming
      startZoom = this.getViewportProps().zoom;
      startZoomLngLat = this._unproject(startPos) || this._unproject(pos);
    }
    if (!startZoomLngLat) {
      return this;
    }

    const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));
    const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});

    return this._getUpdatedState({
      zoom,
      ...zoomedViewport.panByPosition(startZoomLngLat, pos)
    });
  }

  /**
   * End zooming
   * Must call if `zoomStart()` was called
   */
  zoomEnd(): MapState {
    return this._getUpdatedState({
      startZoomLngLat: null,
      startZoom: null
    });
  }

  zoomIn(speed: number = 2): MapState {
    return this._zoomFromCenter(speed);
  }

  zoomOut(speed: number = 2): MapState {
    return this._zoomFromCenter(1 / speed);
  }

  moveLeft(speed: number = 100): MapState {
    return this._panFromCenter([speed, 0]);
  }

  moveRight(speed: number = 100): MapState {
    return this._panFromCenter([-speed, 0]);
  }

  moveUp(speed: number = 100): MapState {
    return this._panFromCenter([0, speed]);
  }

  moveDown(speed: number = 100): MapState {
    return this._panFromCenter([0, -speed]);
  }

  rotateLeft(speed: number = 15): MapState {
    return this._getUpdatedState({
      bearing: this.getViewportProps().bearing - speed
    });
  }

  rotateRight(speed: number = 15): MapState {
    return this._getUpdatedState({
      bearing: this.getViewportProps().bearing + speed
    });
  }

  rotateUp(speed: number = 10): MapState {
    return this._getUpdatedState({
      pitch: this.getViewportProps().pitch + speed
    });
  }

  rotateDown(speed: number = 10): MapState {
    return this._getUpdatedState({
      pitch: this.getViewportProps().pitch - speed
    });
  }

  shortestPathFrom(viewState: MapState): MapStateProps {
    // const endViewStateProps = new this.ControllerState(endProps).shortestPathFrom(startViewstate);
    const fromProps = viewState.getViewportProps();
    const props = {...this.getViewportProps()};
    const {bearing, longitude} = props;

    if (Math.abs(bearing - fromProps.bearing) > 180) {
      props.bearing = bearing < 0 ? bearing + 360 : bearing - 360;
    }
    if (Math.abs(longitude - fromProps.longitude) > 180) {
      props.longitude = longitude < 0 ? longitude + 360 : longitude - 360;
    }
    return props;
  }

  // Apply any constraints (mathematical or defined by _viewportProps) to map state
  applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
    // Ensure pitch is within specified range
    const {maxPitch, minPitch, pitch, longitude, bearing, normalize, maxBounds} = props;

    if (normalize) {
      if (longitude < -180 || longitude > 180) {
        props.longitude = mod(longitude + 180, 360) - 180;
      }
      if (bearing < -180 || bearing > 180) {
        props.bearing = mod(bearing + 180, 360) - 180;
      }
    }
    props.pitch = clamp(pitch, minPitch, maxPitch);

    props.zoom = this._constrainZoom(props.zoom, props);

    if (maxBounds) {
      const bl = lngLatToWorld(maxBounds[0]);
      const tr = lngLatToWorld(maxBounds[1]);
      // calculate center and zoom ranges at pitch=0 and bearing=0
      // to maintain visual stability when rotating
      const scale = 2 ** props.zoom;
      const halfWidth = props.width / 2 / scale;
      const halfHeight = props.height / 2 / scale;
      const [minLng, minLat] = worldToLngLat([bl[0] + halfWidth, bl[1] + halfHeight]);
      const [maxLng, maxLat] = worldToLngLat([tr[0] - halfWidth, tr[1] - halfHeight]);
      props.longitude = clamp(props.longitude, minLng, maxLng);
      props.latitude = clamp(props.latitude, minLat, maxLat);
    }

    return props;
  }

  /* Private methods */

  _constrainZoom(zoom: number, props?: Required<MapStateProps>): number {
    props ||= this.getViewportProps();
    const {maxZoom, maxBounds} = props;

    const shouldApplyMaxBounds = maxBounds !== null && props.width > 0 && props.height > 0;
    let {minZoom} = props;

    if (shouldApplyMaxBounds) {
      const bl = lngLatToWorld(maxBounds[0]);
      const tr = lngLatToWorld(maxBounds[1]);
      const w = tr[0] - bl[0];
      const h = tr[1] - bl[1];
      // ignore bound size of 0 or Infinity
      if (Number.isFinite(w) && w > 0) {
        minZoom = Math.max(minZoom, Math.log2(props.width / w));
      }
      if (Number.isFinite(h) && h > 0) {
        minZoom = Math.max(minZoom, Math.log2(props.height / h));
      }
      if (minZoom > maxZoom) minZoom = maxZoom;
    }
    return clamp(zoom, minZoom, maxZoom);
  }

  _zoomFromCenter(scale) {
    const {width, height} = this.getViewportProps();
    return this.zoom({
      pos: [width / 2, height / 2],
      scale
    });
  }

  _panFromCenter(offset) {
    const {width, height} = this.getViewportProps();
    return this.pan({
      startPos: [width / 2, height / 2],
      pos: [width / 2 + offset[0], height / 2 + offset[1]]
    });
  }

  _getUpdatedState(newProps): MapState {
    // @ts-ignore
    return new this.constructor({
      makeViewport: this.makeViewport,
      ...this.getViewportProps(),
      ...this.getState(),
      ...newProps
    });
  }

  _unproject(pos?: [number, number]): [number, number] | undefined {
    const viewport = this.makeViewport(this.getViewportProps());
    // @ts-ignore
    return pos && viewport.unproject(pos);
  }

  _unproject3D(pos: [number, number], altitude: number): [number, number, number] {
    const viewport = this.makeViewport(this.getViewportProps());
    return viewport.unproject(pos, {targetZ: altitude}) as [number, number, number];
  }

  _getNewRotation(
    pos: [number, number],
    startPos: [number, number],
    startPitch: number,
    startBearing: number
  ): {
    pitch: number;
    bearing: number;
  } {
    const deltaX = pos[0] - startPos[0];
    const deltaY = pos[1] - startPos[1];
    const centerY = pos[1];
    const startY = startPos[1];
    const {width, height} = this.getViewportProps();

    const deltaScaleX = deltaX / width;
    let deltaScaleY = 0;

    if (deltaY > 0) {
      if (Math.abs(height - startY) > PITCH_MOUSE_THRESHOLD) {
        // Move from 0 to -1 as we drag upwards
        deltaScaleY = (deltaY / (startY - height)) * PITCH_ACCEL;
      }
    } else if (deltaY < 0) {
      if (startY > PITCH_MOUSE_THRESHOLD) {
        // Move from 0 to 1 as we drag upwards
        deltaScaleY = 1 - centerY / startY;
      }
    }
    // clamp deltaScaleY to [-1, 1] so that rotation is constrained between minPitch and maxPitch.
    // deltaScaleX does not need to be clamped as bearing does not have constraints.
    deltaScaleY = clamp(deltaScaleY, -1, 1);

    const {minPitch, maxPitch} = this.getViewportProps();

    const bearing = startBearing + 180 * deltaScaleX;
    let pitch = startPitch;
    if (deltaScaleY > 0) {
      // Gradually increase pitch
      pitch = startPitch + deltaScaleY * (maxPitch - startPitch);
    } else if (deltaScaleY < 0) {
      // Gradually decrease pitch
      pitch = startPitch - deltaScaleY * (minPitch - startPitch);
    }

    return {
      pitch,
      bearing
    };
  }
}

export default class MapController extends Controller<MapState> {
  ControllerState = MapState;

  transition = {
    transitionDuration: 300,
    transitionInterpolator: new LinearInterpolator({
      transitionProps: {
        compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
        required: ['longitude', 'latitude', 'zoom']
      }
    })
  };

  dragMode: 'pan' | 'rotate' = 'pan';

  /**
   * Rotation pivot behavior:
   * - 'center': Rotate around viewport center (default)
   * - '2d': Rotate around pointer position at ground level (z=0)
   * - '3d': Rotate around 3D picked point (requires pickPosition callback)
   */
  protected rotationPivot: 'center' | '2d' | '3d' = 'center';

  setProps(
    props: ControllerProps &
      MapStateProps & {
        rotationPivot?: 'center' | '2d' | '3d';
        getAltitude?: (pos: [number, number]) => number | undefined;
      }
  ) {
    if ('rotationPivot' in props) {
      this.rotationPivot = props.rotationPivot || 'center';
    }
    // this will be passed to MapState constructor
    props.getAltitude = this._getAltitude;
    props.position = props.position || [0, 0, 0];
    props.maxBounds =
      props.maxBounds || (props.normalize === false ? null : WEB_MERCATOR_MAX_BOUNDS);

    super.setProps(props);
  }

  protected updateViewport(
    newControllerState: MapState,
    extraProps: Record<string, any> | null = null,
    interactionState: InteractionState = {}
  ): void {
    // Inject rotation pivot position during rotation for visual feedback
    const state = newControllerState.getState();
    if (interactionState.isDragging && state.startRotateLngLat) {
      interactionState = {
        ...interactionState,
        rotationPivotPosition: state.startRotateLngLat
      };
    } else if (interactionState.isDragging === false) {
      // Clear pivot when drag ends
      interactionState = {...interactionState, rotationPivotPosition: undefined};
    }

    super.updateViewport(newControllerState, extraProps, interactionState);
  }

  /** Add altitude to rotateStart params based on rotationPivot mode */
  protected _getAltitude = (pos: [number, number]): number | undefined => {
    if (this.rotationPivot === '2d') {
      return 0;
    } else if (this.rotationPivot === '3d') {
      if (this.pickPosition) {
        const {x, y} = this.props;
        const pickResult = this.pickPosition(x + pos[0], y + pos[1]);
        if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) {
          return pickResult.coordinate[2];
        }
      }
    }
    return undefined;
  };
}
