Source: modules/GeometryCopyHandler.js

/**
 * @module modules/GeometryCopyHandler.js
 * @name GeometryCopyHandler
 * @copyright 2024 3Liz
 * @license MPL-2.0
 */

import { mainLizmap, mainEventDispatcher } from './Globals.js';
import FeaturePickerPopup from './FeaturePickerPopup.js';
import { Utils } from './Utils.js';
import { Feature } from 'ol';
import { Point, LineString, Polygon, MultiPoint, MultiLineString, MultiPolygon } from 'ol/geom.js';

/**
 * Handles geometry copy workflow
 * @class
 */
export default class GeometryCopyHandler {
    constructor(map) {
        this._map = map;
        this._active = false;
        this._clickHandler = null;
        this._featurePickerPopup = new FeaturePickerPopup(map);
        this._drawControlWasActive = false;

        // Listen for edition form closed event to deactivate copy mode
        mainEventDispatcher.addListener(() => {
            if (this._active) {
                this.deactivate();
            }
        }, 'edition.formClosed');
    }

    /**
     * Activate copy mode
     */
    activate() {
        if (this._active) {
            return;
        }

        this._active = true;

        // Deactivate draw control to prevent creating vertices on map click
        if (mainLizmap?.edition?.drawControl && mainLizmap.edition.drawControl.active) {
            this._drawControlWasActive = true;
            mainLizmap.edition.drawControl.deactivate();
        }

        // Change cursor
        $('#map').css('cursor', 'crosshair');
        $('#newOlMap').css('cursor', 'crosshair');

        // Register click handler
        this._clickHandler = this._map.events.register('click', this, this._onMapClick);

        // Dispatch event
        mainEventDispatcher.dispatch({
            type: 'geometryCopy.activated'
        });
    }

    /**
     * Deactivate copy mode
     */
    deactivate() {
        if (!this._active) return;

        this._active = false;

        // Reactivate draw control if it was active before
        if (this._drawControlWasActive && mainLizmap?.edition?.drawControl) {
            mainLizmap.edition.drawControl.activate();
            this._drawControlWasActive = false;
        }

        // Reset cursor
        $('#map').css('cursor', 'default');
        $('#newOlMap').css('cursor', '');

        // Unregister click handler
        if (this._clickHandler) {
            this._map.events.unregister('click', this, this._onMapClick);
            this._clickHandler = null;
        }

        // Hide popup if open
        this._featurePickerPopup.hide();

        // Dispatch event
        mainEventDispatcher.dispatch({
            type: 'geometryCopy.deactivated'
        });
    }

    /**
     * Handle map click
     * @param {object} event - Map click event
     */
    _onMapClick(event) {
        const position = event.xy;
        const coordinate = this._map.getLonLatFromPixel(position);

        // Query features at this position
        this._queryFeaturesAtPosition(coordinate, position);
    }

    /**
     * Query all visible layers for features at position via WMS GetFeatureInfo
     * @param {object} coordinate - Lon/lat coordinate
     * @param {object} pixelPosition - Pixel position
     */
    _queryFeaturesAtPosition(coordinate, pixelPosition) {
        // Close any existing popup before opening a new one
        this._featurePickerPopup.hide();

        // Get all visible layers from mainLizmap state
        let candidateLayers = mainLizmap.state.rootMapGroup.findMapLayers()
            .toSorted((a, b) => b.layerOrder - a.layerOrder);

        // Only request visible layers
        candidateLayers = candidateLayers.filter(layer => layer.visibility);

        // Only request layers with 'popup' checked (queryable layers)
        candidateLayers = candidateLayers.filter(layer => {
            const layerCfg = layer.layerConfig;
            return layerCfg && layerCfg.popup;
        });

        if (!candidateLayers.length) {
            lizMap.addMessage('No queryable layers found. Please enable popup on layers you want to copy from.', 'info', true);
            this.deactivate();
            return;
        }

        const layersNames = [];
        const layersStyles = [];
        const filterTokens = [];
        const legendOn = [];
        const legendOff = [];

        for (const layer of candidateLayers) {
            const layerWmsParams = layer.wmsParameters;
            layersNames.push(layerWmsParams['LAYERS']);
            layersStyles.push(layerWmsParams['STYLES']);
            if ('FILTERTOKEN' in layerWmsParams) {
                filterTokens.push(layerWmsParams['FILTERTOKEN']);
            }
            if ('LEGEND_ON' in layerWmsParams) {
                legendOn.push(layerWmsParams['LEGEND_ON']);
            }
            if ('LEGEND_OFF' in layerWmsParams) {
                legendOff.push(layerWmsParams['LEGEND_OFF']);
            }
        }

        // Get map dimensions and extent from OL2 map
        const mapSize = this._map.getSize();
        const extent = this._map.getExtent();
        const projection = this._map.getProjection();

        const wmsParams = {
            QUERY_LAYERS: layersNames.join(','),
            LAYERS: layersNames.join(','),
            STYLE: layersStyles.join(','),
            INFO_FORMAT: 'text/html',
            CRS: projection,
            BBOX: extent.toBBOX(),
            WIDTH: mapSize.w,
            HEIGHT: mapSize.h,
            FEATURE_COUNT: 100,
            I: Math.round(pixelPosition.x),
            J: Math.round(pixelPosition.y),
            FI_POINT_TOLERANCE: 25,
            FI_LINE_TOLERANCE: 10,
            FI_POLYGON_TOLERANCE: 5
        };

        if (filterTokens.length) {
            wmsParams.FILTERTOKEN = filterTokens.join(';');
        }
        if (legendOn.length) {
            wmsParams.LEGEND_ON = legendOn.join(';');
        }
        if (legendOff.length) {
            wmsParams.LEGEND_OFF = legendOff.join(';');
        }

        $('#newOlMap').css('cursor', 'wait');

        // Use fetchHTML to get the standard Lizmap popup HTML response
        Utils.fetchHTML(globalThis['lizUrls'].wms, {
            method: "POST",
            body: new URLSearchParams({
                repository: globalThis['lizUrls'].params.repository,
                project: globalThis['lizUrls'].params.project,
                SERVICE: 'WMS',
                REQUEST: 'GetFeatureInfo',
                VERSION: '1.3.0',
                ...wmsParams
            })
        }).then(response => {
            $('#newOlMap').css('cursor', 'crosshair');
            this._handleWMSResponse(response, pixelPosition);
        }).catch(() => {
            $('#newOlMap').css('cursor', 'crosshair');
            lizMap.addMessage('Error querying features', 'error', true);
            this.deactivate();
        });
    }

    /**
     * Handle WMS GetFeatureInfo HTML response
     * @param {string} htmlResponse - HTML response from WMS GetFeatureInfo
     * @param {object} pixelPosition - Pixel position where user clicked
     */
    _handleWMSResponse(htmlResponse, pixelPosition) {
        const features = [];

        // Get current editing layer's geometry type for filtering
        const editingGeometryType = this._getEditingGeometryType();

        // Parse HTML response
        const $html = $('<div>').html(htmlResponse);
        const $featureDivs = $html.find('.lizmapPopupSingleFeature');

        $featureDivs.each((index, featureDiv) => {
            try {
                const $featureDiv = $(featureDiv);

                // Extract layer name from lizmapPopupTitle
                const layerName = $featureDiv.find('.lizmapPopupTitle').text().trim() || 'Unknown Layer';

                // Extract layer ID and feature ID from data attributes
                const layerId = $featureDiv.attr('data-layer-id') || '';
                const featureId = $featureDiv.attr('data-feature-id') || 'Unknown';

                // Extract geometry WKT from hidden input
                const geometryWKT = $featureDiv.find('.lizmap-popup-layer-feature-geometry').val();

                if (!geometryWKT) {
                    return;
                }

                // Convert WKT to OL2 geometry
                const ol2Geometry = OpenLayers.Geometry.fromWKT(geometryWKT);

                if (!ol2Geometry) {
                    return;
                }

                const geometryType = this._getGeometryType(ol2Geometry);

                // Filter by geometry type - only add features that match the editing layer's geometry type
                if (editingGeometryType && !this._geometryTypesMatch(geometryType, editingGeometryType)) {
                    return;
                }

                // Create OL2 feature
                const ol2Feature = new OpenLayers.Feature.Vector(ol2Geometry);
                ol2Feature.fid = featureId;

                features.push({
                    layerName: layerName,
                    layerId: layerId,
                    featureLabel: featureId,
                    geometryType: geometryType,
                    geometry: ol2Geometry,
                    feature: ol2Feature
                });
            } catch {
                // Silently skip features that fail to parse
            }
        });

        if (features.length > 0) {
            this._showFeaturePicker(features, pixelPosition);
        } else {
            lizMap.addMessage('No compatible features found at this location', 'info', true);
            this.deactivate();
        }
    }

    /**
     * Get feature label for display
     * @param {object} feature - Feature object
     * @returns {string} Feature label
     */
    _getFeatureLabel(feature) {
        // Try to get a meaningful label from attributes
        if (feature.attributes) {
            const labelAttrs = ['name', 'label', 'title', 'id', 'fid', 'pk_uid'];
            for (const attr of labelAttrs) {
                if (feature.attributes[attr] !== undefined && feature.attributes[attr] !== null) {
                    return String(feature.attributes[attr]);
                }
            }
        }
        return feature.fid || feature.id || 'Feature';
    }

    /**
     * Get geometry type name
     * @param {object} geometry - Geometry object
     * @returns {string} Geometry type name
     */
    _getGeometryType(geometry) {
        if (!geometry) return 'Unknown';
        return geometry.CLASS_NAME.replace('OpenLayers.Geometry.', '');
    }

    /**
     * Show feature picker popup
     * @param {Array} features - Array of features
     * @param {object} pixelPosition - Pixel position
     */
    _showFeaturePicker(features, pixelPosition) {
        this._featurePickerPopup.show(
            features,
            pixelPosition,
            (selectedFeature) => {
                this._onFeatureSelected(selectedFeature);
            }
        );
    }

    /**
     * Handle feature selection from popup
     * @param {object} featureData - Selected feature data
     */
    _onFeatureSelected(featureData) {
        // Convert OL2 geometry to OL6 format
        const ol6Feature = this._convertToOL6Feature(featureData.feature);

        // Store in FeatureStorage
        mainLizmap.featureStorage.copy([ol6Feature], {
            geometryType: featureData.geometryType,
            sourceLayer: featureData.layerName,
            sourceCRS: this._map.projection
        });

        // Apply to current editing feature
        this._applyGeometryToEditing(featureData.geometry);

        // Visual feedback
        lizMap.addMessage('Geometry copied successfully', 'info', true);

        // Deactivate copy mode
        this.deactivate();
    }

    /**
     * Convert OL2 feature to OL6 format
     * @param {object} ol2Feature - OpenLayers 2 feature
     * @returns {object | null} OpenLayers 6 feature or null
     */
    _convertToOL6Feature(ol2Feature) {
        const geometry = ol2Feature.geometry;
        const geomType = this._getGeometryType(geometry);

        let olGeometry;

        switch(geomType) {
            case 'Point':
                olGeometry = new Point([geometry.x, geometry.y]);
                break;

            case 'LineString':
                if (geometry.components && geometry.components.length > 0) {
                    olGeometry = new LineString(
                        geometry.components.map(pt => [pt.x, pt.y])
                    );
                }
                break;

            case 'Polygon':
                if (geometry.components && geometry.components.length > 0) {
                    olGeometry = new Polygon(
                        geometry.components.map(ring =>
                            ring.components.map(pt => [pt.x, pt.y])
                        )
                    );
                }
                break;

            case 'MultiPoint':
                if (geometry.components && geometry.components.length > 0) {
                    olGeometry = new MultiPoint(
                        geometry.components.map(pt => [pt.x, pt.y])
                    );
                }
                break;

            case 'MultiLineString':
                if (geometry.components && geometry.components.length > 0) {
                    olGeometry = new MultiLineString(
                        geometry.components.map(line =>
                            line.components.map(pt => [pt.x, pt.y])
                        )
                    );
                }
                break;

            case 'MultiPolygon':
                if (geometry.components && geometry.components.length > 0) {
                    olGeometry = new MultiPolygon(
                        geometry.components.map(poly =>
                            poly.components.map(ring =>
                                ring.components.map(pt => [pt.x, pt.y])
                            )
                        )
                    );
                }
                break;

            default:
                console.warn('Unsupported geometry type for conversion:', geomType);
                return null;
        }

        if (!olGeometry) {
            console.error('Failed to convert geometry');
            return null;
        }

        return new Feature({ geometry: olGeometry });
    }

    /**
     * Apply copied geometry to current editing feature
     * @param {object} geometry - Geometry to apply
     */
    _applyGeometryToEditing(geometry) {
        if (!mainLizmap.edition) {
            return;
        }

        const geomClone = geometry.clone();
        const feature = new OpenLayers.Feature.Vector(geomClone);

        // Try to apply to draw control layer (for new features being drawn)
        if (mainLizmap.edition.drawControl && mainLizmap.edition.drawControl.layer) {
            mainLizmap.edition.drawControl.layer.removeAllFeatures();
            mainLizmap.edition.drawControl.layer.addFeatures([feature]);
        }

        // Try to apply to modify control layer (for existing features being edited)
        if (mainLizmap.edition.modifyFeatureControl &&
            mainLizmap.edition.modifyFeatureControl.active &&
            mainLizmap.edition.modifyFeatureControl.layer) {
            mainLizmap.edition.modifyFeatureControl.layer.destroyFeatures();
            mainLizmap.edition.modifyFeatureControl.layer.addFeatures([feature]);
        }

        // Try to find editLayer directly (legacy approach)
        const editLayers = this._map.getLayersByName('editLayer');
        if (editLayers && editLayers.length > 0) {
            editLayers[0].removeAllFeatures();
            editLayers[0].addFeatures([feature]);
        }

        // Update geometry field in form if available
        if (lizMap.edition && typeof lizMap.edition.updateGeometryColumnFromFeature === 'function') {
            lizMap.edition.updateGeometryColumnFromFeature(feature);
        }
    }

    /**
     * Check if copy mode is active
     * @returns {boolean} True if active
     */
    isActive() {
        return this._active;
    }

    /**
     * Get the geometry type of the current editing layer
     * @returns {string|null} Geometry type or null
     */
    _getEditingGeometryType() {
        // Try to get from mainLizmap.edition.layerGeometry
        if (mainLizmap?.edition?.layerGeometry) {
            return mainLizmap.edition.layerGeometry;
        }

        // Try to get from layer config using layer ID
        if (mainLizmap?.edition?.layerId && lizMap?.config?.editionLayers) {
            const layerId = mainLizmap.edition.layerId;

            // editionLayers is an object with layer names as keys, but each has a layerId property
            // We need to find the config where layerId matches
            for (const config of Object.values(lizMap.config.editionLayers)) {
                if (config.layerId === layerId && config.geometryType) {
                    return config.geometryType;
                }
            }
        }

        // Try to get from legacy lizMap.edition
        if (lizMap?.edition?.layerConfig?.geometryType) {
            return lizMap.edition.layerConfig.geometryType;
        }

        // Try to get from lizMap.map editLayer
        const editLayers = this._map.getLayersByName('editLayer');
        if (editLayers && editLayers.length > 0 && editLayers[0].features && editLayers[0].features.length > 0) {
            const feature = editLayers[0].features[0];
            if (feature.geometry) {
                return this._getGeometryType(feature.geometry);
            }
        }

        // Try getting from current draw control
        if (mainLizmap?.edition?.drawControl?.handler?.CLASS_NAME) {
            const handlerClass = mainLizmap.edition.drawControl.handler.CLASS_NAME;

            if (handlerClass.includes('Point')) return 'Point';
            if (handlerClass.includes('Path') || handlerClass.includes('Line')) return 'LineString';
            if (handlerClass.includes('Polygon')) return 'Polygon';
        }

        return null;
    }

    /**
     * Check if two geometry types are compatible
     * @param {string} geomType1 - First geometry type
     * @param {string} geomType2 - Second geometry type
     * @returns {boolean} True if compatible
     */
    _geometryTypesMatch(geomType1, geomType2) {
        if (!geomType1 || !geomType2) return true;

        // Normalize geometry type names (case insensitive)
        const type1 = geomType1.toLowerCase().replace('multi', '');
        const type2 = geomType2.toLowerCase().replace('multi', '');

        // Point matches Point and MultiPoint
        // LineString matches LineString and MultiLineString
        // Polygon matches Polygon and MultiPolygon
        return type1 === type2;
    }
}