import React, {
  forwardRef,
  memo,
  useCallback,
  useImperativeHandle,
  useMemo,
  useRef,
} from 'react';
import { NativeModules, requireNativeComponent } from 'react-native';
import { Position } from '@turf/helpers';

import { UserTrackingModeChangeCallback, UserTrackingMode } from '../types';
import { makeLatLngBounds, makePoint } from '../utils/geoUtils';

const NativeModule = NativeModules.MGLModule;

/**
 * Converts the provided React Native animation mode into the corresponding native enum value.
 */
const nativeAnimationMode = (
  mode?: CameraAnimationMode,
): NativeAnimationMode => {
  const NativeCameraModes = NativeModule.CameraModes;

  switch (mode) {
    case 'flyTo':
      return NativeCameraModes.Flight;
    case 'easeTo':
      return NativeCameraModes.Ease;
    case 'linearTo':
      return NativeCameraModes.Linear;
    case 'moveTo':
      return NativeCameraModes.Move;
    case 'none':
      return NativeCameraModes.None;
    default:
      return NativeCameraModes.Ease;
  }
};

export const NATIVE_MODULE_NAME = 'RCTMGLCamera';

// Native module types.

type NativeAnimationMode = 'flight' | 'ease' | 'linear' | 'none' | 'move';

interface NativeCameraProps extends CameraFollowConfig {
  testID?: string;
  stop: NativeCameraStop | null;
  animationDuration?: number;
  animationMode?: CameraAnimationMode;
  defaultStop?: NativeCameraStop | null;
  minZoomLevel?: number;
  maxZoomLevel?: number;
  maxBounds?: string | null;
  onUserTrackingModeChange?: UserTrackingModeChangeCallback;
}

interface NativeCameraStop {
  centerCoordinate?: string;
  bounds?: string;
  heading?: number;
  pitch?: number;
  zoom?: number;
  paddingLeft?: number;
  paddingRight?: number;
  paddingTop?: number;
  paddingBottom?: number;
  duration?: number;
  mode?: NativeAnimationMode;
}

export interface CameraRef {
  setCamera: (config: CameraStop | CameraStops) => void;
  fitBounds: (
    ne: Position,
    sw: Position,
    paddingConfig?: number | number[],
    animationDuration?: number,
  ) => void;
  flyTo: (centerCoordinate: Position, animationDuration?: number) => void;
  moveTo: (centerCoordinate: Position, animationDuration?: number) => void;
  zoomTo: (zoomLevel: number, animationDuration?: number) => void;
}

export type CameraStop = {
  /** Allows static check of the data type. For internal use only. */
  readonly type?: 'CameraStop';
  /** The location on which the map should center. */
  centerCoordinate?: Position;
  /** The corners of a box around which the map should bound. Contains padding props for backwards
   * compatibility; the root `padding` prop should be used instead. */
  bounds?: CameraBoundsWithPadding;
  /** The heading (orientation) of the map. */
  heading?: number;
  /** The pitch of the map. */
  pitch?: number;
  /** The zoom level of the map. */
  zoomLevel?: number;
  /** The viewport padding in points. */
  padding?: CameraPadding;
  /** The duration the map takes to animate to a new configuration. */
  animationDuration?: number;
  /** The easing or path the camera uses to animate to a new configuration. */
  animationMode?: CameraAnimationMode;
};

export type CameraFollowConfig = {
  /** The mode used to track the user location on the map. */
  followUserMode?: UserTrackingMode;
  /** Whether the map orientation follows the user location. */
  followUserLocation?: boolean;
  /** The zoom level used when following the user location. */
  followZoomLevel?: number;
  /** The pitch used when following the user location. */
  followPitch?: number;
  /** The heading used when following the user location. */
  followHeading?: number;
};

export type CameraMinMaxConfig = {
  /** The lowest allowed zoom level. */
  minZoomLevel?: number;
  /** The highest allowed zoom level. */
  maxZoomLevel?: number;
  /** The corners of a box defining the limits of where the camera can pan or zoom. */
  maxBounds?: {
    ne: Position;
    sw: Position;
  };
};

export interface CameraProps
  extends CameraStop,
    CameraFollowConfig,
    CameraMinMaxConfig {
  /** The configuration that the camera falls back on, if no other values are specified. */
  defaultSettings?: CameraStop;
  /** Whether the camera should send any configuration to the native module. Prevents unnecessary tile
   * fetching and improves performance when the map is not visible. Defaults to `true`. */
  allowUpdates?: boolean;
  /** Any arbitrary primitive value that, when changed, causes the camera to retry moving to its target
   * configuration. (Not yet implemented.) */
  triggerKey?: string | number;
  /** Executes when user tracking mode changes. */
  onUserTrackingModeChange?: UserTrackingModeChangeCallback;
}

export type CameraBounds = {
  ne: Position;
  sw: Position;
};

export type CameraPadding = {
  paddingLeft: number;
  paddingRight: number;
  paddingTop: number;
  paddingBottom: number;
};

export type CameraBoundsWithPadding = Partial<CameraPadding> & CameraBounds;

export type CameraStops = {
  /** Allows static check of the data type. For internal use only. */
  readonly type: 'CameraStops';
  stops: CameraStop[];
};

export type CameraAnimationMode =
  | 'flyTo'
  | 'easeTo'
  | 'linearTo'
  | 'moveTo'
  | 'none';

/**
 * Controls the perspective from which the user sees the map.
 *
 * To use imperative methods, pass in a ref object:
 *
 * ```
 * const camera = useRef<Camera>(null);
 *
 * useEffect(() => {
 *   camera.current?.setCamera({
 *     centerCoordinate: [lon, lat],
 *   });
 * }, []);
 *
 * return (
 *   <Camera ref={camera} />
 * );
 * ```
 */
export const Camera = memo(
  forwardRef<CameraRef, CameraProps>(
    (props: CameraProps, ref: React.ForwardedRef<CameraRef>) => {
      const {
        centerCoordinate,
        bounds,
        heading,
        pitch,
        zoomLevel,
        padding,
        animationDuration,
        animationMode,
        minZoomLevel,
        maxZoomLevel,
        maxBounds,
        followUserLocation,
        followUserMode,
        followZoomLevel,
        followPitch,
        followHeading,
        defaultSettings,
        allowUpdates = true,
        triggerKey,
        onUserTrackingModeChange,
      } = props;

      // @ts-expect-error This avoids a type/value mismatch.
      const nativeCamera = useRef<RCTMGLCamera>(null);

      const buildNativeStop = useCallback(
        (
          stop: CameraStop,
          ignoreFollowUserLocation = false,
        ): NativeCameraStop | null => {
          stop = {
            ...stop,
            type: 'CameraStop',
          };

          if (props.followUserLocation && !ignoreFollowUserLocation) {
            return null;
          }

          const _nativeStop: NativeCameraStop = {};

          if (stop.pitch !== undefined) _nativeStop.pitch = stop.pitch;
          if (stop.heading !== undefined) _nativeStop.heading = stop.heading;
          if (stop.zoomLevel !== undefined) _nativeStop.zoom = stop.zoomLevel;
          if (stop.animationMode !== undefined)
            _nativeStop.mode = nativeAnimationMode(stop.animationMode);
          if (stop.animationDuration !== undefined)
            _nativeStop.duration = stop.animationDuration;

          if (stop.centerCoordinate) {
            _nativeStop.centerCoordinate = JSON.stringify(
              makePoint(stop.centerCoordinate),
            );
          }

          if (stop.bounds && stop.bounds.ne && stop.bounds.sw) {
            const { ne, sw } = stop.bounds;
            _nativeStop.bounds = JSON.stringify(makeLatLngBounds(ne, sw));
          }

          _nativeStop.paddingTop =
            stop.padding?.paddingTop ?? stop.bounds?.paddingTop ?? 0;
          _nativeStop.paddingRight =
            stop.padding?.paddingRight ?? stop.bounds?.paddingRight ?? 0;
          _nativeStop.paddingBottom =
            stop.padding?.paddingBottom ?? stop.bounds?.paddingBottom ?? 0;
          _nativeStop.paddingLeft =
            stop.padding?.paddingLeft ?? stop.bounds?.paddingLeft ?? 0;

          return _nativeStop;
        },
        [props.followUserLocation],
      );

      const nativeDefaultStop = useMemo((): NativeCameraStop | null => {
        if (!defaultSettings) {
          return null;
        }
        return buildNativeStop(defaultSettings);
      }, [defaultSettings, buildNativeStop]);

      const nativeStop = useMemo(() => {
        return buildNativeStop({
          type: 'CameraStop',
          centerCoordinate,
          bounds,
          heading,
          pitch,
          zoomLevel,
          padding,
          animationDuration,
          animationMode,
        });
      }, [
        centerCoordinate,
        bounds,
        heading,
        pitch,
        zoomLevel,
        padding,
        animationDuration,
        animationMode,
        buildNativeStop,
      ]);

      const nativeMaxBounds = useMemo(() => {
        if (!maxBounds?.ne || !maxBounds?.sw) {
          return null;
        }
        return JSON.stringify(makeLatLngBounds(maxBounds.ne, maxBounds.sw));
      }, [maxBounds]);

      const _setCamera: CameraRef['setCamera'] = (config) => {
        if (!allowUpdates) {
          return;
        }

        if (!config.type)
          // @ts-expect-error The compiler doesn't understand that the `config` union type is guaranteed
          // to be an object type.
          config = {
            ...config,
            // @ts-expect-error Allows JS files to pass in an invalid config (lacking the `type` property),
            // which would raise a compilation error in TS files.
            type: config.stops ? 'CameraStops' : 'CameraStop',
          };

        if (config.type === 'CameraStops') {
          for (const _stop of config.stops) {
            let _nativeStops: NativeCameraStop[] = [];
            const _nativeStop = buildNativeStop(_stop);
            if (_nativeStop) {
              _nativeStops = [..._nativeStops, _nativeStop];
            }
            nativeCamera.current.setNativeProps({
              stop: { stops: _nativeStops },
            });
          }
        } else if (config.type === 'CameraStop') {
          const _nativeStop = buildNativeStop(config);
          if (_nativeStop) {
            nativeCamera.current.setNativeProps({ stop: _nativeStop });
          }
        }
      };
      const setCamera = useCallback(_setCamera, [
        allowUpdates,
        buildNativeStop,
      ]);

      const _fitBounds: CameraRef['fitBounds'] = (
        ne,
        sw,
        paddingConfig = 0,
        _animationDuration = 0,
      ) => {
        let _padding = {
          paddingTop: 0,
          paddingBottom: 0,
          paddingLeft: 0,
          paddingRight: 0,
        };

        if (typeof paddingConfig === 'object') {
          if (paddingConfig.length === 2) {
            _padding = {
              paddingTop: paddingConfig[0],
              paddingBottom: paddingConfig[0],
              paddingLeft: paddingConfig[1],
              paddingRight: paddingConfig[1],
            };
          } else if (paddingConfig.length === 4) {
            _padding = {
              paddingTop: paddingConfig[0],
              paddingBottom: paddingConfig[2],
              paddingLeft: paddingConfig[3],
              paddingRight: paddingConfig[1],
            };
          }
        } else if (typeof paddingConfig === 'number') {
          _padding = {
            paddingTop: paddingConfig,
            paddingBottom: paddingConfig,
            paddingLeft: paddingConfig,
            paddingRight: paddingConfig,
          };
        }

        setCamera({
          type: 'CameraStop',
          bounds: {
            ne,
            sw,
          },
          padding: _padding,
          animationDuration: _animationDuration,
          animationMode: 'easeTo',
        });
      };
      const fitBounds = useCallback(_fitBounds, [setCamera]);

      const _flyTo: CameraRef['flyTo'] = (
        _centerCoordinate,
        _animationDuration = 2000,
      ) => {
        setCamera({
          type: 'CameraStop',
          centerCoordinate: _centerCoordinate,
          animationDuration: _animationDuration,
        });
      };
      const flyTo = useCallback(_flyTo, [setCamera]);

      const _moveTo: CameraRef['moveTo'] = (
        _centerCoordinate,
        _animationDuration = 0,
      ) => {
        setCamera({
          type: 'CameraStop',
          centerCoordinate: _centerCoordinate,
          animationDuration: _animationDuration,
          animationMode: 'easeTo',
        });
      };
      const moveTo = useCallback(_moveTo, [setCamera]);

      const _zoomTo: CameraRef['zoomTo'] = (
        _zoomLevel,
        _animationDuration = 2000,
      ) => {
        setCamera({
          type: 'CameraStop',
          zoomLevel: _zoomLevel,
          animationDuration: _animationDuration,
          animationMode: 'flyTo',
        });
      };
      const zoomTo = useCallback(_zoomTo, [setCamera]);

      useImperativeHandle(ref, () => ({
        /**
         * Sets any camera properties, with default fallbacks if unspecified.
         *
         * @example
         * camera.current?.setCamera({
         *   centerCoordinate: [lon, lat],
         * });
         *
         * @param {CameraStop | CameraStops} config
         */
        setCamera,
        /**
         * Set the camera position to enclose the provided bounds, with optional
         * padding and duration.
         *
         * @example
         * camera.fitBounds([lon, lat], [lon, lat]);
         * camera.fitBounds([lon, lat], [lon, lat], [20, 0], 1000);
         *
         * @param {Position} ne Northeast coordinate of bounding box
         * @param {Position} sw Southwest coordinate of bounding box
         * @param {number | number[]} paddingConfig The viewport padding, specified as a number (all sides equal), a 2-item array ([vertical, horizontal]), or a 4-item array ([top, right, bottom, left])
         * @param {number} animationDuration The transition duration
         */
        fitBounds,
        /**
         * Sets the camera to center around the provided coordinate using a realistic 'travel'
         * animation, with optional duration.
         *
         * @example
         * camera.flyTo([lon, lat]);
         * camera.flyTo([lon, lat], 12000);
         *
         *  @param {Position} centerCoordinate The coordinate to center in the view
         *  @param {number} animationDuration The transition duration
         */
        flyTo,
        /**
         * Sets the camera to center around the provided coordinate, with optional duration.
         *
         * @example
         * camera.moveTo([lon, lat], 200);
         * camera.moveTo([lon, lat]);
         *
         *  @param {Position} centerCoordinate The coordinate to center in the view
         *  @param {number} animationDuration The transition duration
         */
        moveTo,
        /**
         * Zooms the camera to the provided level, with optional duration.
         *
         * @example
         * camera.zoomTo(16);
         * camera.zoomTo(16, 100);
         *
         * @param {number} zoomLevel The target zoom
         * @param {number} animationDuration The transition duration
         */
        zoomTo,
      }));

      return (
        <RCTMGLCamera
          testID={'Camera'}
          ref={nativeCamera}
          stop={nativeStop}
          animationDuration={animationDuration}
          animationMode={animationMode}
          defaultStop={nativeDefaultStop}
          followUserLocation={followUserLocation}
          followUserMode={followUserMode}
          followPitch={followPitch}
          followHeading={followHeading}
          followZoomLevel={followZoomLevel}
          minZoomLevel={minZoomLevel}
          maxZoomLevel={maxZoomLevel}
          maxBounds={nativeMaxBounds}
          onUserTrackingModeChange={onUserTrackingModeChange}
        />
      );
    },
  ),
);

const RCTMGLCamera =
  requireNativeComponent<NativeCameraProps>(NATIVE_MODULE_NAME);

export type Camera = CameraRef;
