/**
 * @license
 * Copyright 2000-2024 Vaadin Ltd.
 *
 * This program is available under Vaadin Commercial License and Service Terms.
 *
 * See <https://vaadin.com/commercial-license-and-service-terms> for the full
 * license.
 */
import Translate from 'ol/interaction/Translate';
import { setUserProjection as openLayersSetUserProjection } from 'ol/proj';
import { register as openLayersRegisterProjections } from 'ol/proj/proj4';
import proj4 from 'proj4';
import { synchronize } from './synchronization';
import { createLookup, getLayerForFeature } from './util';

// By default, use EPSG:4326 projection for all coordinates passed to, and return from the public API.
// Internally coordinates will be converted to the projection used by the map's view.
openLayersSetUserProjection('EPSG:4326');

function init(mapElement) {
  mapElement.$connector = {
    /**
     * Lookup for storing and retrieving every OL instance used in the map's configuration
     * by its unique ID
     */
    lookup: createLookup(),
    /**
     * Synchronizes an array of Javascript objects into OL instances.
     * It is expected that objects that are lower in the configuration hierarchy occur
     * earlier in the array than objects that are higher in the hierarchy. That ensures
     * that lower-level objects are synchronized first, before higher-level objects that
     * reference them.
     * @param changedObjects array of Javascript objects to be synchronized into OL instances
     */
    synchronize(changedObjects) {
      // Provide synchronization function and the OL instance lookup through context object
      const context = { synchronize, lookup: this.lookup, mapElement, connector: mapElement.$connector };

      changedObjects.forEach((change) => {
        // The OL map instance already exists and should not be created by the
        // synchronization mechanism. So we put it into the lookup manually.
        if (change.type === 'ol/Map') {
          this.lookup.put(change.id, mapElement.configuration);
        }

        synchronize(change, context);
      });
    },
    /**
     * Forces a render of the OpenLayers map. Some objects in OpenLayers are not observable
     * and do not trigger change events, for example Style objects or any of their children.
     * In these cases this method can be called from the synchronization functions of these
     * objects.
     * This method will trigger a debounced render of the map by firing a change event from
     * each layer. We simply render all layers as a sync. function does not know which layer
     * its synced object is in. Even if the change event is fired from multiple layers, this
     * only results in a single render of the map.
     */
    forceRender() {
      if (this._forceRenderTimeout) {
        return;
      }
      this._forceRenderTimeout = setTimeout(() => {
        this._forceRenderTimeout = null;
        mapElement.configuration
          .getLayers()
          .getArray()
          .forEach((layer) => layer.changed());
      });
    }
  };

  mapElement.configuration.on('moveend', (_event) => {
    const view = mapElement.configuration.getView();
    const center = view.getCenter();
    const rotation = view.getRotation();
    const zoom = view.getZoom();
    const extent = view.calculateExtent();

    const customEvent = new CustomEvent('map-view-moveend', {
      detail: {
        center,
        rotation,
        zoom,
        extent
      }
    });

    mapElement.dispatchEvent(customEvent);
  });

  mapElement.configuration.on('singleclick', (event) => {
    const coordinate = event.coordinate;
    // Get the features at the clicked pixel position
    // In case multiple features exist at that position, OpenLayers
    // returns the features sorted in the order that they are displayed,
    // with the front-most feature as the first result, and the
    // back-most feature as the last result
    const pixelCoordinate = event.pixel;
    const featuresAtPixel = mapElement.configuration.getFeaturesAtPixel(pixelCoordinate);
    // Create tuples of features and the layer that they are in
    const featuresAndLayers = featuresAtPixel.map((feature) => {
      const layer = getLayerForFeature(mapElement.configuration.getLayers().getArray(), feature);
      return {
        feature,
        layer
      };
    });

    // Map click event
    const mapClickEvent = new CustomEvent('map-click', {
      detail: {
        coordinate,
        features: featuresAndLayers,
        originalEvent: event.originalEvent
      }
    });

    mapElement.dispatchEvent(mapClickEvent);

    // Feature click event
    if (featuresAndLayers.length > 0) {
      // Send a feature click event for the top-level feature
      const featureAndLayer = featuresAndLayers[0];
      const featureClickEvent = new CustomEvent('map-feature-click', {
        detail: {
          feature: featureAndLayer.feature,
          layer: featureAndLayer.layer,
          originalEvent: event.originalEvent
        }
      });

      mapElement.dispatchEvent(featureClickEvent);
    }
  });

  // Feature drag&drop
  const translate = new Translate({
    filter(feature) {
      return !!feature.draggable;
    }
  });
  translate.on('translateend', (event) => {
    const feature = event.features.item(0);
    if (!feature) return;
    const layer = getLayerForFeature(mapElement.configuration.getLayers().getArray(), feature);

    const featureDropEvent = new CustomEvent('map-feature-drop', {
      detail: {
        feature,
        layer,
        coordinate: event.coordinate,
        startCoordinate: event.startCoordinate
      }
    });

    mapElement.dispatchEvent(featureDropEvent);
  });

  mapElement.configuration.addInteraction(translate);
}

/**
 * Set a custom user projection for all coordinates passing through the public API.
 * Internally coordinates will be converted to the projection used by the map's view.
 * @param projection
 */
function setUserProjection(projection) {
  openLayersSetUserProjection(projection);
}

/**
 * Define a coordinate projection that can be used as view or user projection.
 * Projection definitions must be provided in the WKT (well known text) format.
 * Internally the proj4 library is used to define the projection, which is then
 * integrated with OpenLayers.
 * @param projection
 * @param wksDefinition
 */
function defineProjection(projection, wksDefinition) {
  // Define projection in proj4, and then integrate it with OpenLayers
  // There should not be any side effects from calling either multiple times
  proj4.defs(projection, wksDefinition);
  openLayersRegisterProjections(proj4);
}

window.Vaadin.Flow.mapConnector = {
  init,
  setUserProjection,
  defineProjection
};
