import {Ref, useEffect, useRef, useState} from 'react';

import {MapProps} from '../map';
import {APIProviderContextValue} from '../api-provider';

import {useCallbackRef} from '../../hooks/use-callback-ref';
import {useApiIsLoaded} from '../../hooks/use-api-is-loaded';
import {
  CameraState,
  CameraStateRef,
  useTrackedCameraStateRef
} from './use-tracked-camera-state-ref';

/**
 * Stores a stack of map-instances for each mapId. Whenever an
 * instance is used, it is removed from the stack while in use,
 * and returned to the stack when the component unmounts.
 * This allows us to correctly implement caching for multiple
 * maps om the same page, while reusing as much as possible.
 *
 * FIXME: while it should in theory be possible to reuse maps solely
 *   based on mapId/renderingType/colorScheme (as all other parameters can be
 *   changed at runtime), we don't yet have good enough tracking of options to
 *   reliably unset all the options that have been set.
 */
class CachedMapStack {
  static entries: {[key: string]: google.maps.Map[]} = {};

  static has(key: string) {
    return this.entries[key] && this.entries[key].length > 0;
  }

  static pop(key: string) {
    if (!this.entries[key]) return null;

    return this.entries[key].pop() || null;
  }

  static push(key: string, value: google.maps.Map) {
    if (!this.entries[key]) this.entries[key] = [];

    this.entries[key].push(value);
  }
}

/**
 * The main hook takes care of creating map-instances and registering them in
 * the api-provider context.
 * @return a tuple of the map-instance created (or null) and the callback
 *   ref that will be used to pass the map-container into this hook.
 * @internal
 */
export function useMapInstance(
  props: MapProps,
  context: APIProviderContextValue
): readonly [
  map: google.maps.Map | null,
  containerRef: Ref<HTMLDivElement>,
  cameraStateRef: CameraStateRef
] {
  const apiIsLoaded = useApiIsLoaded();
  const [map, setMap] = useState<google.maps.Map | null>(null);
  const [container, containerRef] = useCallbackRef<HTMLDivElement>();

  const cameraStateRef = useTrackedCameraStateRef(map);

  const {
    id,
    defaultBounds,
    defaultCenter,
    defaultZoom,
    defaultHeading,
    defaultTilt,
    reuseMaps,
    renderingType,
    colorScheme,

    ...mapOptions
  } = props;

  const hasZoom = props.zoom !== undefined || props.defaultZoom !== undefined;
  const hasCenter =
    props.center !== undefined || props.defaultCenter !== undefined;

  if (!defaultBounds && (!hasZoom || !hasCenter)) {
    console.warn(
      '<Map> component is missing configuration. ' +
        'You have to provide zoom and center (via the `zoom`/`defaultZoom` and ' +
        '`center`/`defaultCenter` props) or specify the region to show using ' +
        '`defaultBounds`. See ' +
        'https://visgl.github.io/react-google-maps/docs/api-reference/components/map#required'
    );
  }

  // apply default camera props if available and not overwritten by controlled props
  if (!mapOptions.center && defaultCenter) mapOptions.center = defaultCenter;
  if (!mapOptions.zoom && Number.isFinite(defaultZoom))
    mapOptions.zoom = defaultZoom;
  if (!mapOptions.heading && Number.isFinite(defaultHeading))
    mapOptions.heading = defaultHeading;
  if (!mapOptions.tilt && Number.isFinite(defaultTilt))
    mapOptions.tilt = defaultTilt;

  // Handle internalUsageAttributionIds
  const customIds = mapOptions.internalUsageAttributionIds;

  if (customIds == null) {
    // Not specified - use context default (which may be null if disabled)
    mapOptions.internalUsageAttributionIds =
      context.internalUsageAttributionIds;
  } else {
    // Merge context defaults with custom IDs
    mapOptions.internalUsageAttributionIds = [
      ...(context.internalUsageAttributionIds || []),
      ...customIds
    ];
  }

  for (const key of Object.keys(mapOptions) as (keyof typeof mapOptions)[])
    if (mapOptions[key] === undefined) delete mapOptions[key];

  const savedMapStateRef = useRef<{
    mapId?: string | null;
    cameraState: CameraState;
  }>(undefined);

  // create the map instance and register it in the context
  useEffect(
    () => {
      if (!container || !apiIsLoaded) return;

      const {addMapInstance, removeMapInstance} = context;

      // note: colorScheme (upcoming feature) isn't yet in the typings, remove once that is fixed:
      const {mapId} = props;
      const cacheKey = `${mapId || 'default'}:${renderingType || 'default'}:${colorScheme || 'LIGHT'}`;

      let mapDiv: HTMLElement;
      let map: google.maps.Map;

      if (reuseMaps && CachedMapStack.has(cacheKey)) {
        map = CachedMapStack.pop(cacheKey) as google.maps.Map;
        mapDiv = map.getDiv();

        container.appendChild(mapDiv);
        map.setOptions(mapOptions);

        // detaching the element from the DOM sometimes causes the map to collapse
        // and no longer render tiles that should be in view after re-attaching it.
        // Triggering moveCamera after remounting should trigger a re-layout of
        // the map.
        setTimeout(() => map.moveCamera({}), 0);
      } else {
        mapDiv = document.createElement('div');
        mapDiv.style.height = '100%';
        container.appendChild(mapDiv);

        map = new google.maps.Map(mapDiv, {
          ...mapOptions,
          ...(renderingType
            ? {renderingType: renderingType as google.maps.RenderingType}
            : {}),
          ...(colorScheme
            ? {colorScheme: colorScheme as google.maps.ColorScheme}
            : {})
        });
      }

      setMap(map);
      addMapInstance(map, id);

      if (defaultBounds) {
        const {padding, ...defBounds} = defaultBounds;
        map.fitBounds(defBounds, padding);
      }

      // prevent map not rendering due to missing configuration
      else if (!hasZoom || !hasCenter) {
        map.fitBounds({east: 180, west: -180, south: -90, north: 90});
      }

      // the savedMapState is used to restore the camera parameters when the mapId is changed
      if (savedMapStateRef.current) {
        const {mapId: savedMapId, cameraState: savedCameraState} =
          savedMapStateRef.current;
        if (savedMapId !== mapId) {
          map.moveCamera(savedCameraState);
        }
      }

      return () => {
        savedMapStateRef.current = {
          mapId,
          // eslint-disable-next-line react-hooks/exhaustive-deps
          cameraState: cameraStateRef.current
        };

        // detach the map-div from the dom
        mapDiv.remove();

        if (reuseMaps) {
          // push back on the stack
          CachedMapStack.push(cacheKey, map);
        } else {
          // remove all event-listeners to minimize the possibility of memory-leaks
          google.maps.event.clearInstanceListeners(map);
        }

        setMap(null);
        removeMapInstance(id);
      };
    },

    // some dependencies are ignored in the list below:
    //  - defaultBounds and the default* camera props will only be used once, and
    //    changes should be ignored
    //  - mapOptions has special hooks that take care of updating the options
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      container,
      apiIsLoaded,
      id,

      // these props can't be changed after initialization and require a new
      // instance to be created
      props.mapId,
      props.renderingType,
      props.colorScheme
    ]
  );

  return [map, containerRef, cameraStateRef] as const;
}
