// @flow
import {isEqual} from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import type {ChildrenArray, Element, ElementRef} from 'react';
import {injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import {compose} from 'redux';

import {wrapConfigElements} from 'javascript/anatomy/util/ConfigElements';

import {GoogleMapLoader} from './GoogleMapLoader';
import {Marker} from './Marker';
import {createReactOverlay} from './ReactOverlay';
import {config} from './config';
import type {
  GoogleMap,
  LatLng,
  LatLngBounds,
  LatLngBoundsLiteral,
  LatLngLiteral,
} from './types';

import css from './Map.scss';

type Props = {
  // If you include defaultCenter/defaultZoom, remember to include *both* - they are required to load a map if you
  // initialize a map with them.
  defaultCenter?: LatLngLiteral,
  defaultZoom?: number,
  elements: {
    Markers?: {
      children: ChildrenArray<Element<typeof Marker>>,
    },
    ZoomControl?: {},
  },
  intl: IntlShape,
  onBoundsChanged?: (bounds: LatLngBoundsLiteral) => void,
  onMapLoad?: () => void,
};

type State = {
  loaded: boolean,
};

/**
 * Map is a wrapper on top of a Google Map. It provides functionality for rendering a map and map markers as well as
 * a custom zoom control and refresh results trigger. Custom map options, max/min zoom, and basemap styling are *not*
 * supported.
 *
 * NOTE: To set a ref on the map component, use wrappedComponentRef instead of ref. This is due to the way
 * wrapConfigElements works.
 *
 * Public methods that you can call with a reference to the map component:
 *   - getBounds: Gets the map's current bounding box (LatLngBounds return type).
 *   - getCenter: Gets the map's current center (LatLng return type).
 *   - getZoom: Gets the map's current zoom (number return type).
 *   - fitBoundsToMarkers: Takes in a list of marker IDs and fits the map's bounds to the associated markers.
 *   - setBounds: Takes in a LatLngBounds and fits the map to them.
 *
 * Usage:
 *   <Map
 *     defaultCenter={{lat: 40.714286, lng: -73.998864}}
 *     defaultZoom={5}
 *     wrappedComponentRef={this.setSomeRef}
 *   >
 *     <Map.Markers>
 *       {markers}
 *     </Map.Markers>
 *     <Map.ZoomControl />
 *   </Map
 */
class MapComponent extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props);
    const validPosition = !!props.defaultCenter === !!props.defaultZoom;

    if (!validPosition) {
      throw new Error('Must include both defaultCenter and defaultZoom or neither');
    }
  }

  state = {
    loaded: false,
  };

  componentDidMount() {
    GoogleMapLoader.load(this._onMapLoad, this.props.intl.locale);
  }

  componentDidUpdate(prevProps: Props) {
    if (!this.state.loaded) {
      return;
    }

    const prevMarkers = React.Children.toArray(
      prevProps.elements.Markers?.children || []
    );
    const currMarkers = React.Children.toArray(
      this.props.elements.Markers?.children || []
    );

    const markersDeepEqual = isEqual(
      prevMarkers.map((marker) => marker.props),
      currMarkers.map((marker) => marker.props)
    );
    const markerIdsEqual = isEqual(
      prevMarkers.map((marker) => marker.props.id),
      currMarkers.map((marker) => marker.props.id)
    );

    // Redraw the overlays if there's any change in the marker's props. This can be caused by changes in hover states
    // or the tooltip.
    if (!markersDeepEqual) {
      this._drawOverlays();
    }
    // Fit map bounds if the marker IDs have changed. This signifies any changes in the markers themselves (and
    // potentially the position of the map).
    if (!markerIdsEqual) {
      this._fitMapBounds(currMarkers);
    }
  }

  componentWillUnmount() {
    window.google.maps.event.clearInstanceListeners(this._map);
  }

  _map: GoogleMap;
  _mapElement: ElementRef<any> = React.createRef();
  _overlayById: {[string]: any} = {};

  _setMapElement = (mapElement: ElementRef<any>) => (this._mapElement = mapElement);

  _onBoundsChanged = () => {
    const {onBoundsChanged} = this.props;
    if (onBoundsChanged) {
      onBoundsChanged(this._map.getBounds().toJSON());
    }
  };

  _onUserMapChange = () => {
    this._onBoundsChanged();
  };

  _onMapLoad = () => {
    const {defaultCenter, defaultZoom, onMapLoad} = this.props;
    const mapOptions = config.DEFAULT_MAP_OPTIONS;

    if (defaultCenter) {
      mapOptions.center = defaultCenter;
    }
    if (defaultZoom) {
      mapOptions.zoom = defaultZoom;
    }

    this._map = new window.google.maps.Map(this._mapElement.current, mapOptions);

    window.google.maps.event.addListener(this._map, 'dragend', this._onUserMapChange);
    window.google.maps.event.addListener(
      this._map,
      'zoom_changed',
      this._onUserMapChange
    );

    // Render initial markers, if any.
    this._drawOverlays();

    this.setState({loaded: true}, () => {
      if (onMapLoad) {
        onMapLoad();
      }
    });
  };

  _drawOverlays = () => {
    const markers = React.Children.toArray(this.props.elements.Markers?.children || []);
    const existingIds = Object.keys(this._overlayById);
    const newIds = [];

    markers.forEach((marker) => {
      const currOverlay = this._overlayById[marker.props.id];

      newIds.push(marker.props.id);

      // NOTE(chrisng): If we run into performance issues, think about making currOverlay a React component and using
      // ReactDOM.render instead.
      if (currOverlay) {
        // Update the React component in case it changed and then redraw.
        currOverlay.redrawWithComponent(marker);
      } else {
        this._overlayById[marker.props.id] = createReactOverlay({
          id: marker.props.id,
          map: this._map,
          position: marker.props.position,
          reactComponent: marker,
        });
      }
    });

    // Delete overlays that should no longer exist.
    existingIds.forEach((id) => {
      if (newIds.indexOf(id) === -1) {
        this._overlayById[id].remove();
        delete this._overlayById[id];
      }
    });
  };

  _fitMapBounds = (markers: Array<Element<typeof Marker>>) => {
    // Fit bounds iff there are markers. If we fit bounds when there are no markers, we get a blank screen on the map
    // because there is no bounding box.
    if (markers.length) {
      const bounds = new window.google.maps.LatLngBounds();
      markers.forEach((marker) => {
        bounds.extend(marker.props.position);
      });
      this._map.fitBounds(bounds);

      this._onBoundsChanged();
    }
  };

  getBounds = (): LatLngBounds => this._map.getBounds();

  getCenter = (): LatLng => this._map.getCenter();

  getZoom = (): number => this._map.getZoom();

  fitBoundsToMarkers = (ids: string[]) => {
    const markers = ids
      .filter((id) => {
        const overlay = this._overlayById[id];
        if (!overlay) {
          if (__DEBUG__) {
            // eslint-disable-next-line no-console
            console.error(`Could not find marker with ID ${id}`);
          }
          return false;
        }
        return true;
      })
      .map((id) => this._overlayById[id].reactComponent);

    this._fitMapBounds(markers);
  };

  setBounds = (bounds: LatLngBounds) => {
    if (!this.state.loaded) {
      return;
    }
    this._map.fitBounds(bounds);
  };

  setCenter = (center: LatLng) => {
    if (!this.state.loaded) {
      return;
    }
    this._map.setCenter(center);
  };

  setZoom = (zoom: number) => {
    if (!this.state.loaded) {
      return;
    }
    this._map.setZoom(zoom);
  };

  // We initialize Google Maps with a min/max zoom, so min/max zoom is handled for us.
  _zoomIn = () => this._map.setZoom(this._map.getZoom() + 1);
  _zoomOut = () => this._map.setZoom(this._map.getZoom() - 1);

  _renderZoomControl() {
    if (!this.props.elements.ZoomControl) {
      return null;
    }

    return (
      <div className={css.zoomControlContainer}>
        <div className={css.zoomInClickable} onClick={this._zoomIn}>
          <div className={css.zoomInContainer}>
            <img
              alt="zoom-in-map"
              className={css.zoomInButton}
              src="https://maps.gstatic.com/mapfiles/api-3/images/tmapctrl_hdpi.png"
            />
          </div>
        </div>
        <div className={css.zoomSplit} />
        <div className={css.zoomOutClickable} onClick={this._zoomOut}>
          <div className={css.zoomOutContainer}>
            <img
              alt="zoom-out-map"
              className={css.zoomOutButton}
              src="https://maps.gstatic.com/mapfiles/api-3/images/tmapctrl_hdpi.png"
            />
          </div>
        </div>
      </div>
    );
  }

  render() {
    return (
      <div className={css.map}>
        {this._renderZoomControl()}
        <div className={css.map} ref={this._mapElement} />
      </div>
    );
  }
}

const Map = compose(
  wrapConfigElements({
    Markers: {
      children: PropTypes.node,
    },
    ZoomControl: {},
  }),
  (component) => injectIntl(component, {withRef: true})
)(MapComponent);

export {Map, MapComponent};