// Copyright (c) 2022 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

// libraries
import React, {Component, createRef} from 'react';
import MapboxGLMap from 'react-map-gl';
import DeckGL from '@deck.gl/react';
import {createSelector} from 'reselect';
import {OPERATION, Layer} from '@deck.gl/core';
import {SolidPolygonLayer} from '@deck.gl/layers';
import {TileLayer} from '@deck.gl/geo-layers';
import {errorNotification} from 'utils/notifications-utils';

import * as VisStateActions from 'actions/vis-state-actions';
import * as MapStateActions from 'actions/map-state-actions';
import * as UIStateActions from 'actions/ui-state-actions';

// components
import MapPopoverFactory from 'components/map/map-popover';
import MapControlFactory from 'components/map/map-control';
import {StyledMapContainer} from 'components/common/styled-components';

import EditorFactory from './editor/editor';

// utils
import {generateMapboxLayers} from 'layers/mapbox-utils';
import {setLayerBlending} from 'utils/gl-utils';
import {transformRequest} from 'utils/map-style-utils/mapbox-utils';
import {renderDeckGlLayer, prepareLayersToRender, prepareLayersForDeck} from 'utils/layer-utils';

// default-settings
import ThreeDBuildingLayer from 'deckgl-layers/3d-building-layer/3d-building-layer';
import {FILTER_TYPES, MASK_LAYER_ID, THROTTLE_NOTIFICATION_TIME} from 'constants/default-settings';

import {observeDimensions, unobserveDimensions} from '../utils/observe-dimensions';
import {LOCALE_CODES} from 'localization/locales';
import {
  Datasets,
  Filter,
  InteractionConfig,
  MapControls,
  MapState,
  MapStyle,
  Viewport
} from 'reducers';
import {SplitMapLayers} from 'reducers/vis-state-updaters';
import {LayerBaseConfig, VisualChannelDomain} from 'layers/base-layer';

// Performance debugging
import {createTracker, createLayerUpdateTracker, renderPerformanceStats} from './performance';
// @ts-expect-error
const trackers: {drawCount?: ReturnType<createTracker>, viewportChangeCount?: ReturnType<createTracker>} = {};
const PERF_DEBUG = window?.location?.search?.includes('PERF_DEBUG');
if (PERF_DEBUG) {
  trackers.drawCount = createTracker('draws');
  trackers.viewportChangeCount = createTracker('viewport changes');
  createLayerUpdateTracker('state updates', [Layer, TileLayer]);
}

/** @type {{[key: string]: React.CSSProperties}} */
const MAP_STYLE: {[key: string]: React.CSSProperties} = {
  container: {
    display: 'inline-block',
    position: 'relative',
    width: '100%',
    height: '100%'
  },
  top: {
    position: 'absolute',
    top: '0px',
    pointerEvents: 'none',
    width: '100%',
    height: '100%'
  }
};

const TRANSITION_DURATION = 0;

MapContainerFactory.deps = [MapPopoverFactory, MapControlFactory, EditorFactory];

type MapboxStyle = string | object | undefined;

interface MapContainerProps {
  datasets: Datasets;
  interactionConfig: InteractionConfig;
  layerBlending: string;
  layerOrder: number[];
  layerData: any[];
  layers: Layer[];
  filters: Filter[];
  mapState: MapState;
  mapControls: MapControls;
  mapStyle: {bottomMapStyle?: MapboxStyle; topMapStyle?: MapboxStyle} & MapStyle;
  mousePos: any;
  mapboxApiAccessToken: string;
  mapboxApiUrl: string;
  visStateActions: typeof VisStateActions;
  mapStateActions: typeof MapStateActions;
  uiStateActions: typeof UIStateActions;

  // optional
  primary?: boolean; // primary one will be reporting its size to appState
  readOnly?: boolean;
  isExport?: boolean;
  clicked?: any;
  hoverInfo?: any;
  mapLayers?: SplitMapLayers | null;
  onMapToggleLayer?: Function;
  onMapStyleLoaded?: Function;
  onMapRender?: Function;
  getMapboxRef?: (mapbox?: typeof MapboxGLMap | null, index?: number) => void;
  index?: number;

  locale?: any;
  editor?: any;
  MapComponent?: typeof MapboxGLMap;
  deckGlProps?: any;
  onDeckInitialized?: (a: any, b: any) => void;
  onViewStateChange?: (viewport: Viewport) => void;

  generateThumbnail?: Function;
  popupSettings?: any;
  maskPolygon?: any;
}

export default function MapContainerFactory(
  MapPopover,
  MapControl,
  Editor
): React.ComponentType<MapContainerProps> {
  class MapContainer extends Component<MapContainerProps> {
    static propTypes = {
      // required
    };

    static defaultProps = {
      MapComponent: MapboxGLMap,
      deckGlProps: {},
      index: 0,
      primary: true
    };

    previousLayers = {
      // [layers.id]: mapboxLayerConfig
    };

    displayName = 'MapContainer';

    _deck: any = null;
    _map: mapboxgl.Map | null = null;
    _ref = createRef<HTMLDivElement>();
    _deckGLErrorsElapsed: {[id: string]: number} = {};

    _hasRendered: React.MutableRefObject<boolean> = createRef<boolean>();
    _mousePosition: React.MutableRefObject<any> = createRef<any>();

    constructor(props) {
      super(props);
    }

    componentDidMount() {
      if (!this._ref.current) {
        return;
      }
      observeDimensions(this._ref.current, this._handleResize);
    }

    componentWillUnmount() {
      unobserveDimensions(this._ref.current as Element);
    }

    componentDidUpdate() {
      const contextLost = (this.state as any)?.contextLost;

      if (contextLost) {
        // eslint-disable-next-line react/no-did-update-set-state
        this.setState({contextLost: false});
      }
    }

    _handleResize = dimensions => {
      const {primary, isExport} = this.props;
      if (primary) {
        const {mapStateActions} = this.props;
        if (!isExport && dimensions && dimensions.width > 0 && dimensions.height > 0) {
          mapStateActions.updateMap(dimensions);
        }
      }
    };

    layersSelector = props => props.layers;
    layerDataSelector = props => props.layerData;
    mapLayersSelector = props => props.mapLayers;
    layerOrderSelector = props => props.layerOrder;
    popupSettingsSelector = props => props.popupSettings;

    layersToRenderSelector = createSelector(
      this.layersSelector,
      this.layerDataSelector,
      this.mapLayersSelector,
      this.popupSettingsSelector,
      prepareLayersToRender
    );
    layersForDeckSelector = createSelector(
      this.layersSelector,
      this.layerDataSelector,
      prepareLayersForDeck
    );
    filtersSelector = props => props.filters;
    polygonFilters = createSelector(this.filtersSelector, filters =>
      filters.filter(f => f.type === FILTER_TYPES.polygon)
    );

    mapboxLayersSelector = createSelector(
      this.layersSelector,
      this.layerDataSelector,
      this.layerOrderSelector,
      this.layersToRenderSelector,
      generateMapboxLayers
    );

    /* component private functions */
    _onCloseMapPopover = () => {
      this.props.visStateActions.onLayerClick(null);
    };

    _onLayerSetDomain = (idx: number, colorDomain: VisualChannelDomain) => {
      // @ts-expect-error
      this.props.visStateActions.layerConfigChange(this.props.layers[idx], {
        colorDomain
      } as Partial<LayerBaseConfig>);
    };

    _handleMapToggleLayer = layerId => {
      const {index: mapIndex = 0, visStateActions} = this.props;
      visStateActions.toggleLayerForMap(mapIndex, layerId);
    };

    _onDeckInitialized(gl) {
      if (this.props.onDeckInitialized) {
        this.props.onDeckInitialized(this._deck, gl);
      }
    }

    _onBeforeRender = ({gl}) => {
      setLayerBlending(gl, this.props.layerBlending);
    };

    _onDeckError = (error, layer) => {
      if (error?.message === 'WebGL context is lost') {
        this.setState({contextLost: true});
        return;
      }

      const errorMessage = `An error in deck.gl: ${error?.message} in ${layer?.id}`;
      const notificationId = `${layer?.id}-${error?.message}`;

      // Throttle error notifications, as React doesn't like too many state changes from here.
      const lastShown = this._deckGLErrorsElapsed[notificationId];
      if (!lastShown || lastShown < Date.now() - THROTTLE_NOTIFICATION_TIME) {
        this._deckGLErrorsElapsed[notificationId] = Date.now();

        // Create new error notification or update existing one with same id.
        // Update is required to preserve the order of notifications as they probably are going to "jump" based on order of errors.
        const {uiStateActions} = this.props;
        uiStateActions.addNotification(
          errorNotification({
            message: errorMessage,
            id: notificationId
          })
        );
      }
    };

    /* component render functions */

    /* eslint-disable complexity */
    _renderMapPopover(layersToRender) {
      // Overriden in Builder: features/builder/ui/KeplerGl/MapContainer/MapContainer.tsx
      return null;
    }

    /* eslint-enable complexity */

    _getHoverXY(viewport, lngLat) {
      const screenCoord = !viewport || !lngLat ? null : viewport.project(lngLat);
      return screenCoord && {x: screenCoord[0], y: screenCoord[1]};
    }

    _renderDeckOverlay(layersForDeck) {
      const {
        mapState,
        mapStyle,
        layerData,
        layerOrder,
        layers,
        visStateActions,
        mapboxApiUrl,
        maskPolygon
      } = this.props;

      const mousePosition = this._mousePosition;

      trackers.drawCount?.increment();

      // initialise layers from props if exists
      let deckGlLayers = this.props.deckGlProps?.layers || [];

      // wait until data is ready before render data layers
      if (layerData && layerData.length) {
        // last layer render first
        const dataLayers = layerOrder
          .slice()
          .reverse()
          .filter(idx => layersForDeck[layers[idx].id])
          .reduce((overlays, idx) => {
            const layerCallbacks = {
              onSetLayerDomain: val => this._onLayerSetDomain(idx, val)
            };
            const layerOverlay = renderDeckGlLayer(this.props, layerCallbacks, idx);
            return overlays.concat(layerOverlay || []);
          }, []);
        deckGlLayers = deckGlLayers.concat(dataLayers);
      }

      if (this.props.deckGlProps?.layersOnTop) {
        deckGlLayers = deckGlLayers.concat(this.props.deckGlProps.layersOnTop);
      }

      if (maskPolygon) {
        deckGlLayers.push(
          new SolidPolygonLayer({
            id: MASK_LAYER_ID,
            operation: OPERATION.MASK,
            data: [maskPolygon],
            dataComparator: (data, oldData) => data[0] === oldData[0],
            getPolygon: d => d,
            getFillColor: [255, 255, 255, 255]
          })
        );
      }

      if (mapStyle.visibleLayerGroups['3d building']) {
        deckGlLayers.push(
          new (ThreeDBuildingLayer as any)({
            id: '_keplergl_3d-building',
            mapboxApiUrl,
            threeDBuildingColor: mapStyle.threeDBuildingColor,
            updateTriggers: {
              getFillColor: mapStyle.threeDBuildingColor
            }
          })
        );
      }

      // Limit renders
      const hasRendered = this._hasRendered;
      const resetHasRendered = () => (hasRendered.current = false);

      return (
        <DeckGL
          controller={true}
          {...this.props.deckGlProps}
          glOptions={{preserveDrawingBuffer: true}}
          viewState={mapState}
          onViewStateChange={({viewState}) => {
            // Limit updates (prevents freezes when dev tools open)
            // @ts-expect-error
            if (!hasRendered.current && viewState.width > 1 && viewState.height > 1) {
              hasRendered.current = true;
              this._onViewportChange(viewState);
            }
          }}
          id="default-deckgl-overlay"
          layers={deckGlLayers}
          onBeforeRender={opts => {
            resetHasRendered();
            this._onBeforeRender(opts);
          }}
          // onHover={visStateActions.onLayerHover}
          onHover={info => {
            visStateActions.onLayerHover(info);
            mousePosition.current = {mousePosition: info.pixel};
          }}
          onClick={visStateActions.onLayerClick}
          onDragStart={() => {
            mousePosition.current = null;
          }}
          onError={this._onDeckError}
          ref={comp => {
            if (comp && comp.deck && comp.deck !== this._deck) {
              this._deck = comp.deck;
            }
          }}
          onWebGLInitialized={gl => this._onDeckInitialized(gl)}
          layerFilter={({layer, viewport}) => {
            if (
              // @ts-expect-error
              isNaN(layer.props.visibilityByZoom?.min) ||
              // @ts-expect-error
              isNaN(layer.props.visibilityByZoom?.max)
            ) {
              return true;
            }
            const currentZoomRange = Math.round(mapState.zoom);

            return (
              // @ts-expect-error
              layer.props.visibilityByZoom.min <= currentZoomRange &&
              // @ts-expect-error
              layer.props.visibilityByZoom.max >= currentZoomRange
            );
          }}
        />
      );
    }

    _onViewportChange = viewState => {
      trackers.viewportChangeCount?.increment();
      const {width, height, ...restViewState} = viewState;
      const {primary, isExport} = this.props;
      // react-map-gl sends 0,0 dimensions during initialization
      // after we have received proper dimensions from observeDimensions
      const next = {
        ...(width > 0 && height > 0 ? viewState : restViewState),
        // enabling transition in two maps may lead to endless update loops
        transitionDuration: primary ? TRANSITION_DURATION : 0
      };
      if (typeof this.props.onViewStateChange === 'function') {
        this.props.onViewStateChange(next);
      }

      if (!isExport) {
        this.props.mapStateActions.updateMap(next);
      }
    };

    _toggleMapControl = panelId => {
      const {index, uiStateActions} = this.props;

      uiStateActions.toggleMapControl(panelId, Number(index));
    };

    /* eslint-disable complexity */
    _renderMap() {
      const {
        mapState,
        mapStyle,
        mapStateActions,
        layers,
        MapComponent = MapboxGLMap,
        datasets,
        mapControls,
        isExport,
        locale,
        uiStateActions,
        visStateActions,
        interactionConfig,
        editor,
        index,
        primary,
        generateThumbnail
      } = this.props;

      const layersToRender = this.layersToRenderSelector(this.props);
      const layersForDeck = this.layersForDeckSelector(this.props);

      const mapProps = {
        ...mapState,
        width: '100%',
        height: '100%',
        preserveDrawingBuffer: true,
        transformRequest,
        generateThumbnail
      };

      const isSplit = Boolean(mapState.isSplit);

      return (
        <>
          <MapControl
            datasets={datasets}
            availableLocales={Object.keys(LOCALE_CODES)}
            dragRotate={mapState.dragRotate}
            isSplit={isSplit}
            primary={primary}
            isExport={isExport}
            layers={layers}
            layersToRender={layersToRender}
            mapIndex={index}
            mapControls={mapControls}
            readOnly={this.props.readOnly}
            scale={mapState.scale || 1}
            top={interactionConfig.geocoder && interactionConfig.geocoder.enabled ? 52 : 0}
            editor={editor}
            locale={locale}
            onTogglePerspective={mapStateActions.togglePerspective}
            onToggleSplitMap={mapStateActions.toggleSplitMap}
            onMapToggleLayer={this._handleMapToggleLayer}
            onToggleMapControl={this._toggleMapControl}
            onSetEditorMode={visStateActions.setEditorMode}
            onSetLocale={uiStateActions.setLocale}
            onToggleEditorVisibility={visStateActions.toggleEditorVisibility}
            mapHeight={mapState.height}
          />
          <MapComponent {...mapProps} key="bottom" mapStyle={mapStyle.bottomMapStyle}>
            {PERF_DEBUG && renderPerformanceStats()}
            {this._renderDeckOverlay(layersForDeck)}
          </MapComponent>
          {this._renderMapPopover(layersToRender)}
        </>
      );
    }

    render() {
      const {mapState, mapStyle} = this.props;
      const contextLost = (this.state as any)?.contextLost;

      if (contextLost) {
        return null;
      }

      return (
        <StyledMapContainer ref={this._ref} style={MAP_STYLE.container}>
          {mapStyle.bottomMapStyle && this._renderMap()}
        </StyledMapContainer>
      );
    }
  }

  return MapContainer;
}
