/**
 * @license
 * Copyright 2000-2026 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 { extend } from 'ol/extent';
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/index.js';
import { createLookup, getFeatureInfo } 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) {
  // Clear default controls from web component, so that we can cleanly synchronize controls from server configuration
  // Consider changing this in the future, so that the web component comes without default controls
  mapElement.configuration.getControls().clear();

  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());
      });
    },
    /**
     * Adjust the map view to fit the given features.
     * @param featureIds array of IDs of the features to fit in the map view
     * @param options optional options for the OL `View.fit` method
     */
    zoomToFit(featureIds, options) {
      // Synchronization call for the referenced features must run beforehand, but may have
      // been scheduled after this call. Using a timeout to compensate.
      setTimeout(() => {
        const features = featureIds.map((id) => this.lookup.get(id)).filter(Boolean);
        if (features.length === 0) {
          return;
        }

        let extent;
        features.forEach((feature) => {
          const featureExtent = feature.getGeometry().getExtent();
          if (!extent) {
            extent = featureExtent.slice();
          } else {
            extend(extent, featureExtent);
          }
        });

        const padding = options?.padding ?? 0;
        const duration = options?.duration ?? 0;
        // Viewport size in the OL View instance may not have updated yet to the actual map size,
        // so provide the size explicitly
        const bounds = mapElement.getBoundingClientRect();
        const size = [bounds.width, bounds.height];

        mapElement.configuration.getView().fit(extent, {
          padding: [padding, padding, padding, padding],
          duration,
          size
        });
      });
    }
  };

  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);
    const featureInfos = featuresAtPixel.map((feature) => getFeatureInfo(mapElement.configuration, feature));

    // Map click event
    const nonClusterFeatures = featureInfos.filter((info) => info && !info.isCluster);
    const mapClickEvent = new CustomEvent('map-click', {
      detail: {
        coordinate,
        features: nonClusterFeatures,
        originalEvent: event.originalEvent
      }
    });

    mapElement.dispatchEvent(mapClickEvent);

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

      mapElement.dispatchEvent(featureClickEvent);
    }

    // Cluster click event
    const clusterInfos = featureInfos.filter((info) => info && info.isCluster);
    if (clusterInfos.length > 0) {
      // Send a cluster click event for the top-level cluster
      const clusterInfo = clusterInfos[0];
      const clusterClickEvent = new CustomEvent('map-cluster-click', {
        detail: {
          features: clusterInfo.feature.get('features'),
          layer: clusterInfo.layer,
          originalEvent: event.originalEvent
        }
      });

      mapElement.dispatchEvent(clusterClickEvent);
    }
  });

  // 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 featureInfo = getFeatureInfo(mapElement.configuration, feature);
    const featureDropEvent = new CustomEvent('map-feature-drop', {
      detail: {
        feature,
        layer: featureInfo.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
};
