import React, { useCallback, useState } from "react";
import { Map, MapProps } from "react-map-gl/maplibre";
import maplibregl, { MapLayerMouseEvent } from "maplibre-gl";

import { useIntl } from "react-intl";

import * as Styled from "./styled";
import * as util from "./util";
import MarkerWithPopup from "./MarkerWithPopup";
import generateMapControlTranslations from "./mapControlLocale";

/**
 * The BaseMap component renders a MapLibre map
 * as well as markers that are declared as child elements of the BaseMap element.
 *
 * As BaseMap wraps a react-map-gl Map component, any control which can be added as a child of a react-map-gl map is supported.
 * See https://visgl.github.io/react-map-gl/docs/api-reference/map to see which react-map-gl
 * children are shipped by default. Others are also supported.
 *
 * Overlays are groups of similar MapLibre markers, e.g. vehicle location
 * markers, bus stop markers, etc.
 *
 * Overlays are automatically added to the overlay control displayed by the
 * BaseMap. The user uses that control to turn overlays on or off. Only overlays
 * with an id are added to the control.
 */
type Props = React.ComponentPropsWithoutRef<React.ElementType> & {
  /** A URL, or list of URLs pointing to the vector tile specification which should be used as the main map.  */
  baseLayer?: string | string[];
  /** A list of names to match onto the base layers. Used only if there are multiple entries defined for `BaseLayer` */
  baseLayerNames?: string[];
  /** A [lat, lon] position to center the map at. */
  center?: [number, number];
  /** A unique identifier for the map (useful when using MapProvider) */
  id?: string;
  /** An object of props which should be passed down to MapLibre */
  mapLibreProps?: MapProps;
  /** The maximum zoom level the map should allow */
  maxZoom?: number;
  /** A callback method which is fired when the map is clicked with the left mouse button/tapped */
  onClick?: (e: MapLayerMouseEvent) => void;
  /** A callback method which is fired when the layer selector component is clicked with the left mouse button/tapped */
  onClickLayerSelector?: () => void;
  /** A callback method which is fired when the map is clicked with the right mouse button/long tapped */
  onContextMenu?: (e: MapLayerMouseEvent) => void;
  /** A callback method which is fired when the map zoom or map bounds change */
  onViewportChanged?: (e: State) => void;
  /** When set to true, all hidden layers will be removed. No layers will be uncheckable until
   * it is set to false
   */
  showEverything?: boolean;
  /** An initial zoom value for the map */
  zoom?: number;
};
type State = {
  latitude: number;
  longitude: number;
  zoom: number;
};

const BaseMap = ({
  // These tiles are free to use, but not in production
  baseLayer = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
  baseLayerNames,
  center,
  children,
  id,
  mapLibreProps,
  maxZoom,
  onClick,
  onClickLayerSelector,
  onContextMenu,
  showEverything,
  onViewportChanged,
  style,
  zoom: initZoom = 12
}: Props): JSX.Element => {
  const intl = useIntl();

  // Firefox and Safari on iOS: hover is not triggered when the user touches the layer selector
  // (unlike Firefox or Chromium on Android), so we have to detect touch and trigger hover ourselves.
  const [fakeMobileHover, setFakeHover] = useState(false);
  const [longPressTimer, setLongPressTimer] = useState(null);

  const toggleableLayers = Array.isArray(children)
    ? children
        .flat(10)
        .filter(
          child =>
            child?.props?.id !== undefined &&
            // Some sources will not have layers as children, and should be ignored
            // from the list.
            child?.props?.alwaysShow !== true
        )
        .map(child => {
          const { id: layerId, name, visible } = child.props;
          return { id: layerId, name, visible };
        })
    : [];

  const [hiddenLayers, setHiddenLayers] = useState(
    toggleableLayers.filter(layer => !layer?.visible).map(layer => layer.id)
  );
  const computedHiddenLayers =
    showEverything && hiddenLayers.length > 0 ? [] : hiddenLayers;

  const [activeBaseLayer, setActiveBaseLayer] = useState(
    typeof baseLayer === "object" ? baseLayer?.[0] : baseLayer
  );

  const clearLongPressTimer = useCallback(() => clearTimeout(longPressTimer), [
    longPressTimer
  ]);

  const handleViewportChange = useCallback(
    evt => {
      onViewportChanged && onViewportChanged(evt.viewState);
      clearLongPressTimer();
    },
    [clearLongPressTimer, onViewportChanged]
  );

  return (
    <Map
      // eslint-disable-next-line react/jsx-props-no-spreading
      {...mapLibreProps}
      canvasContextAttributes={{
        // This is a copy of the defaults shown at https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/#default-value_5,
        // except for:
        //   contextType = undefined (from the source code)
        //   preserveDrawingBuffer = true to support printing.
        antialias: false,
        desynchronized: false,
        failIfMajorPerformanceCaveat: false,
        powerPreference: "high-performance",
        preserveDrawingBuffer: true
      }}
      id={id}
      initialViewState={{
        latitude: center?.[0],
        longitude: center?.[1],
        zoom: initZoom
      }}
      locale={generateMapControlTranslations(intl)}
      mapLib={maplibregl}
      mapStyle={activeBaseLayer}
      maxZoom={maxZoom}
      onClick={onClick}
      onContextMenu={onContextMenu}
      onMove={handleViewportChange}
      onTouchStart={e => {
        setFakeHover(false);
        // Start detecting long presses on screens when there is only one touch point.
        // If the user is pinching the map or does other multi-touch actions, cancel long-press detection.
        const touchPointCount = e.points.length;
        if (touchPointCount === 1) {
          setLongPressTimer(
            setTimeout(
              // In practice, MapLayerTouchEvent and MapTouchEvent behave as if they were
              // subclasses of MapLayerMouseEvent and MapMouseEvent, respectively,
              // so the conversion from touch to mouse event works, with the caveat that
              // the `type` prop takes different string values between mouse and touch events.
              () => onContextMenu((e as unknown) as MapLayerMouseEvent),
              600
            )
          );
        } else {
          clearLongPressTimer();
        }
      }}
      onTouchCancel={clearLongPressTimer}
      onTouchEnd={clearLongPressTimer}
      onZoom={handleViewportChange}
      style={style}
    >
      {(toggleableLayers.length > 0 ||
        (!!baseLayer &&
          typeof baseLayer === "object" &&
          baseLayer.length > 1)) && (
        <Styled.LayerSelector
          className="filter-group"
          id="filter-group"
          onBlur={() => setFakeHover(false)}
          onClick={onClickLayerSelector}
          onFocus={() => {
            setFakeHover(true);
            if (onClickLayerSelector) onClickLayerSelector();
          }}
          onTouchEnd={() => {
            setFakeHover(true);
            if (onClickLayerSelector) onClickLayerSelector();
          }}
        >
          <ul
            className={`maplibregl-ctrl-group layers-list ${
              fakeMobileHover ? "fake-mobile-hover" : ""
            }`}
          >
            {!!baseLayer &&
              typeof baseLayer === "object" &&
              baseLayer.map((layer: string, index: number) => {
                return (
                  <li key={index}>
                    <label htmlFor={layer}>
                      <input
                        checked={activeBaseLayer === layer}
                        id={layer}
                        name="base-layer"
                        onChange={() => setActiveBaseLayer(layer)}
                        type="radio"
                      />
                      {baseLayerNames?.[index] || layer}
                    </label>
                  </li>
                );
              })}

            {toggleableLayers.map((layer: LayerProps, index: number) => {
              return (
                <li key={index}>
                  <label htmlFor={layer.id}>
                    <input
                      checked={!computedHiddenLayers.includes(layer.id)}
                      disabled={showEverything}
                      id={layer.id}
                      onChange={() => {
                        const updatedLayers = [...hiddenLayers];
                        // Delete the layer id if present, add it otherwise
                        updatedLayers.includes(layer.id)
                          ? updatedLayers.splice(
                              updatedLayers.indexOf(layer.id),
                              1
                            )
                          : updatedLayers.push(layer.id);

                        setHiddenLayers(updatedLayers);
                      }}
                      type="checkbox"
                    />
                    {layer.name || layer.id}
                  </label>
                </li>
              );
            })}
          </ul>
        </Styled.LayerSelector>
      )}
      {Array.isArray(children)
        ? children
            .flat(10)
            .filter(child => !computedHiddenLayers.includes(child?.props?.id))
        : children}
    </Map>
  );
};

export default BaseMap;

type LayerProps = React.ComponentPropsWithoutRef<React.ElementType> & {
  id: string;
  name?: string;
  visible?: boolean;
};
const LayerWrapper = (props: LayerProps): JSX.Element => {
  const { children, visible } = props;
  return <>{visible && children}</>;
};

export const Popup = Styled.Popup;

export { LayerWrapper, MarkerWithPopup, Styled, util };
